1use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26use std::time::{Duration, SystemTime};
27
28use bytes::Bytes;
29
30use camel_api::body::Body;
31use camel_api::cache::{CacheEntry, CacheRepository, ContentType};
32use camel_api::{CamelError, Exchange, OutcomePipeline, OutcomeSegment, PipelineOutcome};
33use camel_component_api::RuntimeObservability;
34
35use crate::MessageIdExpression;
36
37#[derive(Clone)]
45pub(crate) enum CoalesceTerminal {
46 Completed(Body),
48 Failed(CamelError),
50 Stopped,
52}
53
54struct InFlight {
61 terminal: std::sync::Mutex<Option<CoalesceTerminal>>,
62 notify: tokio::sync::Notify,
63}
64
65impl Default for InFlight {
66 fn default() -> Self {
67 Self {
68 terminal: std::sync::Mutex::new(None),
69 notify: tokio::sync::Notify::new(),
70 }
71 }
72}
73
74impl InFlight {
75 fn publish(&self, terminal: CoalesceTerminal) {
79 if let Ok(mut slot) = self.terminal.lock()
80 && slot.is_none()
81 {
82 *slot = Some(terminal);
83 }
84 }
85
86 fn terminal_snapshot(&self) -> Option<CoalesceTerminal> {
88 match self.terminal.lock() {
89 Ok(slot) => (*slot).clone(),
90 Err(_) => None,
91 }
92 }
93}
94
95type InFlightMap = std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<InFlight>>>;
98
99struct LeaderGuard {
108 key: String,
109 map: Arc<InFlightMap>,
110 cell: Arc<InFlight>,
111}
112
113impl LeaderGuard {
114 fn retire(map: &Arc<InFlightMap>, key: &str, cell: &Arc<InFlight>) {
120 if let Ok(mut map) = map.lock()
121 && map
122 .get(key)
123 .is_some_and(|current| Arc::ptr_eq(current, cell))
124 {
125 map.remove(key);
126 }
127 }
128}
129
130impl Drop for LeaderGuard {
131 fn drop(&mut self) {
132 self.cell
134 .publish(CoalesceTerminal::Failed(CamelError::Config(
135 "cache coalesce leader cancelled".into(),
136 )));
137 self.cell.notify.notify_waiters();
138 Self::retire(&self.map, &self.key, &self.cell);
139 }
140}
141
142pub struct CacheService {
171 repository: Arc<dyn CacheRepository>,
172 repository_name: String,
174 key_expr: MessageIdExpression,
175 ttl: Option<Duration>,
176 max_entry_bytes: usize,
177 on_miss: OutcomeSegment,
178 rt: Arc<dyn RuntimeObservability>,
179 coalesce_misses: bool,
181 inflight: Arc<InFlightMap>,
184}
185
186impl CacheService {
187 pub fn new(
192 repository: Arc<dyn CacheRepository>,
193 key_expr: MessageIdExpression,
194 ttl: Option<Duration>,
195 max_entry_bytes: usize,
196 on_miss: OutcomeSegment,
197 rt: Arc<dyn RuntimeObservability>,
198 ) -> Self {
199 let repository_name = repository.name().to_string();
200 Self {
201 repository,
202 repository_name,
203 key_expr,
204 ttl,
205 max_entry_bytes,
206 on_miss,
207 rt,
208 coalesce_misses: false,
209 inflight: Arc::new(InFlightMap::default()),
210 }
211 }
212
213 pub fn with_coalesce(mut self, coalesce_misses: bool) -> Self {
219 self.coalesce_misses = coalesce_misses;
220 self
221 }
222
223 pub fn repository_name(&self) -> &str {
225 &self.repository_name
226 }
227}
228
229#[allow(clippy::too_many_arguments)]
237async fn write_back(
238 repository: &Arc<dyn CacheRepository>,
239 repository_name: &str,
240 max_entry_bytes: usize,
241 ttl: Option<Duration>,
242 exchange: Exchange,
243 key: &str,
244 serialized: Vec<u8>,
245 content_type: ContentType,
246) -> PipelineOutcome {
247 if serialized.len() <= max_entry_bytes {
248 let entry = CacheEntry {
249 bytes: serialized,
250 payload_path: None,
251 content_type,
252 expires_at: None,
253 };
254 match repository.set(key, entry, ttl).await {
255 Ok(()) => {}
256 Err(e) => {
257 if matches!(&e, CamelError::Config(msg) if msg.starts_with("cache: max_entries")) {
258 tracing::debug!(
259 repository = %repository_name,
260 key = %key,
261 "cache at capacity, skipping write-back"
262 ); } else {
264 return PipelineOutcome::Failed(e);
265 }
266 }
267 }
268 } else {
269 tracing::debug!(
271 repository = %repository_name,
272 key = %key,
273 len = serialized.len(),
274 max = max_entry_bytes,
275 "cache write-back skipped: body exceeds max_entry_bytes"
276 );
277 }
278 PipelineOutcome::Completed(exchange)
279}
280
281impl Clone for CacheService {
282 fn clone(&self) -> Self {
283 Self {
284 repository: Arc::clone(&self.repository),
285 repository_name: self.repository_name.clone(),
286 key_expr: Arc::clone(&self.key_expr),
287 ttl: self.ttl,
288 max_entry_bytes: self.max_entry_bytes,
289 on_miss: self.on_miss.clone(),
290 rt: Arc::clone(&self.rt),
291 coalesce_misses: self.coalesce_misses,
292 inflight: Arc::clone(&self.inflight),
293 }
294 }
295}
296
297impl OutcomePipeline for CacheService {
298 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
299 Box::new(self.clone())
300 }
301
302 fn run<'a>(
303 &'a mut self,
304 exchange: Exchange,
305 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
306 Box::pin(async move {
307 let key = match (self.key_expr)(&exchange) {
309 Some(k) => k,
310 None => return self.on_miss.run(exchange).await,
311 };
312
313 match self.repository.get(&key).await {
315 Err(e) => return PipelineOutcome::Failed(e),
316 Ok(Some(entry)) => {
317 self.rt.metrics().record_counter(
319 "camel.cache.hits",
320 1.0_f64,
321 &[("repository", &self.repository_name)],
322 );
323 match reconstruct_body(&entry) {
324 Ok(body) => {
325 let mut exchange = exchange;
326 exchange.input.body = body;
327 return PipelineOutcome::Completed(exchange);
328 }
329 Err(e) => return PipelineOutcome::Failed(e),
330 }
331 }
332 Ok(None) => {
333 self.rt.metrics().record_counter(
335 "camel.cache.misses",
336 1.0_f64,
337 &[("repository", &self.repository_name)],
338 );
339 }
340 }
341
342 if self.coalesce_misses {
344 return self.coalesced_miss(exchange, key).await;
345 }
346 self.run_miss(exchange, key).await
347 })
348 }
349}
350
351impl CacheService {
352 async fn run_miss(&mut self, exchange: Exchange, key: String) -> PipelineOutcome {
357 let mut exchange = match self.on_miss.run(exchange).await {
359 PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
360 PipelineOutcome::Failed(e) => return PipelineOutcome::Failed(e),
361 PipelineOutcome::Completed(ex) => ex,
362 };
363
364 let body = std::mem::replace(&mut exchange.input.body, Body::Empty);
366 match body {
367 Body::Bytes(b) => {
368 let serialized = b.to_vec();
369 exchange.input.body = Body::Bytes(b);
370 write_back(
371 &self.repository,
372 &self.repository_name,
373 self.max_entry_bytes,
374 self.ttl,
375 exchange,
376 &key,
377 serialized,
378 ContentType::Bytes,
379 )
380 .await
381 }
382 Body::Text(s) => {
383 let serialized = s.as_bytes().to_vec();
384 exchange.input.body = Body::Text(s);
385 write_back(
386 &self.repository,
387 &self.repository_name,
388 self.max_entry_bytes,
389 self.ttl,
390 exchange,
391 &key,
392 serialized,
393 ContentType::Text,
394 )
395 .await
396 }
397 Body::Json(v) => {
398 let serialized = match serde_json::to_vec(&v) {
399 Ok(b) => b,
400 Err(e) => {
401 exchange.input.body = Body::Json(v);
402 return PipelineOutcome::Failed(CamelError::TypeConversionFailed(
403 e.to_string(),
404 ));
405 }
406 };
407 exchange.input.body = Body::Json(v);
408 write_back(
409 &self.repository,
410 &self.repository_name,
411 self.max_entry_bytes,
412 self.ttl,
413 exchange,
414 &key,
415 serialized,
416 ContentType::Json,
417 )
418 .await
419 }
420 Body::Xml(s) => {
421 let serialized = s.as_bytes().to_vec();
422 exchange.input.body = Body::Xml(s);
423 write_back(
424 &self.repository,
425 &self.repository_name,
426 self.max_entry_bytes,
427 self.ttl,
428 exchange,
429 &key,
430 serialized,
431 ContentType::Xml,
432 )
433 .await
434 }
435 Body::Stream(stream_body) => {
436 let materialized = match Body::Stream(stream_body)
438 .into_bytes(self.max_entry_bytes)
439 .await
440 {
441 Ok(b) => b,
442 Err(e) => return PipelineOutcome::Failed(e),
443 };
444 let entry = CacheEntry {
446 bytes: materialized.to_vec(),
447 payload_path: None,
448 content_type: ContentType::Bytes,
449 expires_at: None,
450 };
451 if let Err(e) = self.repository.set(&key, entry, self.ttl).await {
452 if matches!(&e, CamelError::Config(msg) if msg.starts_with("cache: max_entries"))
454 {
455 tracing::debug!(
456 repository = %self.repository_name,
457 key = %key,
458 "cache at capacity, skipping write-back for stream"
459 ); exchange.input.body = Body::Bytes(materialized);
461 return PipelineOutcome::Completed(exchange);
462 }
463 exchange.input.body = Body::Bytes(materialized);
464 return PipelineOutcome::Failed(e);
465 }
466 exchange.input.body = Body::Bytes(materialized);
467 PipelineOutcome::Completed(exchange)
468 }
469 _ => {
470 exchange.input.body = body;
472 PipelineOutcome::Completed(exchange)
473 }
474 }
475 }
476
477 async fn coalesced_miss(&mut self, exchange: Exchange, key: String) -> PipelineOutcome {
499 let inflight = Arc::clone(&self.inflight);
500
501 let mut wave: Option<Arc<InFlight>> = None;
508 let mut registered = None;
509 match inflight.lock() {
510 Ok(mut map) => {
511 match map.get(&key).cloned() {
512 Some(existing) => {
513 wave = Some(existing);
516 if let Some(cell) = wave.as_ref() {
517 let mut notified = Box::pin(cell.notify.notified());
518 notified.as_mut().enable();
519 registered = Some(notified);
520 }
521 }
522 None => {
523 let cell = Arc::new(InFlight::default());
525 map.insert(key.clone(), Arc::clone(&cell));
526 wave = Some(cell);
527 }
528 }
529 }
530 Err(poisoned) => drop(poisoned),
531 }
532 let Some(cell_ref) = wave.as_ref() else {
533 return self.run_miss(exchange, key).await;
537 };
538
539 if let Some(notified) = registered {
540 let terminal = match cell_ref.terminal_snapshot() {
544 Some(t) => t,
545 None => {
546 notified.await;
547 cell_ref.terminal_snapshot().unwrap_or_else(|| {
548 CoalesceTerminal::Failed(CamelError::Config(
549 "cache coalesce waiter woke without a terminal state".into(),
550 ))
551 })
552 }
553 };
554 match terminal {
555 CoalesceTerminal::Completed(body) => {
556 let mut exchange = exchange;
557 exchange.input.body = body;
558 PipelineOutcome::Completed(exchange)
559 }
560 CoalesceTerminal::Failed(e) => PipelineOutcome::Failed(e),
561 CoalesceTerminal::Stopped => PipelineOutcome::Stopped(exchange),
562 }
563 } else {
564 let cell = Arc::clone(cell_ref);
568 let _guard = LeaderGuard {
569 key: key.clone(),
570 map: Arc::clone(&inflight),
571 cell: Arc::clone(&cell),
572 };
573 let outcome = self.run_miss(exchange, key.clone()).await;
574 let terminal = match &outcome {
575 PipelineOutcome::Completed(ex) => {
576 CoalesceTerminal::Completed(ex.input.body.clone())
577 }
578 PipelineOutcome::Failed(e) => CoalesceTerminal::Failed(e.clone()),
579 PipelineOutcome::Stopped(_) => CoalesceTerminal::Stopped,
580 };
581 cell.publish(terminal);
583 cell.notify.notify_waiters();
584 LeaderGuard::retire(&inflight, &key, &cell);
585 outcome
588 }
589 }
590}
591
592fn reconstruct_body(entry: &CacheEntry) -> Result<Body, CamelError> {
597 match entry.content_type {
598 ContentType::Bytes => Ok(Body::Bytes(Bytes::from(entry.bytes.clone()))),
599 ContentType::Text => {
600 let s = String::from_utf8(entry.bytes.clone()).map_err(|e| {
601 CamelError::TypeConversionFailed(format!("cached text is not valid UTF-8: {e}"))
602 })?;
603 Ok(Body::Text(s))
604 }
605 ContentType::Json => {
606 let v = serde_json::from_slice(&entry.bytes).map_err(|e| {
607 CamelError::TypeConversionFailed(format!("cached bytes are not valid JSON: {e}"))
608 })?;
609 Ok(Body::Json(v))
610 }
611 ContentType::Xml => {
612 let s = String::from_utf8(entry.bytes.clone()).map_err(|e| {
613 CamelError::TypeConversionFailed(format!("cached xml is not valid UTF-8: {e}"))
614 })?;
615 Ok(Body::Xml(s))
616 }
617 }
618}
619
620pub const CAMEL_CACHE_INVALIDATED_COUNT: &str = "CamelCacheInvalidatedCount";
630
631#[derive(Clone)]
634pub enum CacheInvalidateTarget {
635 Key(MessageIdExpression),
637 Prefix(MessageIdExpression),
639}
640
641pub struct CacheInvalidateService {
656 repository: Arc<dyn CacheRepository>,
657 target: CacheInvalidateTarget,
658 rt: Arc<dyn RuntimeObservability>,
659 repository_name: String,
660}
661
662impl CacheInvalidateService {
663 pub fn new(
664 repository: Arc<dyn CacheRepository>,
665 target: CacheInvalidateTarget,
666 rt: Arc<dyn RuntimeObservability>,
667 ) -> Self {
668 let repository_name = repository.name().to_string();
669 Self {
670 repository,
671 target,
672 rt,
673 repository_name,
674 }
675 }
676}
677
678impl Clone for CacheInvalidateService {
679 fn clone(&self) -> Self {
680 Self {
681 repository: Arc::clone(&self.repository),
682 target: self.target.clone(),
683 rt: Arc::clone(&self.rt),
684 repository_name: self.repository_name.clone(),
685 }
686 }
687}
688
689impl OutcomePipeline for CacheInvalidateService {
690 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
691 Box::new(self.clone())
692 }
693
694 fn run<'a>(
695 &'a mut self,
696 exchange: Exchange,
697 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
698 Box::pin(async move {
699 match self.target.clone() {
700 CacheInvalidateTarget::Key(key_expr) => {
701 let key = match key_expr(&exchange) {
702 Some(k) => k,
703 None => return PipelineOutcome::Completed(exchange),
704 };
705 match self.repository.invalidate(&key).await {
706 Err(e) => PipelineOutcome::Failed(e),
707 Ok(()) => {
708 self.rt.metrics().record_counter(
709 "camel.cache.invalidations",
710 1.0_f64,
711 &[("repository", &self.repository_name)],
712 );
713 let mut exchange = exchange;
714 exchange.set_property(
715 CAMEL_CACHE_INVALIDATED_COUNT,
716 serde_json::Value::from(1u64),
717 );
718 PipelineOutcome::Completed(exchange)
719 }
720 }
721 }
722 CacheInvalidateTarget::Prefix(prefix_expr) => {
723 let prefix = match prefix_expr(&exchange) {
724 Some(p) => p,
725 None => return PipelineOutcome::Completed(exchange),
726 };
727 match self.repository.invalidate_prefix(&prefix).await {
728 Err(e) => PipelineOutcome::Failed(e),
729 Ok(count) => {
730 self.rt.metrics().record_counter(
731 "camel.cache.invalidations",
732 1.0_f64,
733 &[("repository", &self.repository_name)],
734 );
735 let mut exchange = exchange;
736 exchange.set_property(
737 CAMEL_CACHE_INVALIDATED_COUNT,
738 serde_json::Value::from(count),
739 );
740 PipelineOutcome::Completed(exchange)
741 }
742 }
743 }
744 }
745 })
746 }
747}
748
749pub const CAMEL_CACHE_PEEK_HIT: &str = "CamelCachePeekHit";
755pub const CAMEL_CACHE_PEEK_STALE: &str = "CamelCachePeekStale";
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765pub enum PeekStaleMissPolicy {
766 Stop,
768 Continue,
770}
771
772impl PeekStaleMissPolicy {
773 pub fn parse_on_miss(raw: Option<&str>) -> Result<Self, CamelError> {
778 match raw {
779 None | Some("stop") => Ok(Self::Stop),
780 Some("continue") => Ok(Self::Continue),
781 Some(other) => Err(CamelError::Config(format!(
782 "cache_peek_stale: invalid on_miss '{other}'; must be \"stop\" or \"continue\""
783 ))),
784 }
785 }
786}
787
788pub struct CachePeekStaleService {
807 repository: Arc<dyn CacheRepository>,
808 key_expr: MessageIdExpression,
809 miss_policy: PeekStaleMissPolicy,
810 rt: Arc<dyn RuntimeObservability>,
811 repository_name: String,
812}
813
814impl CachePeekStaleService {
815 pub fn new(
816 repository: Arc<dyn CacheRepository>,
817 key_expr: MessageIdExpression,
818 miss_policy: PeekStaleMissPolicy,
819 rt: Arc<dyn RuntimeObservability>,
820 ) -> Self {
821 let repository_name = repository.name().to_string();
822 Self {
823 repository,
824 key_expr,
825 miss_policy,
826 rt,
827 repository_name,
828 }
829 }
830}
831
832impl Clone for CachePeekStaleService {
833 fn clone(&self) -> Self {
834 Self {
835 repository: Arc::clone(&self.repository),
836 key_expr: Arc::clone(&self.key_expr),
837 miss_policy: self.miss_policy,
838 rt: Arc::clone(&self.rt),
839 repository_name: self.repository_name.clone(),
840 }
841 }
842}
843
844fn set_peek_properties(exchange: &mut Exchange, hit: bool, stale: bool) {
847 exchange.set_property(CAMEL_CACHE_PEEK_HIT, serde_json::Value::Bool(hit));
848 exchange.set_property(CAMEL_CACHE_PEEK_STALE, serde_json::Value::Bool(stale));
849}
850
851impl OutcomePipeline for CachePeekStaleService {
852 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
853 Box::new(self.clone())
854 }
855
856 fn run<'a>(
857 &'a mut self,
858 exchange: Exchange,
859 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
860 Box::pin(async move {
861 let key = match (self.key_expr)(&exchange) {
862 Some(k) => k,
863 None => {
864 tracing::debug!(
865 step = "cache_peek_stale",
866 repository = %self.repository.name(),
867 "key expression resolved to None; stopping branch"
868 );
869 return PipelineOutcome::Stopped(exchange);
870 }
871 };
872 match self.repository.peek_stale(&key).await {
873 Err(e) => PipelineOutcome::Failed(e),
874 Ok(Some(entry)) => {
875 self.rt.metrics().record_counter(
877 "camel.cache.peek_stale_served",
878 1.0_f64,
879 &[("repository", &self.repository_name)],
880 );
881 let stale = entry
883 .expires_at
884 .map(|t| t <= SystemTime::now())
885 .unwrap_or(false);
886 match reconstruct_body(&entry) {
887 Ok(body) => {
888 let mut exchange = exchange;
889 exchange.input.body = body;
890 set_peek_properties(&mut exchange, true, stale);
891 PipelineOutcome::Completed(exchange)
892 }
893 Err(e) => PipelineOutcome::Failed(e),
894 }
895 }
896 Ok(None) => match self.miss_policy {
897 PeekStaleMissPolicy::Stop => {
898 let mut exchange = exchange;
899 set_peek_properties(&mut exchange, false, false);
900 tracing::debug!(
901 step = "cache_peek_stale",
902 repository = %self.repository.name(),
903 "peek miss; stopping branch per on_miss=stop"
904 );
905 PipelineOutcome::Stopped(exchange)
906 }
907 PeekStaleMissPolicy::Continue => {
908 let mut exchange = exchange;
909 set_peek_properties(&mut exchange, false, false);
910 PipelineOutcome::Completed(exchange)
911 }
912 },
913 }
914 })
915 }
916}
917
918pub struct CacheClearService {
927 repository: Arc<dyn CacheRepository>,
928}
929
930impl CacheClearService {
931 pub fn new(repository: Arc<dyn CacheRepository>) -> Self {
932 Self { repository }
933 }
934}
935
936impl Clone for CacheClearService {
937 fn clone(&self) -> Self {
938 Self {
939 repository: Arc::clone(&self.repository),
940 }
941 }
942}
943
944impl OutcomePipeline for CacheClearService {
945 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
946 Box::new(self.clone())
947 }
948
949 fn run<'a>(
950 &'a mut self,
951 exchange: Exchange,
952 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
953 Box::pin(async move {
954 match self.repository.clear().await {
955 Err(e) => PipelineOutcome::Failed(e),
956 Ok(()) => PipelineOutcome::Completed(exchange),
957 }
958 })
959 }
960}
961
962pub struct CacheStatsService {
969 repository: Arc<dyn CacheRepository>,
970 repository_name: String,
971}
972
973impl CacheStatsService {
974 pub fn new(repository: Arc<dyn CacheRepository>) -> Self {
975 let repository_name = repository.name().to_string();
976 Self {
977 repository,
978 repository_name,
979 }
980 }
981}
982
983impl Clone for CacheStatsService {
984 fn clone(&self) -> Self {
985 Self {
986 repository: Arc::clone(&self.repository),
987 repository_name: self.repository_name.clone(),
988 }
989 }
990}
991
992impl OutcomePipeline for CacheStatsService {
993 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
994 Box::new(self.clone())
995 }
996
997 fn run<'a>(
998 &'a mut self,
999 mut exchange: Exchange,
1000 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1001 Box::pin(async move {
1002 let s = self.repository.stats().await;
1003 exchange.input.body = Body::Json(serde_json::json!({
1004 "repository": self.repository_name,
1005 "hits": s.hits,
1006 "misses": s.misses,
1007 "evictions": s.evictions,
1008 "entries": s.entries,
1009 "peek_stale_served": s.peek_stale_served,
1010 "invalidations": s.invalidations,
1011 "bytes": s.bytes,
1012 }));
1013 PipelineOutcome::Completed(exchange)
1014 })
1015 }
1016}
1017
1018#[cfg(test)]
1023mod test_utils {
1024 use super::*;
1025 use async_trait::async_trait;
1026 use camel_api::cache::CacheStats;
1027 use std::collections::HashMap;
1028 use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
1029 use tokio::sync::Mutex;
1030
1031 #[derive(Debug, Default)]
1035 pub struct MockCacheRepository {
1036 name: String,
1037 entries: Arc<Mutex<HashMap<String, CacheEntry>>>,
1038 get_should_fail: Arc<AtomicBool>,
1039 set_should_fail: Arc<AtomicBool>,
1040 set_call_count: Arc<AtomicU32>,
1041 last_set_ttl: Arc<Mutex<Option<Duration>>>,
1042 invalidate_call_count: Arc<AtomicU32>,
1043 last_invalidate_key: Arc<Mutex<Option<String>>>,
1044 clear_call_count: Arc<AtomicU64>,
1045 clear_should_fail: Arc<AtomicBool>,
1046 invalidate_should_fail: Arc<AtomicBool>,
1047 prefix_unsupported: Arc<AtomicBool>,
1048 stats_override: std::sync::Mutex<CacheStats>,
1049 }
1050
1051 impl MockCacheRepository {
1052 pub fn new(name: &str) -> Self {
1053 Self {
1054 name: name.to_string(),
1055 ..Default::default()
1056 }
1057 }
1058
1059 pub fn invalidate_call_count(&self) -> u32 {
1060 self.invalidate_call_count.load(Ordering::SeqCst)
1061 }
1062
1063 pub async fn last_invalidate_key(&self) -> Option<String> {
1064 self.last_invalidate_key.lock().await.clone()
1065 }
1066
1067 pub fn clear_call_count(&self) -> u64 {
1068 self.clear_call_count.load(Ordering::SeqCst)
1069 }
1070
1071 pub fn set_should_fail_clear(&self, v: bool) {
1072 self.clear_should_fail.store(v, Ordering::SeqCst);
1073 }
1074
1075 pub fn set_should_fail_invalidate(&self, v: bool) {
1076 self.invalidate_should_fail.store(v, Ordering::SeqCst);
1077 }
1078
1079 pub fn set_prefix_unsupported(&self, v: bool) {
1082 self.prefix_unsupported.store(v, Ordering::SeqCst);
1083 }
1084
1085 pub fn set_stats(&self, stats: CacheStats) {
1086 *self.stats_override.lock().unwrap() = stats; }
1088
1089 pub async fn seed(&self, key: &str, entry: CacheEntry) {
1091 self.entries.lock().await.insert(key.to_string(), entry);
1092 }
1093
1094 pub fn set_get_should_fail(&self, v: bool) {
1095 self.get_should_fail.store(v, Ordering::SeqCst);
1096 }
1097
1098 pub fn set_set_should_fail(&self, v: bool) {
1099 self.set_should_fail.store(v, Ordering::SeqCst);
1100 }
1101
1102 pub fn set_call_count(&self) -> u32 {
1103 self.set_call_count.load(Ordering::SeqCst)
1104 }
1105
1106 pub async fn last_set_ttl(&self) -> Option<Duration> {
1108 *self.last_set_ttl.lock().await
1109 }
1110
1111 pub async fn stored_entry(&self, key: &str) -> Option<CacheEntry> {
1113 self.entries.lock().await.get(key).cloned()
1114 }
1115 }
1116
1117 #[async_trait]
1118 impl CacheRepository for MockCacheRepository {
1119 fn name(&self) -> &str {
1120 &self.name
1121 }
1122
1123 async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
1124 if self.get_should_fail.load(Ordering::SeqCst) {
1125 return Err(CamelError::ProcessorError("synthetic get failure".into()));
1126 }
1127 let found = self.entries.lock().await.get(key).cloned();
1133 tokio::task::yield_now().await;
1134 Ok(found)
1135 }
1136
1137 async fn set(
1138 &self,
1139 key: &str,
1140 value: CacheEntry,
1141 ttl: Option<Duration>,
1142 ) -> Result<(), CamelError> {
1143 self.set_call_count.fetch_add(1, Ordering::SeqCst);
1144 *self.last_set_ttl.lock().await = ttl;
1145 if self.set_should_fail.load(Ordering::SeqCst) {
1146 return Err(CamelError::ProcessorError("synthetic set failure".into()));
1147 }
1148 self.entries.lock().await.insert(key.to_string(), value);
1149 Ok(())
1150 }
1151
1152 async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
1153 self.get(key).await
1154 }
1155
1156 async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
1157 self.invalidate_call_count.fetch_add(1, Ordering::SeqCst);
1158 *self.last_invalidate_key.lock().await = Some(key.to_string());
1159 if self.invalidate_should_fail.load(Ordering::SeqCst) {
1160 return Err(CamelError::ProcessorError(
1161 "synthetic invalidate failure".into(),
1162 ));
1163 }
1164 self.entries.lock().await.remove(key);
1165 Ok(())
1166 }
1167
1168 async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
1169 if self.prefix_unsupported.load(Ordering::SeqCst) {
1170 return Err(CamelError::Config(format!(
1171 "cache backend '{}' does not support invalidate_prefix (no key iteration)",
1172 self.name()
1173 )));
1174 }
1175 let mut entries = self.entries.lock().await;
1176 let keys: Vec<String> = entries
1177 .keys()
1178 .filter(|k| k.starts_with(prefix))
1179 .cloned()
1180 .collect();
1181 let count = keys.len() as u64;
1182 for k in keys {
1183 entries.remove(&k);
1184 }
1185 Ok(count)
1186 }
1187
1188 async fn clear(&self) -> Result<(), CamelError> {
1189 self.clear_call_count.fetch_add(1, Ordering::SeqCst);
1190 if self.clear_should_fail.load(Ordering::SeqCst) {
1191 return Err(CamelError::ProcessorError("synthetic clear failure".into()));
1192 }
1193 self.entries.lock().await.clear();
1194 Ok(())
1195 }
1196
1197 async fn stats(&self) -> CacheStats {
1198 self.stats_override.lock().unwrap().clone() }
1200 }
1201}
1202
1203#[cfg(test)]
1208mod tests {
1209 use super::test_utils::MockCacheRepository;
1210 use super::*;
1211 use camel_api::body::{StreamBody, StreamMetadata};
1212 use camel_api::cache::CacheStats;
1213 use camel_api::metrics::NoOpMetrics;
1214 use camel_api::{Message, Value};
1215 use camel_component_api::health_registry::NoOpHealthCheckRegistry;
1216 use futures::stream;
1217 use std::sync::Mutex;
1218 use std::sync::atomic::{AtomicBool, Ordering};
1219 use std::time::SystemTime;
1220
1221 #[test]
1222 fn parse_on_miss_maps_absent_stop_and_continue() {
1223 assert_eq!(
1224 PeekStaleMissPolicy::parse_on_miss(None).unwrap(),
1225 PeekStaleMissPolicy::Stop
1226 );
1227 assert_eq!(
1228 PeekStaleMissPolicy::parse_on_miss(Some("stop")).unwrap(),
1229 PeekStaleMissPolicy::Stop
1230 );
1231 assert_eq!(
1232 PeekStaleMissPolicy::parse_on_miss(Some("continue")).unwrap(),
1233 PeekStaleMissPolicy::Continue
1234 );
1235 }
1236
1237 #[test]
1238 fn parse_on_miss_rejects_unknown_value_naming_the_step() {
1239 let err = PeekStaleMissPolicy::parse_on_miss(Some("explode")).unwrap_err();
1240 let msg = format!("{err}");
1241 assert!(msg.contains("cache_peek_stale"), "got: {msg}");
1242 assert!(msg.contains("explode"), "got: {msg}");
1243 }
1244
1245 #[derive(Clone)]
1247 struct NoopRt;
1248
1249 impl camel_component_api::HealthCheckRegistry for NoopRt {
1250 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
1251 }
1252
1253 impl RuntimeObservability for NoopRt {
1254 fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
1255 Arc::new(NoOpMetrics)
1256 }
1257 fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
1258 Arc::new(NoOpHealthCheckRegistry)
1259 }
1260 }
1261
1262 fn noop_rt() -> Arc<dyn RuntimeObservability> {
1263 Arc::new(NoopRt)
1264 }
1265
1266 #[derive(Clone)]
1269 enum ScriptedOutcome {
1270 Complete,
1271 Stop,
1272 Fail(CamelError),
1273 }
1274
1275 struct ScriptedOnMiss {
1278 body: Option<Body>,
1279 outcome: ScriptedOutcome,
1280 invoked: Arc<AtomicBool>,
1281 }
1282
1283 impl OutcomePipeline for ScriptedOnMiss {
1284 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
1285 unreachable!("clone_box not used in cache_eip tests")
1287 }
1288
1289 fn run<'a>(
1290 &'a mut self,
1291 mut exchange: Exchange,
1292 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1293 self.invoked.store(true, Ordering::SeqCst);
1294 let body = self.body.take();
1295 let outcome = self.outcome.clone();
1296 Box::pin(async move {
1297 if let Some(b) = body {
1298 exchange.input.body = b;
1299 }
1300 match outcome {
1301 ScriptedOutcome::Complete => PipelineOutcome::Completed(exchange),
1302 ScriptedOutcome::Stop => PipelineOutcome::Stopped(exchange),
1303 ScriptedOutcome::Fail(e) => PipelineOutcome::Failed(e),
1304 }
1305 })
1306 }
1307 }
1308
1309 fn fixed_key() -> MessageIdExpression {
1312 Arc::new(|_| Some("cache-key".to_string()))
1313 }
1314
1315 fn none_key() -> MessageIdExpression {
1316 Arc::new(|_| None)
1317 }
1318
1319 fn prefix_key() -> MessageIdExpression {
1320 Arc::new(|_| Some("ns:".to_string()))
1321 }
1322
1323 fn build_service(
1325 repo: Arc<MockCacheRepository>,
1326 key_expr: MessageIdExpression,
1327 max_entry_bytes: usize,
1328 body: Option<Body>,
1329 outcome: ScriptedOutcome,
1330 ttl: Option<Duration>,
1331 rt: Arc<dyn RuntimeObservability>,
1332 ) -> (CacheService, Arc<AtomicBool>) {
1333 let invoked = Arc::new(AtomicBool::new(false));
1334 let on_miss = OutcomeSegment::new(Box::new(ScriptedOnMiss {
1335 body,
1336 outcome,
1337 invoked: invoked.clone(),
1338 }));
1339 let svc = CacheService::new(repo, key_expr, ttl, max_entry_bytes, on_miss, rt);
1340 (svc, invoked)
1341 }
1342
1343 fn exchange() -> Exchange {
1344 let mut ex = Exchange::new(Message::new(""));
1345 ex.input.set_header("ignored", Value::String("v".into()));
1346 ex
1347 }
1348
1349 fn stream_body(data: &'static [u8]) -> Body {
1350 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from_static(data))];
1351 let s = stream::iter(chunks);
1352 Body::Stream(StreamBody {
1353 stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(s)))),
1354 metadata: StreamMetadata::default(),
1355 })
1356 }
1357
1358 fn stub_error(msg: &str) -> CamelError {
1359 CamelError::ProcessorError(msg.into())
1360 }
1361
1362 #[tokio::test]
1365 async fn cache_hit_short_circuits_on_miss() {
1366 let repo = Arc::new(MockCacheRepository::new("mock"));
1367 repo.seed(
1368 "cache-key",
1369 CacheEntry {
1370 bytes: b"cached-payload".to_vec(),
1371 payload_path: None,
1372 content_type: ContentType::Bytes,
1373 expires_at: None,
1374 },
1375 )
1376 .await;
1377 let (mut svc, on_miss_invoked) = build_service(
1378 repo,
1379 fixed_key(),
1380 1024,
1381 Some(Body::Bytes(Bytes::from_static(b"unreached"))),
1382 ScriptedOutcome::Complete,
1383 None,
1384 noop_rt(),
1385 );
1386
1387 let outcome = svc.run(exchange()).await;
1388
1389 let ex = match outcome {
1390 PipelineOutcome::Completed(ex) => ex,
1391 other => panic!("expected Completed, got {other:?}"),
1392 };
1393 assert_eq!(
1394 ex.input.body,
1395 Body::Bytes(Bytes::from_static(b"cached-payload"))
1396 );
1397 assert!(
1398 !on_miss_invoked.load(Ordering::SeqCst),
1399 "on_miss must NOT run on a cache HIT"
1400 );
1401 }
1402
1403 #[tokio::test]
1406 async fn cache_miss_runs_on_miss_sets_continues() {
1407 let ttl = Duration::from_secs(30);
1408 let repo = Arc::new(MockCacheRepository::new("mock"));
1409 let (mut svc, on_miss_invoked) = build_service(
1410 repo.clone(),
1411 fixed_key(),
1412 1024,
1413 Some(Body::Bytes(Bytes::from_static(b"x"))),
1414 ScriptedOutcome::Complete,
1415 Some(ttl),
1416 noop_rt(),
1417 );
1418
1419 let outcome = svc.run(exchange()).await;
1420
1421 let ex = match outcome {
1422 PipelineOutcome::Completed(ex) => ex,
1423 other => panic!("expected Completed, got {other:?}"),
1424 };
1425 assert!(on_miss_invoked.load(Ordering::SeqCst));
1426 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
1427 assert_eq!(repo.set_call_count(), 1, "set must be called once on miss");
1428 let stored = repo
1429 .stored_entry("cache-key")
1430 .await
1431 .expect("entry must be stored");
1432 assert_eq!(stored.bytes, b"x");
1433 assert_eq!(stored.content_type, ContentType::Bytes);
1434 assert_eq!(repo.last_set_ttl().await, Some(ttl));
1435 }
1436
1437 #[tokio::test]
1440 async fn cache_miss_oversized_materialized_body_skips_writeback() {
1441 let repo = Arc::new(MockCacheRepository::new("mock"));
1442 let (mut svc, _invoked) = build_service(
1444 repo.clone(),
1445 fixed_key(),
1446 4,
1447 Some(Body::Bytes(Bytes::from_static(b"oversized"))),
1448 ScriptedOutcome::Complete,
1449 None,
1450 noop_rt(),
1451 );
1452
1453 let outcome = svc.run(exchange()).await;
1454
1455 let ex = match outcome {
1456 PipelineOutcome::Completed(ex) => ex,
1457 other => panic!("expected Completed, got {other:?}"),
1458 };
1459 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"oversized")));
1461 assert_eq!(
1462 repo.set_call_count(),
1463 0,
1464 "set must NOT be called for oversized body"
1465 );
1466 assert!(repo.stored_entry("cache-key").await.is_none());
1467 }
1468
1469 #[tokio::test]
1472 async fn cache_miss_oversized_stream_propagates_err() {
1473 let repo = Arc::new(MockCacheRepository::new("mock"));
1474 let (mut svc, _invoked) = build_service(
1475 repo.clone(),
1476 fixed_key(),
1477 4,
1478 Some(stream_body(b"way-too-big-stream")),
1479 ScriptedOutcome::Complete,
1480 None,
1481 noop_rt(),
1482 );
1483
1484 let outcome = svc.run(exchange()).await;
1485
1486 match outcome {
1487 PipelineOutcome::Failed(CamelError::StreamLimitExceeded(n)) => {
1488 assert_eq!(n, 4);
1489 }
1490 other => panic!("expected Failed(StreamLimitExceeded(4)), got {other:?}"),
1491 }
1492 assert_eq!(
1493 repo.set_call_count(),
1494 0,
1495 "set must NOT be called when stream exceeds limit"
1496 );
1497 }
1498
1499 #[tokio::test]
1502 async fn cache_on_miss_stopped_propagates_without_writeback() {
1503 let repo = Arc::new(MockCacheRepository::new("mock"));
1504 let (mut svc, _invoked) = build_service(
1505 repo.clone(),
1506 fixed_key(),
1507 1024,
1508 None,
1509 ScriptedOutcome::Stop,
1510 None,
1511 noop_rt(),
1512 );
1513
1514 let outcome = svc.run(exchange()).await;
1515
1516 assert!(
1517 matches!(outcome, PipelineOutcome::Stopped(_)),
1518 "Stopped from on_miss MUST propagate as Stopped"
1519 );
1520 assert_eq!(
1521 repo.set_call_count(),
1522 0,
1523 "set must NOT be called when on_miss Stops"
1524 );
1525 }
1526
1527 #[tokio::test]
1530 async fn cache_on_miss_err_propagates_without_writeback() {
1531 let repo = Arc::new(MockCacheRepository::new("mock"));
1532 let (mut svc, _invoked) = build_service(
1533 repo.clone(),
1534 fixed_key(),
1535 1024,
1536 None,
1537 ScriptedOutcome::Fail(stub_error("on-miss blew up")),
1538 None,
1539 noop_rt(),
1540 );
1541
1542 let outcome = svc.run(exchange()).await;
1543
1544 match outcome {
1545 PipelineOutcome::Failed(e) => {
1546 assert!(e.to_string().contains("on-miss blew up"), "got: {e}");
1547 }
1548 other => panic!("expected Failed, got {other:?}"),
1549 }
1550 assert_eq!(
1551 repo.set_call_count(),
1552 0,
1553 "set must NOT be called when on_miss fails"
1554 );
1555 }
1556
1557 #[tokio::test]
1560 async fn cache_repository_get_err_propagates() {
1561 let repo = Arc::new(MockCacheRepository::new("mock"));
1562 repo.set_get_should_fail(true);
1563 let (mut svc, on_miss_invoked) = build_service(
1564 repo,
1565 fixed_key(),
1566 1024,
1567 Some(Body::Bytes(Bytes::from_static(b"x"))),
1568 ScriptedOutcome::Complete,
1569 None,
1570 noop_rt(),
1571 );
1572
1573 let outcome = svc.run(exchange()).await;
1574
1575 match outcome {
1576 PipelineOutcome::Failed(e) => {
1577 assert!(e.to_string().contains("synthetic get failure"), "got: {e}");
1578 }
1579 other => panic!("expected Failed, got {other:?}"),
1580 }
1581 assert!(
1582 !on_miss_invoked.load(Ordering::SeqCst),
1583 "on_miss must NOT run when get fails"
1584 );
1585 }
1586
1587 #[tokio::test]
1590 async fn cache_repository_set_err_propagates() {
1591 let repo = Arc::new(MockCacheRepository::new("mock"));
1592 repo.set_set_should_fail(true);
1593 let (mut svc, _invoked) = build_service(
1594 repo.clone(),
1595 fixed_key(),
1596 1024,
1597 Some(Body::Bytes(Bytes::from_static(b"x"))),
1598 ScriptedOutcome::Complete,
1599 None,
1600 noop_rt(),
1601 );
1602
1603 let outcome = svc.run(exchange()).await;
1604
1605 match outcome {
1606 PipelineOutcome::Failed(e) => {
1607 assert!(e.to_string().contains("synthetic set failure"), "got: {e}");
1608 }
1609 other => panic!("expected Failed, got {other:?}"),
1610 }
1611 assert_eq!(repo.set_call_count(), 1, "set was attempted (and failed)");
1612 }
1613
1614 #[tokio::test]
1617 async fn cache_none_key_bypasses_to_on_miss() {
1618 let repo = Arc::new(MockCacheRepository::new("mock"));
1619 let (mut svc, on_miss_invoked) = build_service(
1620 repo.clone(),
1621 none_key(),
1622 1024,
1623 Some(Body::Bytes(Bytes::from_static(b"x"))),
1624 ScriptedOutcome::Complete,
1625 None,
1626 noop_rt(),
1627 );
1628
1629 let outcome = svc.run(exchange()).await;
1630
1631 let ex = match outcome {
1632 PipelineOutcome::Completed(ex) => ex,
1633 other => panic!("expected Completed, got {other:?}"),
1634 };
1635 assert!(
1636 on_miss_invoked.load(Ordering::SeqCst),
1637 "on_miss MUST run when key is None"
1638 );
1639 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
1640 assert_eq!(
1641 repo.set_call_count(),
1642 0,
1643 "set must NOT be called when key_expr returns None"
1644 );
1645 }
1646
1647 #[tokio::test]
1650 async fn cache_content_type_reconstruction() {
1651 async fn run_case(entry: CacheEntry, expected: Body) {
1652 let repo = Arc::new(MockCacheRepository::new("mock"));
1653 repo.seed("cache-key", entry).await;
1654 let (mut svc, on_miss_invoked) = build_service(
1655 repo,
1656 fixed_key(),
1657 1024,
1658 Some(Body::Bytes(Bytes::from_static(b"unreached"))),
1659 ScriptedOutcome::Complete,
1660 None,
1661 noop_rt(),
1662 );
1663 let outcome = svc.run(exchange()).await;
1664 let ex = match outcome {
1665 PipelineOutcome::Completed(ex) => ex,
1666 other => panic!("expected Completed, got {other:?}"),
1667 };
1668 assert_eq!(ex.input.body, expected);
1669 assert!(!on_miss_invoked.load(Ordering::SeqCst));
1670 }
1671
1672 run_case(
1673 CacheEntry {
1674 bytes: b"raw".to_vec(),
1675 payload_path: None,
1676 content_type: ContentType::Bytes,
1677 expires_at: None,
1678 },
1679 Body::Bytes(Bytes::from_static(b"raw")),
1680 )
1681 .await;
1682 run_case(
1683 CacheEntry {
1684 bytes: b"hi".to_vec(),
1685 payload_path: None,
1686 content_type: ContentType::Text,
1687 expires_at: None,
1688 },
1689 Body::Text("hi".into()),
1690 )
1691 .await;
1692 run_case(
1693 CacheEntry {
1694 bytes: br#"{"k":1}"#.to_vec(),
1695 payload_path: None,
1696 content_type: ContentType::Json,
1697 expires_at: None,
1698 },
1699 Body::Json(serde_json::json!({"k": 1})),
1700 )
1701 .await;
1702 run_case(
1703 CacheEntry {
1704 bytes: b"<a/>".to_vec(),
1705 payload_path: None,
1706 content_type: ContentType::Xml,
1707 expires_at: None,
1708 },
1709 Body::Xml("<a/>".into()),
1710 )
1711 .await;
1712 }
1713
1714 #[tokio::test]
1717 async fn cache_miss_stream_body_is_materialized_and_cached() {
1718 let repo = Arc::new(MockCacheRepository::new("mock"));
1719 let (mut svc, _invoked) = build_service(
1720 repo.clone(),
1721 fixed_key(),
1722 1024,
1723 Some(stream_body(b"chunky")),
1724 ScriptedOutcome::Complete,
1725 None,
1726 noop_rt(),
1727 );
1728
1729 let outcome = svc.run(exchange()).await;
1730
1731 let ex = match outcome {
1732 PipelineOutcome::Completed(ex) => ex,
1733 other => panic!("expected Completed, got {other:?}"),
1734 };
1735 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"chunky")));
1737 assert_eq!(repo.set_call_count(), 1);
1738 let stored = repo.stored_entry("cache-key").await.expect("stored");
1739 assert_eq!(stored.bytes, b"chunky");
1740 assert_eq!(stored.content_type, ContentType::Bytes);
1741 }
1742
1743 #[tokio::test]
1746 async fn cache_peek_stale_serves_post_expiry_entry() {
1747 let repo = Arc::new(MockCacheRepository::new("mock"));
1748 repo.seed(
1749 "cache-key",
1750 CacheEntry {
1751 bytes: b"stale-payload".to_vec(),
1752 payload_path: None,
1753 content_type: ContentType::Text,
1754 expires_at: None,
1755 },
1756 )
1757 .await;
1758 let mut svc =
1759 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1760
1761 let outcome = svc.run(exchange()).await;
1762
1763 let ex = match outcome {
1764 PipelineOutcome::Completed(ex) => ex,
1765 other => panic!("expected Completed, got {other:?}"),
1766 };
1767 assert_eq!(ex.input.body, Body::Text("stale-payload".into()));
1768 }
1769
1770 #[tokio::test]
1771 #[allow(clippy::await_holding_lock)]
1772 async fn cache_peek_stale_on_absence_stops_branch() {
1773 let _lock = PEEK_STALE_LOG_LOCK
1774 .lock()
1775 .unwrap_or_else(|e| e.into_inner());
1776 let repo = Arc::new(MockCacheRepository::new("mock"));
1777 let mut svc =
1778 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1779
1780 let outcome = svc.run(exchange()).await;
1781
1782 assert!(
1783 matches!(outcome, PipelineOutcome::Stopped(_)),
1784 "expected Stopped when no stale entry, got {outcome:?}"
1785 );
1786 }
1787
1788 #[tokio::test]
1789 #[allow(clippy::await_holding_lock)]
1790 async fn cache_peek_stale_none_key_stops() {
1791 let _lock = PEEK_STALE_LOG_LOCK
1792 .lock()
1793 .unwrap_or_else(|e| e.into_inner());
1794 let repo = Arc::new(MockCacheRepository::new("mock"));
1795 let mut svc =
1796 CachePeekStaleService::new(repo, none_key(), PeekStaleMissPolicy::Stop, noop_rt());
1797
1798 let outcome = svc.run(exchange()).await;
1799
1800 assert!(
1801 matches!(outcome, PipelineOutcome::Stopped(_)),
1802 "expected Stopped when key_expr returns None, got {outcome:?}"
1803 );
1804 }
1805
1806 #[tokio::test]
1807 #[allow(clippy::await_holding_lock)]
1808 async fn peek_stale_miss_stop_sets_properties_and_stops() {
1809 let _lock = PEEK_STALE_LOG_LOCK
1810 .lock()
1811 .unwrap_or_else(|e| e.into_inner());
1812 let repo = Arc::new(MockCacheRepository::new("mock"));
1813 let mut svc =
1814 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1815
1816 let outcome = svc.run(exchange()).await;
1817
1818 let ex = match outcome {
1819 PipelineOutcome::Stopped(ex) => ex,
1820 other => panic!("expected Stopped, got {other:?}"),
1821 };
1822 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(false)));
1823 assert_eq!(
1824 ex.property(CAMEL_CACHE_PEEK_STALE),
1825 Some(&Value::Bool(false))
1826 );
1827 }
1828
1829 #[tokio::test]
1830 async fn peek_stale_miss_continue_completes_with_body_untouched() {
1831 let repo = Arc::new(MockCacheRepository::new("mock"));
1832 let mut svc =
1833 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Continue, noop_rt());
1834
1835 let mut ex = exchange();
1836 ex.input.body = Body::Text("orig".into());
1837
1838 let outcome = svc.run(ex).await;
1839
1840 let ex = match outcome {
1841 PipelineOutcome::Completed(ex) => ex,
1842 other => panic!("expected Completed, got {other:?}"),
1843 };
1844 assert_eq!(ex.input.body, Body::Text("orig".into()));
1845 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(false)));
1846 assert_eq!(
1847 ex.property(CAMEL_CACHE_PEEK_STALE),
1848 Some(&Value::Bool(false))
1849 );
1850 }
1851
1852 #[tokio::test]
1853 async fn peek_stale_hit_sets_hit_and_stale_properties() {
1854 let repo = Arc::new(MockCacheRepository::new("mock"));
1855 repo.seed(
1856 "cache-key",
1857 CacheEntry {
1858 bytes: b"stale-payload".to_vec(),
1859 payload_path: None,
1860 content_type: ContentType::Bytes,
1861 expires_at: Some(SystemTime::now() - Duration::from_millis(1)),
1862 },
1863 )
1864 .await;
1865 let mut svc =
1866 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1867
1868 let outcome = svc.run(exchange()).await;
1869
1870 let ex = match outcome {
1871 PipelineOutcome::Completed(ex) => ex,
1872 other => panic!("expected Completed, got {other:?}"),
1873 };
1874 assert_eq!(
1875 ex.input.body,
1876 Body::Bytes(Bytes::from_static(b"stale-payload"))
1877 );
1878 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(true)));
1879 assert_eq!(
1880 ex.property(CAMEL_CACHE_PEEK_STALE),
1881 Some(&Value::Bool(true))
1882 );
1883 }
1884
1885 #[tokio::test]
1886 async fn peek_stale_hit_fresh_sets_stale_false() {
1887 let repo = Arc::new(MockCacheRepository::new("mock"));
1888 repo.seed(
1889 "cache-key",
1890 CacheEntry {
1891 bytes: b"fresh-payload".to_vec(),
1892 payload_path: None,
1893 content_type: ContentType::Bytes,
1894 expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
1895 },
1896 )
1897 .await;
1898 let mut svc =
1899 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1900
1901 let outcome = svc.run(exchange()).await;
1902
1903 let ex = match outcome {
1904 PipelineOutcome::Completed(ex) => ex,
1905 other => panic!("expected Completed, got {other:?}"),
1906 };
1907 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(true)));
1908 assert_eq!(
1909 ex.property(CAMEL_CACHE_PEEK_STALE),
1910 Some(&Value::Bool(false))
1911 );
1912 }
1913
1914 #[derive(Default)]
1924 struct EventRecorder {
1925 records: Arc<Mutex<Vec<String>>>,
1926 }
1927
1928 static PEEK_STALE_LOG_LOCK: Mutex<()> = Mutex::new(());
1942
1943 impl EventRecorder {
1944 fn install(self) -> (Arc<Mutex<Vec<String>>>, tracing::subscriber::DefaultGuard) {
1947 use tracing_subscriber::prelude::*;
1948 let records = Arc::clone(&self.records);
1949 let guard = tracing_subscriber::registry().with(self).set_default();
1950 (records, guard)
1951 }
1952 }
1953
1954 struct FieldFmt<'a>(&'a mut String);
1957
1958 impl tracing::field::Visit for FieldFmt<'_> {
1959 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1960 use std::fmt::Write as _;
1961 let _ = write!(self.0, " {}={:?}", field.name(), value);
1962 }
1963 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1964 self.record_debug(field, &value);
1965 }
1966 }
1967
1968 impl<C> tracing_subscriber::Layer<C> for EventRecorder
1969 where
1970 C: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
1971 {
1972 fn on_event(
1973 &self,
1974 event: &tracing::Event<'_>,
1975 _ctx: tracing_subscriber::layer::Context<'_, C>,
1976 ) {
1977 let meta = event.metadata();
1978 if meta.target() != "camel_processor::cache_eip" {
1979 return;
1980 }
1981 let mut line = format!("{} ", meta.level());
1982 event.record(&mut FieldFmt(&mut line));
1983 self.records.lock().unwrap().push(line); }
1985 }
1986
1987 #[tokio::test]
1988 #[allow(clippy::await_holding_lock)]
1989 async fn peek_stale_miss_stop_emits_debug_log() {
1990 let _lock = PEEK_STALE_LOG_LOCK
1991 .lock()
1992 .unwrap_or_else(|e| e.into_inner());
1993 let repo = Arc::new(MockCacheRepository::new("mock"));
1994 let mut svc =
1995 CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1996
1997 let (records, _guard) = EventRecorder::default().install();
1998 let outcome = svc.run(exchange()).await;
1999 drop(_guard);
2000
2001 assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
2002
2003 let captured = records.lock().unwrap().join("\n"); let miss_records: Vec<&str> = captured
2005 .lines()
2006 .filter(|l| l.contains("peek miss"))
2007 .collect();
2008 assert_eq!(
2009 miss_records.len(),
2010 1,
2011 "expected exactly one DEBUG record containing \"peek miss\"; got: {captured}"
2012 );
2013 assert!(
2014 miss_records[0].contains("DEBUG"),
2015 "expected DEBUG level record; got: {captured}"
2016 );
2017 assert!(
2018 miss_records[0].contains("repository=mock"),
2019 "expected repository field in record; got: {captured}"
2020 );
2021 assert!(
2022 miss_records[0].contains("step=\"cache_peek_stale\""),
2023 "expected step field in record; got: {captured}"
2024 );
2025 }
2026
2027 #[tokio::test]
2028 #[allow(clippy::await_holding_lock)]
2029 async fn peek_stale_key_none_stops_with_debug_log() {
2030 let _lock = PEEK_STALE_LOG_LOCK
2031 .lock()
2032 .unwrap_or_else(|e| e.into_inner());
2033 let repo = Arc::new(MockCacheRepository::new("mock"));
2034 let mut svc =
2035 CachePeekStaleService::new(repo, none_key(), PeekStaleMissPolicy::Stop, noop_rt());
2036
2037 let (records, _guard) = EventRecorder::default().install();
2038 let outcome = svc.run(exchange()).await;
2039 drop(_guard);
2040
2041 assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
2042
2043 let captured = records.lock().unwrap().join("\n"); let none_records: Vec<&str> = captured
2045 .lines()
2046 .filter(|l| l.contains("resolved to None"))
2047 .collect();
2048 assert_eq!(
2049 none_records.len(),
2050 1,
2051 "expected exactly one DEBUG record containing \"resolved to None\"; got: {captured}"
2052 );
2053 assert!(
2054 none_records[0].contains("DEBUG"),
2055 "expected DEBUG level record; got: {captured}"
2056 );
2057 assert!(
2058 none_records[0].contains("repository=mock"),
2059 "expected repository field in record; got: {captured}"
2060 );
2061 assert!(
2062 none_records[0].contains("step=\"cache_peek_stale\""),
2063 "expected step field in record; got: {captured}"
2064 );
2065 }
2066
2067 #[tokio::test]
2070 async fn cache_invalidate_calls_repository_invalidate() {
2071 let repo = Arc::new(MockCacheRepository::new("mock"));
2072 repo.seed(
2073 "cache-key",
2074 CacheEntry {
2075 bytes: b"to-go".to_vec(),
2076 payload_path: None,
2077 content_type: ContentType::Bytes,
2078 expires_at: None,
2079 },
2080 )
2081 .await;
2082 let mut svc = CacheInvalidateService::new(
2083 repo.clone(),
2084 CacheInvalidateTarget::Key(fixed_key()),
2085 noop_rt(),
2086 );
2087
2088 let outcome = svc.run(exchange()).await;
2089
2090 let ex = match outcome {
2091 PipelineOutcome::Completed(ex) => ex,
2092 other => panic!("expected Completed, got {other:?}"),
2093 };
2094 assert_eq!(
2095 ex.property(CAMEL_CACHE_INVALIDATED_COUNT),
2096 Some(&serde_json::Value::from(1u64)),
2097 "exact-key success must set CamelCacheInvalidatedCount = 1"
2098 );
2099 assert_eq!(
2100 repo.invalidate_call_count(),
2101 1,
2102 "invalidate must be called once"
2103 );
2104 assert_eq!(
2105 repo.last_invalidate_key().await,
2106 Some("cache-key".to_string()),
2107 "invalidate must be called with the correct key"
2108 );
2109 assert!(
2110 repo.stored_entry("cache-key").await.is_none(),
2111 "entry must be removed after invalidation"
2112 );
2113 }
2114
2115 #[tokio::test]
2116 async fn cache_invalidate_none_key_completes() {
2117 let repo = Arc::new(MockCacheRepository::new("mock"));
2118 let mut svc = CacheInvalidateService::new(
2119 repo.clone(),
2120 CacheInvalidateTarget::Key(none_key()),
2121 noop_rt(),
2122 );
2123
2124 let outcome = svc.run(exchange()).await;
2125
2126 let _ex = match outcome {
2127 PipelineOutcome::Completed(ex) => ex,
2128 other => panic!("expected Completed, got {other:?}"),
2129 };
2130 assert_eq!(
2131 repo.invalidate_call_count(),
2132 0,
2133 "invalidate must NOT be called when key_expr returns None"
2134 );
2135 }
2136
2137 type CounterRecording = Vec<(String, f64, Vec<(String, String)>)>;
2141
2142 #[derive(Clone)]
2143 struct RecordingMetricsCollector {
2144 counters: Arc<Mutex<CounterRecording>>,
2145 }
2146
2147 impl RecordingMetricsCollector {
2148 fn new() -> Self {
2149 Self {
2150 counters: Arc::new(Mutex::new(Vec::new())),
2151 }
2152 }
2153 }
2154
2155 impl camel_api::metrics::MetricsCollector for RecordingMetricsCollector {
2156 fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
2157 fn increment_errors(&self, _route_id: &str, _error_type: &str) {}
2158 fn increment_exchanges(&self, _route_id: &str) {}
2159 fn set_queue_depth(&self, _route_id: &str, _depth: usize) {}
2160 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
2161 fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
2162 self.counters.lock().unwrap().push((
2163 name.to_string(),
2164 value,
2165 labels
2166 .iter()
2167 .map(|(k, v)| (k.to_string(), v.to_string()))
2168 .collect(),
2169 ));
2170 }
2171 }
2172
2173 #[derive(Clone)]
2174 struct TestOtelmRt {
2175 collector: Arc<RecordingMetricsCollector>,
2176 }
2177
2178 impl camel_component_api::health_registry::HealthCheckRegistry for TestOtelmRt {
2179 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
2180 }
2181
2182 impl RuntimeObservability for TestOtelmRt {
2183 fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
2184 self.collector.clone()
2185 }
2186 fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
2187 Arc::new(NoOpHealthCheckRegistry)
2188 }
2189 }
2190
2191 #[tokio::test]
2192 async fn cache_step_hit_increments_otel_counter() {
2193 let repo = Arc::new(MockCacheRepository::new("mock"));
2194 repo.seed(
2195 "cache-key",
2196 CacheEntry {
2197 bytes: b"cached".to_vec(),
2198 payload_path: None,
2199 content_type: ContentType::Bytes,
2200 expires_at: None,
2201 },
2202 )
2203 .await;
2204 let collector = RecordingMetricsCollector::new();
2205 let counters = collector.counters.clone();
2206 let rt = Arc::new(TestOtelmRt {
2207 collector: Arc::new(collector),
2208 });
2209 let (mut svc, _invoked) = build_service(
2210 repo,
2211 fixed_key(),
2212 1024,
2213 None,
2214 ScriptedOutcome::Complete,
2215 None,
2216 rt,
2217 );
2218
2219 let outcome = svc.run(exchange()).await;
2220 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2221
2222 let recorded = counters.lock().unwrap().clone();
2223 assert!(
2224 recorded.contains(&(
2225 "camel.cache.hits".to_string(),
2226 1.0,
2227 vec![("repository".to_string(), "mock".to_string())]
2228 )),
2229 "expected camel.cache.hits counter, got: {recorded:?}"
2230 );
2231 }
2232
2233 #[tokio::test]
2234 async fn cache_step_miss_increments_otel_counter() {
2235 let repo = Arc::new(MockCacheRepository::new("mock"));
2236 let collector = RecordingMetricsCollector::new();
2237 let counters = collector.counters.clone();
2238 let rt = Arc::new(TestOtelmRt {
2239 collector: Arc::new(collector),
2240 });
2241 let (mut svc, _invoked) = build_service(
2242 repo.clone(),
2243 fixed_key(),
2244 1024,
2245 Some(Body::Bytes(Bytes::from_static(b"x"))),
2246 ScriptedOutcome::Complete,
2247 None,
2248 rt,
2249 );
2250
2251 let outcome = svc.run(exchange()).await;
2252 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2253
2254 let recorded = counters.lock().unwrap().clone();
2255 assert!(
2256 recorded.contains(&(
2257 "camel.cache.misses".to_string(),
2258 1.0,
2259 vec![("repository".to_string(), "mock".to_string())]
2260 )),
2261 "expected camel.cache.misses counter, got: {recorded:?}"
2262 );
2263 }
2264
2265 #[tokio::test]
2268 async fn cache_clear_calls_repository_clear() {
2269 let repo = Arc::new(MockCacheRepository::new("mock"));
2270 repo.seed(
2271 "k",
2272 CacheEntry {
2273 bytes: b"v".to_vec(),
2274 payload_path: None,
2275 content_type: ContentType::Bytes,
2276 expires_at: None,
2277 },
2278 )
2279 .await;
2280 let mut svc = CacheClearService::new(repo.clone());
2281
2282 let outcome = svc.run(exchange()).await;
2283
2284 match outcome {
2285 PipelineOutcome::Completed(_) => {}
2286 other => panic!("expected Completed, got {other:?}"),
2287 }
2288 assert_eq!(repo.clear_call_count(), 1, "clear must be called once");
2289 assert!(
2290 repo.stored_entry("k").await.is_none(),
2291 "entry must be removed after clear"
2292 );
2293 }
2294
2295 #[tokio::test]
2296 async fn cache_clear_err_propagates_failed() {
2297 let repo = Arc::new(MockCacheRepository::new("mock"));
2298 repo.set_should_fail_clear(true);
2299 let mut svc = CacheClearService::new(repo);
2300
2301 let outcome = svc.run(exchange()).await;
2302
2303 match outcome {
2304 PipelineOutcome::Failed(e) => {
2305 assert!(
2306 e.to_string().contains("synthetic clear failure"),
2307 "got: {e}"
2308 );
2309 }
2310 other => panic!("expected Failed, got {other:?}"),
2311 }
2312 }
2313
2314 #[tokio::test]
2317 async fn cache_stats_sets_json_body() {
2318 let repo = Arc::new(MockCacheRepository::new("mock"));
2319 repo.set_stats(CacheStats {
2320 hits: 2,
2321 misses: 1,
2322 evictions: 0,
2323 entries: 3,
2324 peek_stale_served: 4,
2325 invalidations: 1,
2326 bytes: None,
2327 });
2328 let mut svc = CacheStatsService::new(repo);
2329
2330 let outcome = svc.run(exchange()).await;
2331
2332 let ex = match outcome {
2333 PipelineOutcome::Completed(ex) => ex,
2334 other => panic!("expected Completed, got {other:?}"),
2335 };
2336 let expected = serde_json::json!({
2337 "repository": "mock",
2338 "hits": 2,
2339 "misses": 1,
2340 "evictions": 0,
2341 "entries": 3,
2342 "peek_stale_served": 4,
2343 "invalidations": 1,
2344 "bytes": null
2345 });
2346 assert_eq!(ex.input.body, Body::Json(expected));
2347
2348 let Body::Json(v) = &ex.input.body else {
2351 panic!("expected Json body");
2352 };
2353 let keys: std::collections::BTreeSet<&str> = v
2354 .as_object()
2355 .expect("stats body must be a JSON object")
2356 .keys()
2357 .map(String::as_str)
2358 .collect();
2359 let expected_keys: std::collections::BTreeSet<&str> = [
2360 "repository",
2361 "hits",
2362 "misses",
2363 "evictions",
2364 "entries",
2365 "peek_stale_served",
2366 "invalidations",
2367 "bytes",
2368 ]
2369 .into_iter()
2370 .collect();
2371 assert_eq!(
2372 keys, expected_keys,
2373 "stats body must have exactly the eight canonical keys"
2374 );
2375 }
2376
2377 #[tokio::test]
2380 async fn peek_stale_hit_emits_peek_served_counter() {
2381 let repo = Arc::new(MockCacheRepository::new("mock"));
2382 repo.seed(
2383 "cache-key",
2384 CacheEntry {
2385 bytes: b"cached".to_vec(),
2386 payload_path: None,
2387 content_type: ContentType::Bytes,
2388 expires_at: None,
2389 },
2390 )
2391 .await;
2392 let collector = RecordingMetricsCollector::new();
2393 let counters = collector.counters.clone();
2394 let rt = Arc::new(TestOtelmRt {
2395 collector: Arc::new(collector),
2396 });
2397 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, rt);
2398
2399 let outcome = svc.run(exchange()).await;
2400 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2401
2402 let recorded = counters.lock().unwrap().clone();
2403 assert!(
2404 recorded.contains(&(
2405 "camel.cache.peek_stale_served".to_string(),
2406 1.0,
2407 vec![("repository".to_string(), "mock".to_string())]
2408 )),
2409 "expected camel.cache.peek_stale_served counter, got: {recorded:?}"
2410 );
2411 }
2412
2413 #[tokio::test]
2414 async fn invalidate_emits_invalidations_counter() {
2415 let repo = Arc::new(MockCacheRepository::new("mock"));
2416 repo.seed(
2417 "cache-key",
2418 CacheEntry {
2419 bytes: b"to-go".to_vec(),
2420 payload_path: None,
2421 content_type: ContentType::Bytes,
2422 expires_at: None,
2423 },
2424 )
2425 .await;
2426 let collector = RecordingMetricsCollector::new();
2427 let counters = collector.counters.clone();
2428 let rt = Arc::new(TestOtelmRt {
2429 collector: Arc::new(collector),
2430 });
2431 let mut svc =
2432 CacheInvalidateService::new(repo, CacheInvalidateTarget::Key(fixed_key()), rt);
2433
2434 let outcome = svc.run(exchange()).await;
2435 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2436
2437 let recorded = counters.lock().unwrap().clone();
2438 assert!(
2439 recorded.contains(&(
2440 "camel.cache.invalidations".to_string(),
2441 1.0,
2442 vec![("repository".to_string(), "mock".to_string())]
2443 )),
2444 "expected camel.cache.invalidations counter, got: {recorded:?}"
2445 );
2446 }
2447
2448 #[tokio::test]
2449 #[allow(clippy::await_holding_lock)]
2450 async fn peek_stale_miss_emits_no_peek_served_counter() {
2451 let _lock = PEEK_STALE_LOG_LOCK
2453 .lock()
2454 .unwrap_or_else(|e| e.into_inner());
2455 let repo = Arc::new(MockCacheRepository::new("mock"));
2456 let collector = RecordingMetricsCollector::new();
2457 let counters = collector.counters.clone();
2458 let rt = Arc::new(TestOtelmRt {
2459 collector: Arc::new(collector),
2460 });
2461 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, rt);
2462
2463 let outcome = svc.run(exchange()).await;
2464 assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
2465
2466 let recorded = counters.lock().unwrap().clone();
2467 assert!(
2468 !recorded
2469 .iter()
2470 .any(|(name, _, _)| name == "camel.cache.peek_stale_served"),
2471 "expected zero camel.cache.peek_stale_served counters, got: {recorded:?}"
2472 );
2473 }
2474
2475 #[tokio::test]
2476 async fn invalidate_err_emits_no_invalidations_counter() {
2477 let repo = Arc::new(MockCacheRepository::new("mock"));
2479 repo.seed(
2480 "cache-key",
2481 CacheEntry {
2482 bytes: b"to-go".to_vec(),
2483 payload_path: None,
2484 content_type: ContentType::Bytes,
2485 expires_at: None,
2486 },
2487 )
2488 .await;
2489 repo.set_should_fail_invalidate(true);
2490 let collector = RecordingMetricsCollector::new();
2491 let counters = collector.counters.clone();
2492 let rt = Arc::new(TestOtelmRt {
2493 collector: Arc::new(collector),
2494 });
2495 let mut svc =
2496 CacheInvalidateService::new(repo, CacheInvalidateTarget::Key(fixed_key()), rt);
2497
2498 let outcome = svc.run(exchange()).await;
2499 assert!(matches!(outcome, PipelineOutcome::Failed(_)));
2500
2501 let recorded = counters.lock().unwrap().clone();
2502 assert!(
2503 !recorded
2504 .iter()
2505 .any(|(name, _, _)| name == "camel.cache.invalidations"),
2506 "expected zero camel.cache.invalidations counters, got: {recorded:?}"
2507 );
2508 }
2509
2510 #[tokio::test]
2513 async fn cache_invalidate_prefix_removes_namespace_sets_count() {
2514 let repo = Arc::new(MockCacheRepository::new("mock"));
2515 for key in ["ns:one", "ns:two", "other:x"] {
2516 repo.seed(
2517 key,
2518 CacheEntry {
2519 bytes: key.as_bytes().to_vec(),
2520 payload_path: None,
2521 content_type: ContentType::Bytes,
2522 expires_at: None,
2523 },
2524 )
2525 .await;
2526 }
2527
2528 let collector = RecordingMetricsCollector::new();
2529 let counters = collector.counters.clone();
2530 let rt = Arc::new(TestOtelmRt {
2531 collector: Arc::new(collector),
2532 });
2533 let mut svc = CacheInvalidateService::new(
2534 repo.clone(),
2535 CacheInvalidateTarget::Prefix(prefix_key()),
2536 rt,
2537 );
2538
2539 let outcome = svc.run(exchange()).await;
2540
2541 let ex = match outcome {
2542 PipelineOutcome::Completed(ex) => ex,
2543 other => panic!("expected Completed, got {other:?}"),
2544 };
2545 assert_eq!(
2546 ex.property(CAMEL_CACHE_INVALIDATED_COUNT),
2547 Some(&serde_json::Value::from(2u64)),
2548 "prefix purge must report the removed count"
2549 );
2550 assert!(
2551 repo.stored_entry("ns:one").await.is_none(),
2552 "ns:one must be removed"
2553 );
2554 assert!(
2555 repo.stored_entry("ns:two").await.is_none(),
2556 "ns:two must be removed"
2557 );
2558 assert!(
2559 repo.stored_entry("other:x").await.is_some(),
2560 "other:x must be preserved"
2561 );
2562
2563 let recorded = counters.lock().unwrap().clone();
2564 assert!(
2565 recorded.contains(&(
2566 "camel.cache.invalidations".to_string(),
2567 1.0,
2568 vec![("repository".to_string(), "mock".to_string())]
2569 )),
2570 "expected one camel.cache.invalidations counter, got: {recorded:?}"
2571 );
2572 }
2573
2574 #[tokio::test]
2575 async fn cache_invalidate_prefix_none_expr_completes() {
2576 let repo = Arc::new(MockCacheRepository::new("mock"));
2577 let mut svc = CacheInvalidateService::new(
2578 repo.clone(),
2579 CacheInvalidateTarget::Prefix(none_key()),
2580 noop_rt(),
2581 );
2582
2583 let outcome = svc.run(exchange()).await;
2584
2585 let ex = match outcome {
2586 PipelineOutcome::Completed(ex) => ex,
2587 other => panic!("expected Completed, got {other:?}"),
2588 };
2589 assert_eq!(
2590 repo.invalidate_call_count(),
2591 0,
2592 "no invalidate calls when prefix expr resolves to None"
2593 );
2594 assert!(
2595 ex.property(CAMEL_CACHE_INVALIDATED_COUNT).is_none(),
2596 "no count property when prefix expr resolves to None"
2597 );
2598 }
2599
2600 #[tokio::test]
2601 async fn cache_invalidate_prefix_unsupported_fails_closed() {
2602 let repo = Arc::new(MockCacheRepository::new("mock"));
2603 repo.set_prefix_unsupported(true);
2604 let mut svc = CacheInvalidateService::new(
2605 repo,
2606 CacheInvalidateTarget::Prefix(prefix_key()),
2607 noop_rt(),
2608 );
2609
2610 let outcome = svc.run(exchange()).await;
2611
2612 match outcome {
2613 PipelineOutcome::Failed(e) => {
2614 let msg = format!("{e}");
2615 assert!(
2616 msg.contains("mock"),
2617 "error must name the backend, got: {msg}"
2618 );
2619 }
2620 other => panic!("expected Failed, got {other:?}"),
2621 }
2622 }
2623
2624 use std::sync::atomic::AtomicUsize;
2627 use tokio::sync::Notify;
2628
2629 #[derive(Clone)]
2631 enum GatedOutcome {
2632 Complete(Body),
2633 Fail(CamelError),
2634 Stop,
2635 }
2636
2637 #[derive(Clone)]
2642 struct GatedOnMiss {
2643 leader_entered: Option<Arc<Notify>>,
2644 release: Option<Arc<Notify>>,
2645 invocations: Arc<AtomicUsize>,
2646 outcome: GatedOutcome,
2647 }
2648
2649 impl OutcomePipeline for GatedOnMiss {
2650 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
2651 Box::new(self.clone())
2652 }
2653
2654 fn run<'a>(
2655 &'a mut self,
2656 mut exchange: Exchange,
2657 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
2658 let leader_entered = self.leader_entered.clone();
2659 let release = self.release.clone();
2660 let invocations = Arc::clone(&self.invocations);
2661 let outcome = self.outcome.clone();
2662 Box::pin(async move {
2663 if let Some(entered) = leader_entered.as_ref() {
2665 entered.notify_one();
2666 }
2667 if let Some(release) = release.as_ref() {
2669 release.notified().await;
2670 }
2671 invocations.fetch_add(1, Ordering::SeqCst);
2672 match outcome {
2673 GatedOutcome::Complete(body) => {
2674 exchange.input.body = body;
2675 PipelineOutcome::Completed(exchange)
2676 }
2677 GatedOutcome::Fail(e) => PipelineOutcome::Failed(e),
2678 GatedOutcome::Stop => PipelineOutcome::Stopped(exchange),
2679 }
2680 })
2681 }
2682 }
2683
2684 fn build_gated_service(
2686 repo: Arc<MockCacheRepository>,
2687 outcome: GatedOutcome,
2688 leader_entered: Option<Arc<Notify>>,
2689 release: Option<Arc<Notify>>,
2690 ) -> (CacheService, Arc<AtomicUsize>) {
2691 let invocations = Arc::new(AtomicUsize::new(0));
2692 let on_miss = OutcomeSegment::new(Box::new(GatedOnMiss {
2693 leader_entered,
2694 release,
2695 invocations: Arc::clone(&invocations),
2696 outcome,
2697 }));
2698 let svc = CacheService::new(repo, fixed_key(), None, 1024, on_miss, noop_rt())
2699 .with_coalesce(true);
2700 (svc, invocations)
2701 }
2702
2703 fn exchange_with_body(text: &str) -> Exchange {
2704 Exchange::new(Message::new(text))
2705 }
2706
2707 #[tokio::test]
2708 async fn coalesce_three_concurrent_misses_fetch_once() {
2709 let repo = Arc::new(MockCacheRepository::new("mock"));
2710 let leader_entered = Arc::new(Notify::new());
2711 let release = Arc::new(Notify::new());
2712 let (svc, invocations) = build_gated_service(
2713 repo.clone(),
2714 GatedOutcome::Complete(Body::Text("fetched".into())),
2715 Some(Arc::clone(&leader_entered)),
2716 Some(Arc::clone(&release)),
2717 );
2718
2719 let entered = leader_entered.notified();
2722 tokio::pin!(entered);
2723 entered.as_mut().enable();
2724
2725 let mut leader_svc = svc.clone();
2726 let leader = tokio::spawn(async move { leader_svc.run(exchange()).await });
2727 entered.await; let mut w1_svc = svc.clone();
2730 let mut w1 = tokio::spawn(async move { w1_svc.run(exchange()).await });
2731 let mut w2_svc = svc.clone();
2732 let mut w2 = tokio::spawn(async move { w2_svc.run(exchange()).await });
2733
2734 for waiter in [&mut w1, &mut w2] {
2736 if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), waiter).await {
2737 panic!("waiter resolved before release: {done:?}")
2738 }
2739 }
2740
2741 release.notify_waiters();
2742
2743 let leader_ex = match leader.await.expect("leader task join") {
2744 PipelineOutcome::Completed(ex) => ex,
2745 other => panic!("expected leader Completed, got {other:?}"),
2746 };
2747 let w1_ex = match w1.await.expect("waiter 1 task join") {
2748 PipelineOutcome::Completed(ex) => ex,
2749 other => panic!("expected waiter 1 Completed, got {other:?}"),
2750 };
2751 let w2_ex = match w2.await.expect("waiter 2 task join") {
2752 PipelineOutcome::Completed(ex) => ex,
2753 other => panic!("expected waiter 2 Completed, got {other:?}"),
2754 };
2755 assert_eq!(leader_ex.input.body, Body::Text("fetched".into()));
2756 assert_eq!(w1_ex.input.body, Body::Text("fetched".into()));
2757 assert_eq!(w2_ex.input.body, Body::Text("fetched".into()));
2758 assert_eq!(invocations.load(Ordering::SeqCst), 1, "on_miss ran once");
2759 assert_eq!(repo.set_call_count(), 1, "single write-back set");
2760 }
2761
2762 #[tokio::test]
2763 async fn coalesce_leader_failure_fails_waiters_once() {
2764 let repo = Arc::new(MockCacheRepository::new("mock"));
2765 let leader_entered = Arc::new(Notify::new());
2766 let release = Arc::new(Notify::new());
2767 let (svc, invocations) = build_gated_service(
2768 repo.clone(),
2769 GatedOutcome::Fail(stub_error("coalesce-boom")),
2770 Some(Arc::clone(&leader_entered)),
2771 Some(Arc::clone(&release)),
2772 );
2773
2774 let entered = leader_entered.notified();
2775 tokio::pin!(entered);
2776 entered.as_mut().enable();
2777
2778 let mut leader_svc = svc.clone();
2779 let leader = tokio::spawn(async move { leader_svc.run(exchange()).await });
2780 entered.await;
2781
2782 let mut w1_svc = svc.clone();
2783 let mut w1 = tokio::spawn(async move { w1_svc.run(exchange()).await });
2784 let mut w2_svc = svc.clone();
2785 let mut w2 = tokio::spawn(async move { w2_svc.run(exchange()).await });
2786
2787 for waiter in [&mut w1, &mut w2] {
2788 if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), waiter).await {
2789 panic!("waiter resolved before release: {done:?}")
2790 }
2791 }
2792
2793 release.notify_waiters();
2794
2795 let leader_err = match leader.await.expect("leader task join") {
2796 PipelineOutcome::Failed(e) => e,
2797 other => panic!("expected leader Failed, got {other:?}"),
2798 };
2799 let w1_err = match w1.await.expect("waiter 1 task join") {
2800 PipelineOutcome::Failed(e) => e,
2801 other => panic!("expected waiter 1 Failed, got {other:?}"),
2802 };
2803 let w2_err = match w2.await.expect("waiter 2 task join") {
2804 PipelineOutcome::Failed(e) => e,
2805 other => panic!("expected waiter 2 Failed, got {other:?}"),
2806 };
2807 assert_eq!(format!("{leader_err}"), format!("{w1_err}"));
2808 assert_eq!(format!("{leader_err}"), format!("{w2_err}"));
2809 assert_eq!(invocations.load(Ordering::SeqCst), 1, "on_miss ran once");
2810 assert_eq!(repo.set_call_count(), 0, "no write-back on failure");
2811 }
2812
2813 #[tokio::test]
2814 async fn coalesce_leader_stopped_stops_waiters() {
2815 let repo = Arc::new(MockCacheRepository::new("mock"));
2816 let leader_entered = Arc::new(Notify::new());
2817 let release = Arc::new(Notify::new());
2818 let (svc, invocations) = build_gated_service(
2819 repo.clone(),
2820 GatedOutcome::Stop,
2821 Some(Arc::clone(&leader_entered)),
2822 Some(Arc::clone(&release)),
2823 );
2824
2825 let entered = leader_entered.notified();
2826 tokio::pin!(entered);
2827 entered.as_mut().enable();
2828
2829 let mut leader_svc = svc.clone();
2830 let leader =
2831 tokio::spawn(async move { leader_svc.run(exchange_with_body("leader-orig")).await });
2832 entered.await;
2833
2834 let mut w1_svc = svc.clone();
2835 let mut w1 =
2836 tokio::spawn(async move { w1_svc.run(exchange_with_body("waiter-orig")).await });
2837
2838 if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), &mut w1).await {
2839 panic!("waiter resolved before release: {done:?}")
2840 }
2841
2842 release.notify_waiters();
2843
2844 let leader_ex = match leader.await.expect("leader task join") {
2845 PipelineOutcome::Stopped(ex) => ex,
2846 other => panic!("expected leader Stopped, got {other:?}"),
2847 };
2848 let waiter_ex = match w1.await.expect("waiter task join") {
2849 PipelineOutcome::Stopped(ex) => ex,
2850 other => panic!("expected waiter Stopped, got {other:?}"),
2851 };
2852 assert_eq!(
2853 leader_ex.input.body,
2854 Body::Text("leader-orig".into()),
2855 "leader stopped with its own exchange"
2856 );
2857 assert_eq!(
2858 waiter_ex.input.body,
2859 Body::Text("waiter-orig".into()),
2860 "waiter stopped with its own exchange, body untouched"
2861 );
2862 assert_eq!(invocations.load(Ordering::SeqCst), 1, "on_miss ran once");
2863 assert_eq!(repo.set_call_count(), 0, "no write-back on stop");
2864 }
2865
2866 #[tokio::test]
2867 async fn coalesce_leader_dropped_does_not_strand_waiters() {
2868 let repo = Arc::new(MockCacheRepository::new("mock"));
2869 let leader_entered = Arc::new(Notify::new());
2870 let release = Arc::new(Notify::new());
2871 let (svc, _invocations) = build_gated_service(
2872 repo,
2873 GatedOutcome::Complete(Body::Text("fetched".into())),
2874 Some(Arc::clone(&leader_entered)),
2875 Some(Arc::clone(&release)),
2876 );
2877
2878 let entered = leader_entered.notified();
2879 tokio::pin!(entered);
2880 entered.as_mut().enable();
2881
2882 let mut leader_svc = svc.clone();
2883 let leader = tokio::spawn(async move { leader_svc.run(exchange()).await });
2884 entered.await;
2885
2886 let mut w1_svc = svc.clone();
2887 let mut w1 = tokio::spawn(async move { w1_svc.run(exchange()).await });
2888
2889 if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), &mut w1).await {
2890 panic!("waiter resolved before leader abort: {done:?}")
2891 }
2892
2893 leader.abort();
2896
2897 let joined = tokio::time::timeout(Duration::from_secs(1), w1)
2898 .await
2899 .expect("waiter completes within 1s after leader drop")
2900 .expect("waiter task join");
2901 match joined {
2902 PipelineOutcome::Failed(e) => {
2903 let msg = format!("{e}");
2904 assert!(
2905 msg.contains("cancelled"),
2906 "expected cancellation terminal, got: {msg}"
2907 );
2908 }
2909 other => panic!("expected waiter Failed, got {other:?}"),
2910 }
2911 assert!(
2912 svc.inflight.lock().unwrap().is_empty(),
2913 "in-flight map must not retain the aborted wave's entry"
2914 );
2915 }
2916
2917 #[tokio::test]
2918 async fn no_coalesce_runs_per_exchange() {
2919 let repo = Arc::new(MockCacheRepository::new("mock"));
2920 let invocations = Arc::new(AtomicUsize::new(0));
2921 let on_miss = OutcomeSegment::new(Box::new(GatedOnMiss {
2922 leader_entered: None,
2923 release: None,
2924 invocations: Arc::clone(&invocations),
2925 outcome: GatedOutcome::Complete(Body::Text("per-exchange".into())),
2926 }));
2927 let svc = CacheService::new(repo.clone(), fixed_key(), None, 1024, on_miss, noop_rt());
2929
2930 let mut a = svc.clone();
2931 let mut b = svc.clone();
2932 let mut c = svc.clone();
2933 let (ra, rb, rc) = tokio::join!(a.run(exchange()), b.run(exchange()), c.run(exchange()));
2934
2935 for outcome in [ra, rb, rc] {
2936 match outcome {
2937 PipelineOutcome::Completed(ex) => {
2938 assert_eq!(ex.input.body, Body::Text("per-exchange".into()));
2939 }
2940 other => panic!("expected Completed, got {other:?}"),
2941 }
2942 }
2943 assert_eq!(
2944 invocations.load(Ordering::SeqCst),
2945 3,
2946 "on_miss ran per exchange"
2947 );
2948 assert_eq!(repo.set_call_count(), 3, "set called per exchange");
2949 }
2950}