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