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
37pub struct CacheService {
58 repository: Arc<dyn CacheRepository>,
59 repository_name: String,
61 key_expr: MessageIdExpression,
62 ttl: Option<Duration>,
63 max_entry_bytes: usize,
64 on_miss: OutcomeSegment,
65 rt: Arc<dyn RuntimeObservability>,
66}
67
68impl CacheService {
69 pub fn new(
74 repository: Arc<dyn CacheRepository>,
75 key_expr: MessageIdExpression,
76 ttl: Option<Duration>,
77 max_entry_bytes: usize,
78 on_miss: OutcomeSegment,
79 rt: Arc<dyn RuntimeObservability>,
80 ) -> Self {
81 let repository_name = repository.name().to_string();
82 Self {
83 repository,
84 repository_name,
85 key_expr,
86 ttl,
87 max_entry_bytes,
88 on_miss,
89 rt,
90 }
91 }
92
93 pub fn repository_name(&self) -> &str {
95 &self.repository_name
96 }
97}
98
99#[allow(clippy::too_many_arguments)]
107async fn write_back(
108 repository: &Arc<dyn CacheRepository>,
109 repository_name: &str,
110 max_entry_bytes: usize,
111 ttl: Option<Duration>,
112 exchange: Exchange,
113 key: &str,
114 serialized: Vec<u8>,
115 content_type: ContentType,
116) -> PipelineOutcome {
117 if serialized.len() <= max_entry_bytes {
118 let entry = CacheEntry {
119 bytes: serialized,
120 content_type,
121 expires_at: None,
122 };
123 match repository.set(key, entry, ttl).await {
124 Ok(()) => {}
125 Err(e) => {
126 if matches!(&e, CamelError::Config(msg) if msg.starts_with("cache: max_entries")) {
127 tracing::debug!(
128 repository = %repository_name,
129 key = %key,
130 "cache at capacity, skipping write-back"
131 ); } else {
133 return PipelineOutcome::Failed(e);
134 }
135 }
136 }
137 } else {
138 tracing::debug!(
140 repository = %repository_name,
141 key = %key,
142 len = serialized.len(),
143 max = max_entry_bytes,
144 "cache write-back skipped: body exceeds max_entry_bytes"
145 );
146 }
147 PipelineOutcome::Completed(exchange)
148}
149
150impl Clone for CacheService {
151 fn clone(&self) -> Self {
152 Self {
153 repository: Arc::clone(&self.repository),
154 repository_name: self.repository_name.clone(),
155 key_expr: Arc::clone(&self.key_expr),
156 ttl: self.ttl,
157 max_entry_bytes: self.max_entry_bytes,
158 on_miss: self.on_miss.clone(),
159 rt: Arc::clone(&self.rt),
160 }
161 }
162}
163
164impl OutcomePipeline for CacheService {
165 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
166 Box::new(self.clone())
167 }
168
169 fn run<'a>(
170 &'a mut self,
171 exchange: Exchange,
172 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
173 Box::pin(async move {
174 let key = match (self.key_expr)(&exchange) {
176 Some(k) => k,
177 None => return self.on_miss.run(exchange).await,
178 };
179
180 match self.repository.get(&key).await {
182 Err(e) => return PipelineOutcome::Failed(e),
183 Ok(Some(entry)) => {
184 self.rt.metrics().record_counter(
186 "camel.cache.hits",
187 1.0_f64,
188 &[("repository", &self.repository_name)],
189 );
190 match reconstruct_body(&entry) {
191 Ok(body) => {
192 let mut exchange = exchange;
193 exchange.input.body = body;
194 return PipelineOutcome::Completed(exchange);
195 }
196 Err(e) => return PipelineOutcome::Failed(e),
197 }
198 }
199 Ok(None) => {
200 self.rt.metrics().record_counter(
202 "camel.cache.misses",
203 1.0_f64,
204 &[("repository", &self.repository_name)],
205 );
206 }
207 }
208
209 let mut exchange = match self.on_miss.run(exchange).await {
211 PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
212 PipelineOutcome::Failed(e) => return PipelineOutcome::Failed(e),
213 PipelineOutcome::Completed(ex) => ex,
214 };
215
216 let body = std::mem::replace(&mut exchange.input.body, Body::Empty);
218 match body {
219 Body::Bytes(b) => {
220 let serialized = b.to_vec();
221 exchange.input.body = Body::Bytes(b);
222 write_back(
223 &self.repository,
224 &self.repository_name,
225 self.max_entry_bytes,
226 self.ttl,
227 exchange,
228 &key,
229 serialized,
230 ContentType::Bytes,
231 )
232 .await
233 }
234 Body::Text(s) => {
235 let serialized = s.as_bytes().to_vec();
236 exchange.input.body = Body::Text(s);
237 write_back(
238 &self.repository,
239 &self.repository_name,
240 self.max_entry_bytes,
241 self.ttl,
242 exchange,
243 &key,
244 serialized,
245 ContentType::Text,
246 )
247 .await
248 }
249 Body::Json(v) => {
250 let serialized = match serde_json::to_vec(&v) {
251 Ok(b) => b,
252 Err(e) => {
253 exchange.input.body = Body::Json(v);
254 return PipelineOutcome::Failed(CamelError::TypeConversionFailed(
255 e.to_string(),
256 ));
257 }
258 };
259 exchange.input.body = Body::Json(v);
260 write_back(
261 &self.repository,
262 &self.repository_name,
263 self.max_entry_bytes,
264 self.ttl,
265 exchange,
266 &key,
267 serialized,
268 ContentType::Json,
269 )
270 .await
271 }
272 Body::Xml(s) => {
273 let serialized = s.as_bytes().to_vec();
274 exchange.input.body = Body::Xml(s);
275 write_back(
276 &self.repository,
277 &self.repository_name,
278 self.max_entry_bytes,
279 self.ttl,
280 exchange,
281 &key,
282 serialized,
283 ContentType::Xml,
284 )
285 .await
286 }
287 Body::Stream(stream_body) => {
288 let materialized = match Body::Stream(stream_body)
290 .into_bytes(self.max_entry_bytes)
291 .await
292 {
293 Ok(b) => b,
294 Err(e) => return PipelineOutcome::Failed(e),
295 };
296 let entry = CacheEntry {
298 bytes: materialized.to_vec(),
299 content_type: ContentType::Bytes,
300 expires_at: None,
301 };
302 if let Err(e) = self.repository.set(&key, entry, self.ttl).await {
303 if matches!(&e, CamelError::Config(msg) if msg.starts_with("cache: max_entries"))
305 {
306 tracing::debug!(
307 repository = %self.repository_name,
308 key = %key,
309 "cache at capacity, skipping write-back for stream"
310 ); exchange.input.body = Body::Bytes(materialized);
312 return PipelineOutcome::Completed(exchange);
313 }
314 exchange.input.body = Body::Bytes(materialized);
315 return PipelineOutcome::Failed(e);
316 }
317 exchange.input.body = Body::Bytes(materialized);
318 PipelineOutcome::Completed(exchange)
319 }
320 _ => {
321 exchange.input.body = body;
323 PipelineOutcome::Completed(exchange)
324 }
325 }
326 })
327 }
328}
329
330fn reconstruct_body(entry: &CacheEntry) -> Result<Body, CamelError> {
335 match entry.content_type {
336 ContentType::Bytes => Ok(Body::Bytes(Bytes::from(entry.bytes.clone()))),
337 ContentType::Text => {
338 let s = String::from_utf8(entry.bytes.clone()).map_err(|e| {
339 CamelError::TypeConversionFailed(format!("cached text is not valid UTF-8: {e}"))
340 })?;
341 Ok(Body::Text(s))
342 }
343 ContentType::Json => {
344 let v = serde_json::from_slice(&entry.bytes).map_err(|e| {
345 CamelError::TypeConversionFailed(format!("cached bytes are not valid JSON: {e}"))
346 })?;
347 Ok(Body::Json(v))
348 }
349 ContentType::Xml => {
350 let s = String::from_utf8(entry.bytes.clone()).map_err(|e| {
351 CamelError::TypeConversionFailed(format!("cached xml is not valid UTF-8: {e}"))
352 })?;
353 Ok(Body::Xml(s))
354 }
355 }
356}
357
358pub struct CacheInvalidateService {
370 repository: Arc<dyn CacheRepository>,
371 key_expr: MessageIdExpression,
372}
373
374impl CacheInvalidateService {
375 pub fn new(repository: Arc<dyn CacheRepository>, key_expr: MessageIdExpression) -> Self {
376 Self {
377 repository,
378 key_expr,
379 }
380 }
381}
382
383impl Clone for CacheInvalidateService {
384 fn clone(&self) -> Self {
385 Self {
386 repository: Arc::clone(&self.repository),
387 key_expr: Arc::clone(&self.key_expr),
388 }
389 }
390}
391
392impl OutcomePipeline for CacheInvalidateService {
393 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
394 Box::new(self.clone())
395 }
396
397 fn run<'a>(
398 &'a mut self,
399 exchange: Exchange,
400 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
401 Box::pin(async move {
402 let key = match (self.key_expr)(&exchange) {
403 Some(k) => k,
404 None => return PipelineOutcome::Completed(exchange),
405 };
406 match self.repository.invalidate(&key).await {
407 Err(e) => PipelineOutcome::Failed(e),
408 Ok(()) => PipelineOutcome::Completed(exchange),
409 }
410 })
411 }
412}
413
414pub const CAMEL_CACHE_PEEK_HIT: &str = "CamelCachePeekHit";
420pub const CAMEL_CACHE_PEEK_STALE: &str = "CamelCachePeekStale";
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum PeekStaleMissPolicy {
431 Stop,
433 Continue,
435}
436
437impl PeekStaleMissPolicy {
438 pub fn parse_on_miss(raw: Option<&str>) -> Result<Self, CamelError> {
443 match raw {
444 None | Some("stop") => Ok(Self::Stop),
445 Some("continue") => Ok(Self::Continue),
446 Some(other) => Err(CamelError::Config(format!(
447 "cache_peek_stale: invalid on_miss '{other}'; must be \"stop\" or \"continue\""
448 ))),
449 }
450 }
451}
452
453pub struct CachePeekStaleService {
472 repository: Arc<dyn CacheRepository>,
473 key_expr: MessageIdExpression,
474 miss_policy: PeekStaleMissPolicy,
475}
476
477impl CachePeekStaleService {
478 pub fn new(
479 repository: Arc<dyn CacheRepository>,
480 key_expr: MessageIdExpression,
481 miss_policy: PeekStaleMissPolicy,
482 ) -> Self {
483 Self {
484 repository,
485 key_expr,
486 miss_policy,
487 }
488 }
489}
490
491impl Clone for CachePeekStaleService {
492 fn clone(&self) -> Self {
493 Self {
494 repository: Arc::clone(&self.repository),
495 key_expr: Arc::clone(&self.key_expr),
496 miss_policy: self.miss_policy,
497 }
498 }
499}
500
501fn set_peek_properties(exchange: &mut Exchange, hit: bool, stale: bool) {
504 exchange.set_property(CAMEL_CACHE_PEEK_HIT, serde_json::Value::Bool(hit));
505 exchange.set_property(CAMEL_CACHE_PEEK_STALE, serde_json::Value::Bool(stale));
506}
507
508impl OutcomePipeline for CachePeekStaleService {
509 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
510 Box::new(self.clone())
511 }
512
513 fn run<'a>(
514 &'a mut self,
515 exchange: Exchange,
516 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
517 Box::pin(async move {
518 let key = match (self.key_expr)(&exchange) {
519 Some(k) => k,
520 None => {
521 tracing::debug!(
522 step = "cache_peek_stale",
523 repository = %self.repository.name(),
524 "key expression resolved to None; stopping branch"
525 );
526 return PipelineOutcome::Stopped(exchange);
527 }
528 };
529 match self.repository.peek_stale(&key).await {
530 Err(e) => PipelineOutcome::Failed(e),
531 Ok(Some(entry)) => {
532 let stale = entry
534 .expires_at
535 .map(|t| t <= SystemTime::now())
536 .unwrap_or(false);
537 match reconstruct_body(&entry) {
538 Ok(body) => {
539 let mut exchange = exchange;
540 exchange.input.body = body;
541 set_peek_properties(&mut exchange, true, stale);
542 PipelineOutcome::Completed(exchange)
543 }
544 Err(e) => PipelineOutcome::Failed(e),
545 }
546 }
547 Ok(None) => match self.miss_policy {
548 PeekStaleMissPolicy::Stop => {
549 let mut exchange = exchange;
550 set_peek_properties(&mut exchange, false, false);
551 tracing::debug!(
552 step = "cache_peek_stale",
553 repository = %self.repository.name(),
554 "peek miss; stopping branch per on_miss=stop"
555 );
556 PipelineOutcome::Stopped(exchange)
557 }
558 PeekStaleMissPolicy::Continue => {
559 let mut exchange = exchange;
560 set_peek_properties(&mut exchange, false, false);
561 PipelineOutcome::Completed(exchange)
562 }
563 },
564 }
565 })
566 }
567}
568
569#[cfg(test)]
574mod test_utils {
575 use super::*;
576 use async_trait::async_trait;
577 use std::collections::HashMap;
578 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
579 use tokio::sync::Mutex;
580
581 #[derive(Debug, Default)]
585 pub struct MockCacheRepository {
586 name: String,
587 entries: Arc<Mutex<HashMap<String, CacheEntry>>>,
588 get_should_fail: Arc<AtomicBool>,
589 set_should_fail: Arc<AtomicBool>,
590 set_call_count: Arc<AtomicU32>,
591 last_set_ttl: Arc<Mutex<Option<Duration>>>,
592 invalidate_call_count: Arc<AtomicU32>,
593 last_invalidate_key: Arc<Mutex<Option<String>>>,
594 }
595
596 impl MockCacheRepository {
597 pub fn new(name: &str) -> Self {
598 Self {
599 name: name.to_string(),
600 ..Default::default()
601 }
602 }
603
604 pub fn invalidate_call_count(&self) -> u32 {
605 self.invalidate_call_count.load(Ordering::SeqCst)
606 }
607
608 pub async fn last_invalidate_key(&self) -> Option<String> {
609 self.last_invalidate_key.lock().await.clone()
610 }
611
612 pub async fn seed(&self, key: &str, entry: CacheEntry) {
614 self.entries.lock().await.insert(key.to_string(), entry);
615 }
616
617 pub fn set_get_should_fail(&self, v: bool) {
618 self.get_should_fail.store(v, Ordering::SeqCst);
619 }
620
621 pub fn set_set_should_fail(&self, v: bool) {
622 self.set_should_fail.store(v, Ordering::SeqCst);
623 }
624
625 pub fn set_call_count(&self) -> u32 {
626 self.set_call_count.load(Ordering::SeqCst)
627 }
628
629 pub async fn last_set_ttl(&self) -> Option<Duration> {
631 *self.last_set_ttl.lock().await
632 }
633
634 pub async fn stored_entry(&self, key: &str) -> Option<CacheEntry> {
636 self.entries.lock().await.get(key).cloned()
637 }
638 }
639
640 #[async_trait]
641 impl CacheRepository for MockCacheRepository {
642 fn name(&self) -> &str {
643 &self.name
644 }
645
646 async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
647 if self.get_should_fail.load(Ordering::SeqCst) {
648 return Err(CamelError::ProcessorError("synthetic get failure".into()));
649 }
650 Ok(self.entries.lock().await.get(key).cloned())
651 }
652
653 async fn set(
654 &self,
655 key: &str,
656 value: CacheEntry,
657 ttl: Option<Duration>,
658 ) -> Result<(), CamelError> {
659 self.set_call_count.fetch_add(1, Ordering::SeqCst);
660 *self.last_set_ttl.lock().await = ttl;
661 if self.set_should_fail.load(Ordering::SeqCst) {
662 return Err(CamelError::ProcessorError("synthetic set failure".into()));
663 }
664 self.entries.lock().await.insert(key.to_string(), value);
665 Ok(())
666 }
667
668 async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
669 self.get(key).await
670 }
671
672 async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
673 self.invalidate_call_count.fetch_add(1, Ordering::SeqCst);
674 *self.last_invalidate_key.lock().await = Some(key.to_string());
675 self.entries.lock().await.remove(key);
676 Ok(())
677 }
678
679 async fn clear(&self) -> Result<(), CamelError> {
680 self.entries.lock().await.clear();
681 Ok(())
682 }
683 }
684}
685
686#[cfg(test)]
691mod tests {
692 use super::test_utils::MockCacheRepository;
693 use super::*;
694 use camel_api::body::{StreamBody, StreamMetadata};
695 use camel_api::metrics::NoOpMetrics;
696 use camel_api::{Message, Value};
697 use camel_component_api::health_registry::NoOpHealthCheckRegistry;
698 use futures::stream;
699 use std::sync::Mutex;
700 use std::sync::atomic::{AtomicBool, Ordering};
701 use std::time::SystemTime;
702
703 #[test]
704 fn parse_on_miss_maps_absent_stop_and_continue() {
705 assert_eq!(
706 PeekStaleMissPolicy::parse_on_miss(None).unwrap(),
707 PeekStaleMissPolicy::Stop
708 );
709 assert_eq!(
710 PeekStaleMissPolicy::parse_on_miss(Some("stop")).unwrap(),
711 PeekStaleMissPolicy::Stop
712 );
713 assert_eq!(
714 PeekStaleMissPolicy::parse_on_miss(Some("continue")).unwrap(),
715 PeekStaleMissPolicy::Continue
716 );
717 }
718
719 #[test]
720 fn parse_on_miss_rejects_unknown_value_naming_the_step() {
721 let err = PeekStaleMissPolicy::parse_on_miss(Some("explode")).unwrap_err();
722 let msg = format!("{err}");
723 assert!(msg.contains("cache_peek_stale"), "got: {msg}");
724 assert!(msg.contains("explode"), "got: {msg}");
725 }
726
727 #[derive(Clone)]
729 struct NoopRt;
730
731 impl camel_component_api::HealthCheckRegistry for NoopRt {
732 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
733 }
734
735 impl RuntimeObservability for NoopRt {
736 fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
737 Arc::new(NoOpMetrics)
738 }
739 fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
740 Arc::new(NoOpHealthCheckRegistry)
741 }
742 }
743
744 fn noop_rt() -> Arc<dyn RuntimeObservability> {
745 Arc::new(NoopRt)
746 }
747
748 #[derive(Clone)]
751 enum ScriptedOutcome {
752 Complete,
753 Stop,
754 Fail(CamelError),
755 }
756
757 struct ScriptedOnMiss {
760 body: Option<Body>,
761 outcome: ScriptedOutcome,
762 invoked: Arc<AtomicBool>,
763 }
764
765 impl OutcomePipeline for ScriptedOnMiss {
766 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
767 unreachable!("clone_box not used in cache_eip tests")
769 }
770
771 fn run<'a>(
772 &'a mut self,
773 mut exchange: Exchange,
774 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
775 self.invoked.store(true, Ordering::SeqCst);
776 let body = self.body.take();
777 let outcome = self.outcome.clone();
778 Box::pin(async move {
779 if let Some(b) = body {
780 exchange.input.body = b;
781 }
782 match outcome {
783 ScriptedOutcome::Complete => PipelineOutcome::Completed(exchange),
784 ScriptedOutcome::Stop => PipelineOutcome::Stopped(exchange),
785 ScriptedOutcome::Fail(e) => PipelineOutcome::Failed(e),
786 }
787 })
788 }
789 }
790
791 fn fixed_key() -> MessageIdExpression {
794 Arc::new(|_| Some("cache-key".to_string()))
795 }
796
797 fn none_key() -> MessageIdExpression {
798 Arc::new(|_| None)
799 }
800
801 fn build_service(
803 repo: Arc<MockCacheRepository>,
804 key_expr: MessageIdExpression,
805 max_entry_bytes: usize,
806 body: Option<Body>,
807 outcome: ScriptedOutcome,
808 ttl: Option<Duration>,
809 rt: Arc<dyn RuntimeObservability>,
810 ) -> (CacheService, Arc<AtomicBool>) {
811 let invoked = Arc::new(AtomicBool::new(false));
812 let on_miss = OutcomeSegment::new(Box::new(ScriptedOnMiss {
813 body,
814 outcome,
815 invoked: invoked.clone(),
816 }));
817 let svc = CacheService::new(repo, key_expr, ttl, max_entry_bytes, on_miss, rt);
818 (svc, invoked)
819 }
820
821 fn exchange() -> Exchange {
822 let mut ex = Exchange::new(Message::new(""));
823 ex.input.set_header("ignored", Value::String("v".into()));
824 ex
825 }
826
827 fn stream_body(data: &'static [u8]) -> Body {
828 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from_static(data))];
829 let s = stream::iter(chunks);
830 Body::Stream(StreamBody {
831 stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(s)))),
832 metadata: StreamMetadata::default(),
833 })
834 }
835
836 fn stub_error(msg: &str) -> CamelError {
837 CamelError::ProcessorError(msg.into())
838 }
839
840 #[tokio::test]
843 async fn cache_hit_short_circuits_on_miss() {
844 let repo = Arc::new(MockCacheRepository::new("mock"));
845 repo.seed(
846 "cache-key",
847 CacheEntry {
848 bytes: b"cached-payload".to_vec(),
849 content_type: ContentType::Bytes,
850 expires_at: None,
851 },
852 )
853 .await;
854 let (mut svc, on_miss_invoked) = build_service(
855 repo,
856 fixed_key(),
857 1024,
858 Some(Body::Bytes(Bytes::from_static(b"unreached"))),
859 ScriptedOutcome::Complete,
860 None,
861 noop_rt(),
862 );
863
864 let outcome = svc.run(exchange()).await;
865
866 let ex = match outcome {
867 PipelineOutcome::Completed(ex) => ex,
868 other => panic!("expected Completed, got {other:?}"),
869 };
870 assert_eq!(
871 ex.input.body,
872 Body::Bytes(Bytes::from_static(b"cached-payload"))
873 );
874 assert!(
875 !on_miss_invoked.load(Ordering::SeqCst),
876 "on_miss must NOT run on a cache HIT"
877 );
878 }
879
880 #[tokio::test]
883 async fn cache_miss_runs_on_miss_sets_continues() {
884 let ttl = Duration::from_secs(30);
885 let repo = Arc::new(MockCacheRepository::new("mock"));
886 let (mut svc, on_miss_invoked) = build_service(
887 repo.clone(),
888 fixed_key(),
889 1024,
890 Some(Body::Bytes(Bytes::from_static(b"x"))),
891 ScriptedOutcome::Complete,
892 Some(ttl),
893 noop_rt(),
894 );
895
896 let outcome = svc.run(exchange()).await;
897
898 let ex = match outcome {
899 PipelineOutcome::Completed(ex) => ex,
900 other => panic!("expected Completed, got {other:?}"),
901 };
902 assert!(on_miss_invoked.load(Ordering::SeqCst));
903 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
904 assert_eq!(repo.set_call_count(), 1, "set must be called once on miss");
905 let stored = repo
906 .stored_entry("cache-key")
907 .await
908 .expect("entry must be stored");
909 assert_eq!(stored.bytes, b"x");
910 assert_eq!(stored.content_type, ContentType::Bytes);
911 assert_eq!(repo.last_set_ttl().await, Some(ttl));
912 }
913
914 #[tokio::test]
917 async fn cache_miss_oversized_materialized_body_skips_writeback() {
918 let repo = Arc::new(MockCacheRepository::new("mock"));
919 let (mut svc, _invoked) = build_service(
921 repo.clone(),
922 fixed_key(),
923 4,
924 Some(Body::Bytes(Bytes::from_static(b"oversized"))),
925 ScriptedOutcome::Complete,
926 None,
927 noop_rt(),
928 );
929
930 let outcome = svc.run(exchange()).await;
931
932 let ex = match outcome {
933 PipelineOutcome::Completed(ex) => ex,
934 other => panic!("expected Completed, got {other:?}"),
935 };
936 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"oversized")));
938 assert_eq!(
939 repo.set_call_count(),
940 0,
941 "set must NOT be called for oversized body"
942 );
943 assert!(repo.stored_entry("cache-key").await.is_none());
944 }
945
946 #[tokio::test]
949 async fn cache_miss_oversized_stream_propagates_err() {
950 let repo = Arc::new(MockCacheRepository::new("mock"));
951 let (mut svc, _invoked) = build_service(
952 repo.clone(),
953 fixed_key(),
954 4,
955 Some(stream_body(b"way-too-big-stream")),
956 ScriptedOutcome::Complete,
957 None,
958 noop_rt(),
959 );
960
961 let outcome = svc.run(exchange()).await;
962
963 match outcome {
964 PipelineOutcome::Failed(CamelError::StreamLimitExceeded(n)) => {
965 assert_eq!(n, 4);
966 }
967 other => panic!("expected Failed(StreamLimitExceeded(4)), got {other:?}"),
968 }
969 assert_eq!(
970 repo.set_call_count(),
971 0,
972 "set must NOT be called when stream exceeds limit"
973 );
974 }
975
976 #[tokio::test]
979 async fn cache_on_miss_stopped_propagates_without_writeback() {
980 let repo = Arc::new(MockCacheRepository::new("mock"));
981 let (mut svc, _invoked) = build_service(
982 repo.clone(),
983 fixed_key(),
984 1024,
985 None,
986 ScriptedOutcome::Stop,
987 None,
988 noop_rt(),
989 );
990
991 let outcome = svc.run(exchange()).await;
992
993 assert!(
994 matches!(outcome, PipelineOutcome::Stopped(_)),
995 "Stopped from on_miss MUST propagate as Stopped"
996 );
997 assert_eq!(
998 repo.set_call_count(),
999 0,
1000 "set must NOT be called when on_miss Stops"
1001 );
1002 }
1003
1004 #[tokio::test]
1007 async fn cache_on_miss_err_propagates_without_writeback() {
1008 let repo = Arc::new(MockCacheRepository::new("mock"));
1009 let (mut svc, _invoked) = build_service(
1010 repo.clone(),
1011 fixed_key(),
1012 1024,
1013 None,
1014 ScriptedOutcome::Fail(stub_error("on-miss blew up")),
1015 None,
1016 noop_rt(),
1017 );
1018
1019 let outcome = svc.run(exchange()).await;
1020
1021 match outcome {
1022 PipelineOutcome::Failed(e) => {
1023 assert!(e.to_string().contains("on-miss blew up"), "got: {e}");
1024 }
1025 other => panic!("expected Failed, got {other:?}"),
1026 }
1027 assert_eq!(
1028 repo.set_call_count(),
1029 0,
1030 "set must NOT be called when on_miss fails"
1031 );
1032 }
1033
1034 #[tokio::test]
1037 async fn cache_repository_get_err_propagates() {
1038 let repo = Arc::new(MockCacheRepository::new("mock"));
1039 repo.set_get_should_fail(true);
1040 let (mut svc, on_miss_invoked) = build_service(
1041 repo,
1042 fixed_key(),
1043 1024,
1044 Some(Body::Bytes(Bytes::from_static(b"x"))),
1045 ScriptedOutcome::Complete,
1046 None,
1047 noop_rt(),
1048 );
1049
1050 let outcome = svc.run(exchange()).await;
1051
1052 match outcome {
1053 PipelineOutcome::Failed(e) => {
1054 assert!(e.to_string().contains("synthetic get failure"), "got: {e}");
1055 }
1056 other => panic!("expected Failed, got {other:?}"),
1057 }
1058 assert!(
1059 !on_miss_invoked.load(Ordering::SeqCst),
1060 "on_miss must NOT run when get fails"
1061 );
1062 }
1063
1064 #[tokio::test]
1067 async fn cache_repository_set_err_propagates() {
1068 let repo = Arc::new(MockCacheRepository::new("mock"));
1069 repo.set_set_should_fail(true);
1070 let (mut svc, _invoked) = build_service(
1071 repo.clone(),
1072 fixed_key(),
1073 1024,
1074 Some(Body::Bytes(Bytes::from_static(b"x"))),
1075 ScriptedOutcome::Complete,
1076 None,
1077 noop_rt(),
1078 );
1079
1080 let outcome = svc.run(exchange()).await;
1081
1082 match outcome {
1083 PipelineOutcome::Failed(e) => {
1084 assert!(e.to_string().contains("synthetic set failure"), "got: {e}");
1085 }
1086 other => panic!("expected Failed, got {other:?}"),
1087 }
1088 assert_eq!(repo.set_call_count(), 1, "set was attempted (and failed)");
1089 }
1090
1091 #[tokio::test]
1094 async fn cache_none_key_bypasses_to_on_miss() {
1095 let repo = Arc::new(MockCacheRepository::new("mock"));
1096 let (mut svc, on_miss_invoked) = build_service(
1097 repo.clone(),
1098 none_key(),
1099 1024,
1100 Some(Body::Bytes(Bytes::from_static(b"x"))),
1101 ScriptedOutcome::Complete,
1102 None,
1103 noop_rt(),
1104 );
1105
1106 let outcome = svc.run(exchange()).await;
1107
1108 let ex = match outcome {
1109 PipelineOutcome::Completed(ex) => ex,
1110 other => panic!("expected Completed, got {other:?}"),
1111 };
1112 assert!(
1113 on_miss_invoked.load(Ordering::SeqCst),
1114 "on_miss MUST run when key is None"
1115 );
1116 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
1117 assert_eq!(
1118 repo.set_call_count(),
1119 0,
1120 "set must NOT be called when key_expr returns None"
1121 );
1122 }
1123
1124 #[tokio::test]
1127 async fn cache_content_type_reconstruction() {
1128 async fn run_case(entry: CacheEntry, expected: Body) {
1129 let repo = Arc::new(MockCacheRepository::new("mock"));
1130 repo.seed("cache-key", entry).await;
1131 let (mut svc, on_miss_invoked) = build_service(
1132 repo,
1133 fixed_key(),
1134 1024,
1135 Some(Body::Bytes(Bytes::from_static(b"unreached"))),
1136 ScriptedOutcome::Complete,
1137 None,
1138 noop_rt(),
1139 );
1140 let outcome = svc.run(exchange()).await;
1141 let ex = match outcome {
1142 PipelineOutcome::Completed(ex) => ex,
1143 other => panic!("expected Completed, got {other:?}"),
1144 };
1145 assert_eq!(ex.input.body, expected);
1146 assert!(!on_miss_invoked.load(Ordering::SeqCst));
1147 }
1148
1149 run_case(
1150 CacheEntry {
1151 bytes: b"raw".to_vec(),
1152 content_type: ContentType::Bytes,
1153 expires_at: None,
1154 },
1155 Body::Bytes(Bytes::from_static(b"raw")),
1156 )
1157 .await;
1158 run_case(
1159 CacheEntry {
1160 bytes: b"hi".to_vec(),
1161 content_type: ContentType::Text,
1162 expires_at: None,
1163 },
1164 Body::Text("hi".into()),
1165 )
1166 .await;
1167 run_case(
1168 CacheEntry {
1169 bytes: br#"{"k":1}"#.to_vec(),
1170 content_type: ContentType::Json,
1171 expires_at: None,
1172 },
1173 Body::Json(serde_json::json!({"k": 1})),
1174 )
1175 .await;
1176 run_case(
1177 CacheEntry {
1178 bytes: b"<a/>".to_vec(),
1179 content_type: ContentType::Xml,
1180 expires_at: None,
1181 },
1182 Body::Xml("<a/>".into()),
1183 )
1184 .await;
1185 }
1186
1187 #[tokio::test]
1190 async fn cache_miss_stream_body_is_materialized_and_cached() {
1191 let repo = Arc::new(MockCacheRepository::new("mock"));
1192 let (mut svc, _invoked) = build_service(
1193 repo.clone(),
1194 fixed_key(),
1195 1024,
1196 Some(stream_body(b"chunky")),
1197 ScriptedOutcome::Complete,
1198 None,
1199 noop_rt(),
1200 );
1201
1202 let outcome = svc.run(exchange()).await;
1203
1204 let ex = match outcome {
1205 PipelineOutcome::Completed(ex) => ex,
1206 other => panic!("expected Completed, got {other:?}"),
1207 };
1208 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"chunky")));
1210 assert_eq!(repo.set_call_count(), 1);
1211 let stored = repo.stored_entry("cache-key").await.expect("stored");
1212 assert_eq!(stored.bytes, b"chunky");
1213 assert_eq!(stored.content_type, ContentType::Bytes);
1214 }
1215
1216 #[tokio::test]
1219 async fn cache_peek_stale_serves_post_expiry_entry() {
1220 let repo = Arc::new(MockCacheRepository::new("mock"));
1221 repo.seed(
1222 "cache-key",
1223 CacheEntry {
1224 bytes: b"stale-payload".to_vec(),
1225 content_type: ContentType::Text,
1226 expires_at: None,
1227 },
1228 )
1229 .await;
1230 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop);
1231
1232 let outcome = svc.run(exchange()).await;
1233
1234 let ex = match outcome {
1235 PipelineOutcome::Completed(ex) => ex,
1236 other => panic!("expected Completed, got {other:?}"),
1237 };
1238 assert_eq!(ex.input.body, Body::Text("stale-payload".into()));
1239 }
1240
1241 #[tokio::test]
1242 async fn cache_peek_stale_on_absence_stops_branch() {
1243 let repo = Arc::new(MockCacheRepository::new("mock"));
1244 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop);
1245
1246 let outcome = svc.run(exchange()).await;
1247
1248 assert!(
1249 matches!(outcome, PipelineOutcome::Stopped(_)),
1250 "expected Stopped when no stale entry, got {outcome:?}"
1251 );
1252 }
1253
1254 #[tokio::test]
1255 async fn cache_peek_stale_none_key_stops() {
1256 let repo = Arc::new(MockCacheRepository::new("mock"));
1257 let mut svc = CachePeekStaleService::new(repo, none_key(), PeekStaleMissPolicy::Stop);
1258
1259 let outcome = svc.run(exchange()).await;
1260
1261 assert!(
1262 matches!(outcome, PipelineOutcome::Stopped(_)),
1263 "expected Stopped when key_expr returns None, got {outcome:?}"
1264 );
1265 }
1266
1267 #[tokio::test]
1268 async fn peek_stale_miss_stop_sets_properties_and_stops() {
1269 let repo = Arc::new(MockCacheRepository::new("mock"));
1270 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop);
1271
1272 let outcome = svc.run(exchange()).await;
1273
1274 let ex = match outcome {
1275 PipelineOutcome::Stopped(ex) => ex,
1276 other => panic!("expected Stopped, got {other:?}"),
1277 };
1278 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(false)));
1279 assert_eq!(
1280 ex.property(CAMEL_CACHE_PEEK_STALE),
1281 Some(&Value::Bool(false))
1282 );
1283 }
1284
1285 #[tokio::test]
1286 async fn peek_stale_miss_continue_completes_with_body_untouched() {
1287 let repo = Arc::new(MockCacheRepository::new("mock"));
1288 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Continue);
1289
1290 let mut ex = exchange();
1291 ex.input.body = Body::Text("orig".into());
1292
1293 let outcome = svc.run(ex).await;
1294
1295 let ex = match outcome {
1296 PipelineOutcome::Completed(ex) => ex,
1297 other => panic!("expected Completed, got {other:?}"),
1298 };
1299 assert_eq!(ex.input.body, Body::Text("orig".into()));
1300 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(false)));
1301 assert_eq!(
1302 ex.property(CAMEL_CACHE_PEEK_STALE),
1303 Some(&Value::Bool(false))
1304 );
1305 }
1306
1307 #[tokio::test]
1308 async fn peek_stale_hit_sets_hit_and_stale_properties() {
1309 let repo = Arc::new(MockCacheRepository::new("mock"));
1310 repo.seed(
1311 "cache-key",
1312 CacheEntry {
1313 bytes: b"stale-payload".to_vec(),
1314 content_type: ContentType::Bytes,
1315 expires_at: Some(SystemTime::now() - Duration::from_millis(1)),
1316 },
1317 )
1318 .await;
1319 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop);
1320
1321 let outcome = svc.run(exchange()).await;
1322
1323 let ex = match outcome {
1324 PipelineOutcome::Completed(ex) => ex,
1325 other => panic!("expected Completed, got {other:?}"),
1326 };
1327 assert_eq!(
1328 ex.input.body,
1329 Body::Bytes(Bytes::from_static(b"stale-payload"))
1330 );
1331 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(true)));
1332 assert_eq!(
1333 ex.property(CAMEL_CACHE_PEEK_STALE),
1334 Some(&Value::Bool(true))
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn peek_stale_hit_fresh_sets_stale_false() {
1340 let repo = Arc::new(MockCacheRepository::new("mock"));
1341 repo.seed(
1342 "cache-key",
1343 CacheEntry {
1344 bytes: b"fresh-payload".to_vec(),
1345 content_type: ContentType::Bytes,
1346 expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
1347 },
1348 )
1349 .await;
1350 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop);
1351
1352 let outcome = svc.run(exchange()).await;
1353
1354 let ex = match outcome {
1355 PipelineOutcome::Completed(ex) => ex,
1356 other => panic!("expected Completed, got {other:?}"),
1357 };
1358 assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(true)));
1359 assert_eq!(
1360 ex.property(CAMEL_CACHE_PEEK_STALE),
1361 Some(&Value::Bool(false))
1362 );
1363 }
1364
1365 #[derive(Clone)]
1369 struct CapturingWriter {
1370 sink: Arc<Mutex<Vec<u8>>>,
1371 }
1372
1373 impl std::io::Write for CapturingWriter {
1374 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1375 self.sink.lock().unwrap().extend_from_slice(buf); Ok(buf.len())
1377 }
1378 fn flush(&mut self) -> std::io::Result<()> {
1379 Ok(())
1380 }
1381 }
1382
1383 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingWriter {
1384 type Writer = CapturingWriter;
1385 fn make_writer(&'a self) -> Self::Writer {
1386 self.clone()
1387 }
1388 }
1389
1390 fn debug_sink() -> (Arc<Mutex<Vec<u8>>>, impl tracing::Subscriber) {
1391 let sink: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1392 let writer = CapturingWriter {
1393 sink: Arc::clone(&sink),
1394 };
1395 let subscriber = tracing_subscriber::fmt()
1396 .with_writer(writer)
1397 .with_ansi(false)
1398 .with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG)
1399 .finish();
1400 (sink, subscriber)
1401 }
1402
1403 #[tokio::test]
1404 async fn peek_stale_miss_stop_emits_debug_log() {
1405 let repo = Arc::new(MockCacheRepository::new("mock"));
1406 let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop);
1407
1408 let (sink, subscriber) = debug_sink();
1409 let _guard = tracing::subscriber::set_default(subscriber);
1410 tracing::callsite::rebuild_interest_cache();
1414 let outcome = svc.run(exchange()).await;
1415 drop(_guard);
1416
1417 assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
1418
1419 let captured = String::from_utf8(sink.lock().unwrap().clone()).unwrap(); let miss_records: Vec<&str> = captured
1421 .lines()
1422 .filter(|l| l.contains("peek miss"))
1423 .collect();
1424 assert_eq!(
1425 miss_records.len(),
1426 1,
1427 "expected exactly one DEBUG record containing \"peek miss\"; got: {captured}"
1428 );
1429 assert!(
1430 miss_records[0].contains("DEBUG"),
1431 "expected DEBUG level record; got: {captured}"
1432 );
1433 assert!(
1434 miss_records[0].contains("repository=mock"),
1435 "expected repository field in record; got: {captured}"
1436 );
1437 assert!(
1438 miss_records[0].contains("step=\"cache_peek_stale\""),
1439 "expected step field in record; got: {captured}"
1440 );
1441 }
1442
1443 #[tokio::test]
1444 async fn peek_stale_key_none_stops_with_debug_log() {
1445 let repo = Arc::new(MockCacheRepository::new("mock"));
1446 let mut svc = CachePeekStaleService::new(repo, none_key(), PeekStaleMissPolicy::Stop);
1447
1448 let (sink, subscriber) = debug_sink();
1449 let _guard = tracing::subscriber::set_default(subscriber);
1450 tracing::callsite::rebuild_interest_cache();
1454 let outcome = svc.run(exchange()).await;
1455 drop(_guard);
1456
1457 assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
1458
1459 let captured = String::from_utf8(sink.lock().unwrap().clone()).unwrap(); let none_records: Vec<&str> = captured
1461 .lines()
1462 .filter(|l| l.contains("resolved to None"))
1463 .collect();
1464 assert_eq!(
1465 none_records.len(),
1466 1,
1467 "expected exactly one DEBUG record containing \"resolved to None\"; got: {captured}"
1468 );
1469 assert!(
1470 none_records[0].contains("DEBUG"),
1471 "expected DEBUG level record; got: {captured}"
1472 );
1473 assert!(
1474 none_records[0].contains("repository=mock"),
1475 "expected repository field in record; got: {captured}"
1476 );
1477 assert!(
1478 none_records[0].contains("step=\"cache_peek_stale\""),
1479 "expected step field in record; got: {captured}"
1480 );
1481 }
1482
1483 #[tokio::test]
1486 async fn cache_invalidate_calls_repository_invalidate() {
1487 let repo = Arc::new(MockCacheRepository::new("mock"));
1488 repo.seed(
1489 "cache-key",
1490 CacheEntry {
1491 bytes: b"to-go".to_vec(),
1492 content_type: ContentType::Bytes,
1493 expires_at: None,
1494 },
1495 )
1496 .await;
1497 let mut svc = CacheInvalidateService::new(repo.clone(), fixed_key());
1498
1499 let outcome = svc.run(exchange()).await;
1500
1501 let _ex = match outcome {
1502 PipelineOutcome::Completed(ex) => ex,
1503 other => panic!("expected Completed, got {other:?}"),
1504 };
1505 assert_eq!(
1506 repo.invalidate_call_count(),
1507 1,
1508 "invalidate must be called once"
1509 );
1510 assert_eq!(
1511 repo.last_invalidate_key().await,
1512 Some("cache-key".to_string()),
1513 "invalidate must be called with the correct key"
1514 );
1515 assert!(
1516 repo.stored_entry("cache-key").await.is_none(),
1517 "entry must be removed after invalidation"
1518 );
1519 }
1520
1521 #[tokio::test]
1522 async fn cache_invalidate_none_key_completes() {
1523 let repo = Arc::new(MockCacheRepository::new("mock"));
1524 let mut svc = CacheInvalidateService::new(repo.clone(), none_key());
1525
1526 let outcome = svc.run(exchange()).await;
1527
1528 let _ex = match outcome {
1529 PipelineOutcome::Completed(ex) => ex,
1530 other => panic!("expected Completed, got {other:?}"),
1531 };
1532 assert_eq!(
1533 repo.invalidate_call_count(),
1534 0,
1535 "invalidate must NOT be called when key_expr returns None"
1536 );
1537 }
1538
1539 type CounterRecording = Vec<(String, f64, Vec<(String, String)>)>;
1543
1544 #[derive(Clone)]
1545 struct RecordingMetricsCollector {
1546 counters: Arc<Mutex<CounterRecording>>,
1547 }
1548
1549 impl RecordingMetricsCollector {
1550 fn new() -> Self {
1551 Self {
1552 counters: Arc::new(Mutex::new(Vec::new())),
1553 }
1554 }
1555 }
1556
1557 impl camel_api::metrics::MetricsCollector for RecordingMetricsCollector {
1558 fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
1559 fn increment_errors(&self, _route_id: &str, _error_type: &str) {}
1560 fn increment_exchanges(&self, _route_id: &str) {}
1561 fn set_queue_depth(&self, _route_id: &str, _depth: usize) {}
1562 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
1563 fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
1564 self.counters.lock().unwrap().push((
1565 name.to_string(),
1566 value,
1567 labels
1568 .iter()
1569 .map(|(k, v)| (k.to_string(), v.to_string()))
1570 .collect(),
1571 ));
1572 }
1573 }
1574
1575 #[derive(Clone)]
1576 struct TestOtelmRt {
1577 collector: Arc<RecordingMetricsCollector>,
1578 }
1579
1580 impl camel_component_api::health_registry::HealthCheckRegistry for TestOtelmRt {
1581 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
1582 }
1583
1584 impl RuntimeObservability for TestOtelmRt {
1585 fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
1586 self.collector.clone()
1587 }
1588 fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
1589 Arc::new(NoOpHealthCheckRegistry)
1590 }
1591 }
1592
1593 #[tokio::test]
1594 async fn cache_step_hit_increments_otel_counter() {
1595 let repo = Arc::new(MockCacheRepository::new("mock"));
1596 repo.seed(
1597 "cache-key",
1598 CacheEntry {
1599 bytes: b"cached".to_vec(),
1600 content_type: ContentType::Bytes,
1601 expires_at: None,
1602 },
1603 )
1604 .await;
1605 let collector = RecordingMetricsCollector::new();
1606 let counters = collector.counters.clone();
1607 let rt = Arc::new(TestOtelmRt {
1608 collector: Arc::new(collector),
1609 });
1610 let (mut svc, _invoked) = build_service(
1611 repo,
1612 fixed_key(),
1613 1024,
1614 None,
1615 ScriptedOutcome::Complete,
1616 None,
1617 rt,
1618 );
1619
1620 let outcome = svc.run(exchange()).await;
1621 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
1622
1623 let recorded = counters.lock().unwrap().clone();
1624 assert!(
1625 recorded.contains(&(
1626 "camel.cache.hits".to_string(),
1627 1.0,
1628 vec![("repository".to_string(), "mock".to_string())]
1629 )),
1630 "expected camel.cache.hits counter, got: {recorded:?}"
1631 );
1632 }
1633
1634 #[tokio::test]
1635 async fn cache_step_miss_increments_otel_counter() {
1636 let repo = Arc::new(MockCacheRepository::new("mock"));
1637 let collector = RecordingMetricsCollector::new();
1638 let counters = collector.counters.clone();
1639 let rt = Arc::new(TestOtelmRt {
1640 collector: Arc::new(collector),
1641 });
1642 let (mut svc, _invoked) = build_service(
1643 repo.clone(),
1644 fixed_key(),
1645 1024,
1646 Some(Body::Bytes(Bytes::from_static(b"x"))),
1647 ScriptedOutcome::Complete,
1648 None,
1649 rt,
1650 );
1651
1652 let outcome = svc.run(exchange()).await;
1653 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
1654
1655 let recorded = counters.lock().unwrap().clone();
1656 assert!(
1657 recorded.contains(&(
1658 "camel.cache.misses".to_string(),
1659 1.0,
1660 vec![("repository".to_string(), "mock".to_string())]
1661 )),
1662 "expected camel.cache.misses counter, got: {recorded:?}"
1663 );
1664 }
1665}