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