1use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26use std::time::Duration;
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 struct CachePeekStaleService {
428 repository: Arc<dyn CacheRepository>,
429 key_expr: MessageIdExpression,
430}
431
432impl CachePeekStaleService {
433 pub fn new(repository: Arc<dyn CacheRepository>, key_expr: MessageIdExpression) -> Self {
434 Self {
435 repository,
436 key_expr,
437 }
438 }
439}
440
441impl Clone for CachePeekStaleService {
442 fn clone(&self) -> Self {
443 Self {
444 repository: Arc::clone(&self.repository),
445 key_expr: Arc::clone(&self.key_expr),
446 }
447 }
448}
449
450impl OutcomePipeline for CachePeekStaleService {
451 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
452 Box::new(self.clone())
453 }
454
455 fn run<'a>(
456 &'a mut self,
457 exchange: Exchange,
458 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
459 Box::pin(async move {
460 let key = match (self.key_expr)(&exchange) {
461 Some(k) => k,
462 None => return PipelineOutcome::Stopped(exchange),
463 };
464 match self.repository.peek_stale(&key).await {
465 Err(e) => PipelineOutcome::Failed(e),
466 Ok(Some(entry)) => match reconstruct_body(&entry) {
467 Ok(body) => {
468 let mut exchange = exchange;
469 exchange.input.body = body;
470 PipelineOutcome::Completed(exchange)
471 }
472 Err(e) => PipelineOutcome::Failed(e),
473 },
474 Ok(None) => PipelineOutcome::Stopped(exchange),
475 }
476 })
477 }
478}
479
480#[cfg(test)]
485mod test_utils {
486 use super::*;
487 use async_trait::async_trait;
488 use std::collections::HashMap;
489 use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
490 use tokio::sync::Mutex;
491
492 #[derive(Debug, Default)]
496 pub struct MockCacheRepository {
497 name: String,
498 entries: Arc<Mutex<HashMap<String, CacheEntry>>>,
499 get_should_fail: Arc<AtomicBool>,
500 set_should_fail: Arc<AtomicBool>,
501 set_call_count: Arc<AtomicU32>,
502 last_set_ttl: Arc<Mutex<Option<Duration>>>,
503 invalidate_call_count: Arc<AtomicU32>,
504 last_invalidate_key: Arc<Mutex<Option<String>>>,
505 }
506
507 impl MockCacheRepository {
508 pub fn new(name: &str) -> Self {
509 Self {
510 name: name.to_string(),
511 ..Default::default()
512 }
513 }
514
515 pub fn invalidate_call_count(&self) -> u32 {
516 self.invalidate_call_count.load(Ordering::SeqCst)
517 }
518
519 pub async fn last_invalidate_key(&self) -> Option<String> {
520 self.last_invalidate_key.lock().await.clone()
521 }
522
523 pub async fn seed(&self, key: &str, entry: CacheEntry) {
525 self.entries.lock().await.insert(key.to_string(), entry);
526 }
527
528 pub fn set_get_should_fail(&self, v: bool) {
529 self.get_should_fail.store(v, Ordering::SeqCst);
530 }
531
532 pub fn set_set_should_fail(&self, v: bool) {
533 self.set_should_fail.store(v, Ordering::SeqCst);
534 }
535
536 pub fn set_call_count(&self) -> u32 {
537 self.set_call_count.load(Ordering::SeqCst)
538 }
539
540 pub async fn last_set_ttl(&self) -> Option<Duration> {
542 *self.last_set_ttl.lock().await
543 }
544
545 pub async fn stored_entry(&self, key: &str) -> Option<CacheEntry> {
547 self.entries.lock().await.get(key).cloned()
548 }
549 }
550
551 #[async_trait]
552 impl CacheRepository for MockCacheRepository {
553 fn name(&self) -> &str {
554 &self.name
555 }
556
557 async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
558 if self.get_should_fail.load(Ordering::SeqCst) {
559 return Err(CamelError::ProcessorError("synthetic get failure".into()));
560 }
561 Ok(self.entries.lock().await.get(key).cloned())
562 }
563
564 async fn set(
565 &self,
566 key: &str,
567 value: CacheEntry,
568 ttl: Option<Duration>,
569 ) -> Result<(), CamelError> {
570 self.set_call_count.fetch_add(1, Ordering::SeqCst);
571 *self.last_set_ttl.lock().await = ttl;
572 if self.set_should_fail.load(Ordering::SeqCst) {
573 return Err(CamelError::ProcessorError("synthetic set failure".into()));
574 }
575 self.entries.lock().await.insert(key.to_string(), value);
576 Ok(())
577 }
578
579 async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
580 self.get(key).await
581 }
582
583 async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
584 self.invalidate_call_count.fetch_add(1, Ordering::SeqCst);
585 *self.last_invalidate_key.lock().await = Some(key.to_string());
586 self.entries.lock().await.remove(key);
587 Ok(())
588 }
589
590 async fn clear(&self) -> Result<(), CamelError> {
591 self.entries.lock().await.clear();
592 Ok(())
593 }
594 }
595}
596
597#[cfg(test)]
602mod tests {
603 use super::test_utils::MockCacheRepository;
604 use super::*;
605 use camel_api::body::{StreamBody, StreamMetadata};
606 use camel_api::metrics::NoOpMetrics;
607 use camel_api::{Message, Value};
608 use camel_component_api::health_registry::NoOpHealthCheckRegistry;
609 use futures::stream;
610 use std::sync::Mutex;
611 use std::sync::atomic::{AtomicBool, Ordering};
612
613 #[derive(Clone)]
615 struct NoopRt;
616
617 impl camel_component_api::HealthCheckRegistry for NoopRt {
618 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
619 }
620
621 impl RuntimeObservability for NoopRt {
622 fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
623 Arc::new(NoOpMetrics)
624 }
625 fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
626 Arc::new(NoOpHealthCheckRegistry)
627 }
628 }
629
630 fn noop_rt() -> Arc<dyn RuntimeObservability> {
631 Arc::new(NoopRt)
632 }
633
634 #[derive(Clone)]
637 enum ScriptedOutcome {
638 Complete,
639 Stop,
640 Fail(CamelError),
641 }
642
643 struct ScriptedOnMiss {
646 body: Option<Body>,
647 outcome: ScriptedOutcome,
648 invoked: Arc<AtomicBool>,
649 }
650
651 impl OutcomePipeline for ScriptedOnMiss {
652 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
653 unreachable!("clone_box not used in cache_eip tests")
655 }
656
657 fn run<'a>(
658 &'a mut self,
659 mut exchange: Exchange,
660 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
661 self.invoked.store(true, Ordering::SeqCst);
662 let body = self.body.take();
663 let outcome = self.outcome.clone();
664 Box::pin(async move {
665 if let Some(b) = body {
666 exchange.input.body = b;
667 }
668 match outcome {
669 ScriptedOutcome::Complete => PipelineOutcome::Completed(exchange),
670 ScriptedOutcome::Stop => PipelineOutcome::Stopped(exchange),
671 ScriptedOutcome::Fail(e) => PipelineOutcome::Failed(e),
672 }
673 })
674 }
675 }
676
677 fn fixed_key() -> MessageIdExpression {
680 Arc::new(|_| Some("cache-key".to_string()))
681 }
682
683 fn none_key() -> MessageIdExpression {
684 Arc::new(|_| None)
685 }
686
687 fn build_service(
689 repo: Arc<MockCacheRepository>,
690 key_expr: MessageIdExpression,
691 max_entry_bytes: usize,
692 body: Option<Body>,
693 outcome: ScriptedOutcome,
694 ttl: Option<Duration>,
695 rt: Arc<dyn RuntimeObservability>,
696 ) -> (CacheService, Arc<AtomicBool>) {
697 let invoked = Arc::new(AtomicBool::new(false));
698 let on_miss = OutcomeSegment::new(Box::new(ScriptedOnMiss {
699 body,
700 outcome,
701 invoked: invoked.clone(),
702 }));
703 let svc = CacheService::new(repo, key_expr, ttl, max_entry_bytes, on_miss, rt);
704 (svc, invoked)
705 }
706
707 fn exchange() -> Exchange {
708 let mut ex = Exchange::new(Message::new(""));
709 ex.input.set_header("ignored", Value::String("v".into()));
710 ex
711 }
712
713 fn stream_body(data: &'static [u8]) -> Body {
714 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from_static(data))];
715 let s = stream::iter(chunks);
716 Body::Stream(StreamBody {
717 stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(s)))),
718 metadata: StreamMetadata::default(),
719 })
720 }
721
722 fn stub_error(msg: &str) -> CamelError {
723 CamelError::ProcessorError(msg.into())
724 }
725
726 #[tokio::test]
729 async fn cache_hit_short_circuits_on_miss() {
730 let repo = Arc::new(MockCacheRepository::new("mock"));
731 repo.seed(
732 "cache-key",
733 CacheEntry {
734 bytes: b"cached-payload".to_vec(),
735 content_type: ContentType::Bytes,
736 expires_at: None,
737 },
738 )
739 .await;
740 let (mut svc, on_miss_invoked) = build_service(
741 repo,
742 fixed_key(),
743 1024,
744 Some(Body::Bytes(Bytes::from_static(b"unreached"))),
745 ScriptedOutcome::Complete,
746 None,
747 noop_rt(),
748 );
749
750 let outcome = svc.run(exchange()).await;
751
752 let ex = match outcome {
753 PipelineOutcome::Completed(ex) => ex,
754 other => panic!("expected Completed, got {other:?}"),
755 };
756 assert_eq!(
757 ex.input.body,
758 Body::Bytes(Bytes::from_static(b"cached-payload"))
759 );
760 assert!(
761 !on_miss_invoked.load(Ordering::SeqCst),
762 "on_miss must NOT run on a cache HIT"
763 );
764 }
765
766 #[tokio::test]
769 async fn cache_miss_runs_on_miss_sets_continues() {
770 let ttl = Duration::from_secs(30);
771 let repo = Arc::new(MockCacheRepository::new("mock"));
772 let (mut svc, on_miss_invoked) = build_service(
773 repo.clone(),
774 fixed_key(),
775 1024,
776 Some(Body::Bytes(Bytes::from_static(b"x"))),
777 ScriptedOutcome::Complete,
778 Some(ttl),
779 noop_rt(),
780 );
781
782 let outcome = svc.run(exchange()).await;
783
784 let ex = match outcome {
785 PipelineOutcome::Completed(ex) => ex,
786 other => panic!("expected Completed, got {other:?}"),
787 };
788 assert!(on_miss_invoked.load(Ordering::SeqCst));
789 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
790 assert_eq!(repo.set_call_count(), 1, "set must be called once on miss");
791 let stored = repo
792 .stored_entry("cache-key")
793 .await
794 .expect("entry must be stored");
795 assert_eq!(stored.bytes, b"x");
796 assert_eq!(stored.content_type, ContentType::Bytes);
797 assert_eq!(repo.last_set_ttl().await, Some(ttl));
798 }
799
800 #[tokio::test]
803 async fn cache_miss_oversized_materialized_body_skips_writeback() {
804 let repo = Arc::new(MockCacheRepository::new("mock"));
805 let (mut svc, _invoked) = build_service(
807 repo.clone(),
808 fixed_key(),
809 4,
810 Some(Body::Bytes(Bytes::from_static(b"oversized"))),
811 ScriptedOutcome::Complete,
812 None,
813 noop_rt(),
814 );
815
816 let outcome = svc.run(exchange()).await;
817
818 let ex = match outcome {
819 PipelineOutcome::Completed(ex) => ex,
820 other => panic!("expected Completed, got {other:?}"),
821 };
822 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"oversized")));
824 assert_eq!(
825 repo.set_call_count(),
826 0,
827 "set must NOT be called for oversized body"
828 );
829 assert!(repo.stored_entry("cache-key").await.is_none());
830 }
831
832 #[tokio::test]
835 async fn cache_miss_oversized_stream_propagates_err() {
836 let repo = Arc::new(MockCacheRepository::new("mock"));
837 let (mut svc, _invoked) = build_service(
838 repo.clone(),
839 fixed_key(),
840 4,
841 Some(stream_body(b"way-too-big-stream")),
842 ScriptedOutcome::Complete,
843 None,
844 noop_rt(),
845 );
846
847 let outcome = svc.run(exchange()).await;
848
849 match outcome {
850 PipelineOutcome::Failed(CamelError::StreamLimitExceeded(n)) => {
851 assert_eq!(n, 4);
852 }
853 other => panic!("expected Failed(StreamLimitExceeded(4)), got {other:?}"),
854 }
855 assert_eq!(
856 repo.set_call_count(),
857 0,
858 "set must NOT be called when stream exceeds limit"
859 );
860 }
861
862 #[tokio::test]
865 async fn cache_on_miss_stopped_propagates_without_writeback() {
866 let repo = Arc::new(MockCacheRepository::new("mock"));
867 let (mut svc, _invoked) = build_service(
868 repo.clone(),
869 fixed_key(),
870 1024,
871 None,
872 ScriptedOutcome::Stop,
873 None,
874 noop_rt(),
875 );
876
877 let outcome = svc.run(exchange()).await;
878
879 assert!(
880 matches!(outcome, PipelineOutcome::Stopped(_)),
881 "Stopped from on_miss MUST propagate as Stopped"
882 );
883 assert_eq!(
884 repo.set_call_count(),
885 0,
886 "set must NOT be called when on_miss Stops"
887 );
888 }
889
890 #[tokio::test]
893 async fn cache_on_miss_err_propagates_without_writeback() {
894 let repo = Arc::new(MockCacheRepository::new("mock"));
895 let (mut svc, _invoked) = build_service(
896 repo.clone(),
897 fixed_key(),
898 1024,
899 None,
900 ScriptedOutcome::Fail(stub_error("on-miss blew up")),
901 None,
902 noop_rt(),
903 );
904
905 let outcome = svc.run(exchange()).await;
906
907 match outcome {
908 PipelineOutcome::Failed(e) => {
909 assert!(e.to_string().contains("on-miss blew up"), "got: {e}");
910 }
911 other => panic!("expected Failed, got {other:?}"),
912 }
913 assert_eq!(
914 repo.set_call_count(),
915 0,
916 "set must NOT be called when on_miss fails"
917 );
918 }
919
920 #[tokio::test]
923 async fn cache_repository_get_err_propagates() {
924 let repo = Arc::new(MockCacheRepository::new("mock"));
925 repo.set_get_should_fail(true);
926 let (mut svc, on_miss_invoked) = build_service(
927 repo,
928 fixed_key(),
929 1024,
930 Some(Body::Bytes(Bytes::from_static(b"x"))),
931 ScriptedOutcome::Complete,
932 None,
933 noop_rt(),
934 );
935
936 let outcome = svc.run(exchange()).await;
937
938 match outcome {
939 PipelineOutcome::Failed(e) => {
940 assert!(e.to_string().contains("synthetic get failure"), "got: {e}");
941 }
942 other => panic!("expected Failed, got {other:?}"),
943 }
944 assert!(
945 !on_miss_invoked.load(Ordering::SeqCst),
946 "on_miss must NOT run when get fails"
947 );
948 }
949
950 #[tokio::test]
953 async fn cache_repository_set_err_propagates() {
954 let repo = Arc::new(MockCacheRepository::new("mock"));
955 repo.set_set_should_fail(true);
956 let (mut svc, _invoked) = build_service(
957 repo.clone(),
958 fixed_key(),
959 1024,
960 Some(Body::Bytes(Bytes::from_static(b"x"))),
961 ScriptedOutcome::Complete,
962 None,
963 noop_rt(),
964 );
965
966 let outcome = svc.run(exchange()).await;
967
968 match outcome {
969 PipelineOutcome::Failed(e) => {
970 assert!(e.to_string().contains("synthetic set failure"), "got: {e}");
971 }
972 other => panic!("expected Failed, got {other:?}"),
973 }
974 assert_eq!(repo.set_call_count(), 1, "set was attempted (and failed)");
975 }
976
977 #[tokio::test]
980 async fn cache_none_key_bypasses_to_on_miss() {
981 let repo = Arc::new(MockCacheRepository::new("mock"));
982 let (mut svc, on_miss_invoked) = build_service(
983 repo.clone(),
984 none_key(),
985 1024,
986 Some(Body::Bytes(Bytes::from_static(b"x"))),
987 ScriptedOutcome::Complete,
988 None,
989 noop_rt(),
990 );
991
992 let outcome = svc.run(exchange()).await;
993
994 let ex = match outcome {
995 PipelineOutcome::Completed(ex) => ex,
996 other => panic!("expected Completed, got {other:?}"),
997 };
998 assert!(
999 on_miss_invoked.load(Ordering::SeqCst),
1000 "on_miss MUST run when key is None"
1001 );
1002 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
1003 assert_eq!(
1004 repo.set_call_count(),
1005 0,
1006 "set must NOT be called when key_expr returns None"
1007 );
1008 }
1009
1010 #[tokio::test]
1013 async fn cache_content_type_reconstruction() {
1014 async fn run_case(entry: CacheEntry, expected: Body) {
1015 let repo = Arc::new(MockCacheRepository::new("mock"));
1016 repo.seed("cache-key", entry).await;
1017 let (mut svc, on_miss_invoked) = build_service(
1018 repo,
1019 fixed_key(),
1020 1024,
1021 Some(Body::Bytes(Bytes::from_static(b"unreached"))),
1022 ScriptedOutcome::Complete,
1023 None,
1024 noop_rt(),
1025 );
1026 let outcome = svc.run(exchange()).await;
1027 let ex = match outcome {
1028 PipelineOutcome::Completed(ex) => ex,
1029 other => panic!("expected Completed, got {other:?}"),
1030 };
1031 assert_eq!(ex.input.body, expected);
1032 assert!(!on_miss_invoked.load(Ordering::SeqCst));
1033 }
1034
1035 run_case(
1036 CacheEntry {
1037 bytes: b"raw".to_vec(),
1038 content_type: ContentType::Bytes,
1039 expires_at: None,
1040 },
1041 Body::Bytes(Bytes::from_static(b"raw")),
1042 )
1043 .await;
1044 run_case(
1045 CacheEntry {
1046 bytes: b"hi".to_vec(),
1047 content_type: ContentType::Text,
1048 expires_at: None,
1049 },
1050 Body::Text("hi".into()),
1051 )
1052 .await;
1053 run_case(
1054 CacheEntry {
1055 bytes: br#"{"k":1}"#.to_vec(),
1056 content_type: ContentType::Json,
1057 expires_at: None,
1058 },
1059 Body::Json(serde_json::json!({"k": 1})),
1060 )
1061 .await;
1062 run_case(
1063 CacheEntry {
1064 bytes: b"<a/>".to_vec(),
1065 content_type: ContentType::Xml,
1066 expires_at: None,
1067 },
1068 Body::Xml("<a/>".into()),
1069 )
1070 .await;
1071 }
1072
1073 #[tokio::test]
1076 async fn cache_miss_stream_body_is_materialized_and_cached() {
1077 let repo = Arc::new(MockCacheRepository::new("mock"));
1078 let (mut svc, _invoked) = build_service(
1079 repo.clone(),
1080 fixed_key(),
1081 1024,
1082 Some(stream_body(b"chunky")),
1083 ScriptedOutcome::Complete,
1084 None,
1085 noop_rt(),
1086 );
1087
1088 let outcome = svc.run(exchange()).await;
1089
1090 let ex = match outcome {
1091 PipelineOutcome::Completed(ex) => ex,
1092 other => panic!("expected Completed, got {other:?}"),
1093 };
1094 assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"chunky")));
1096 assert_eq!(repo.set_call_count(), 1);
1097 let stored = repo.stored_entry("cache-key").await.expect("stored");
1098 assert_eq!(stored.bytes, b"chunky");
1099 assert_eq!(stored.content_type, ContentType::Bytes);
1100 }
1101
1102 #[tokio::test]
1105 async fn cache_peek_stale_serves_post_expiry_entry() {
1106 let repo = Arc::new(MockCacheRepository::new("mock"));
1107 repo.seed(
1108 "cache-key",
1109 CacheEntry {
1110 bytes: b"stale-payload".to_vec(),
1111 content_type: ContentType::Text,
1112 expires_at: None,
1113 },
1114 )
1115 .await;
1116 let mut svc = CachePeekStaleService::new(repo, fixed_key());
1117
1118 let outcome = svc.run(exchange()).await;
1119
1120 let ex = match outcome {
1121 PipelineOutcome::Completed(ex) => ex,
1122 other => panic!("expected Completed, got {other:?}"),
1123 };
1124 assert_eq!(ex.input.body, Body::Text("stale-payload".into()));
1125 }
1126
1127 #[tokio::test]
1128 async fn cache_peek_stale_on_absence_stops_branch() {
1129 let repo = Arc::new(MockCacheRepository::new("mock"));
1130 let mut svc = CachePeekStaleService::new(repo, fixed_key());
1131
1132 let outcome = svc.run(exchange()).await;
1133
1134 assert!(
1135 matches!(outcome, PipelineOutcome::Stopped(_)),
1136 "expected Stopped when no stale entry, got {outcome:?}"
1137 );
1138 }
1139
1140 #[tokio::test]
1141 async fn cache_peek_stale_none_key_stops() {
1142 let repo = Arc::new(MockCacheRepository::new("mock"));
1143 let mut svc = CachePeekStaleService::new(repo, none_key());
1144
1145 let outcome = svc.run(exchange()).await;
1146
1147 assert!(
1148 matches!(outcome, PipelineOutcome::Stopped(_)),
1149 "expected Stopped when key_expr returns None, got {outcome:?}"
1150 );
1151 }
1152
1153 #[tokio::test]
1156 async fn cache_invalidate_calls_repository_invalidate() {
1157 let repo = Arc::new(MockCacheRepository::new("mock"));
1158 repo.seed(
1159 "cache-key",
1160 CacheEntry {
1161 bytes: b"to-go".to_vec(),
1162 content_type: ContentType::Bytes,
1163 expires_at: None,
1164 },
1165 )
1166 .await;
1167 let mut svc = CacheInvalidateService::new(repo.clone(), fixed_key());
1168
1169 let outcome = svc.run(exchange()).await;
1170
1171 let _ex = match outcome {
1172 PipelineOutcome::Completed(ex) => ex,
1173 other => panic!("expected Completed, got {other:?}"),
1174 };
1175 assert_eq!(
1176 repo.invalidate_call_count(),
1177 1,
1178 "invalidate must be called once"
1179 );
1180 assert_eq!(
1181 repo.last_invalidate_key().await,
1182 Some("cache-key".to_string()),
1183 "invalidate must be called with the correct key"
1184 );
1185 assert!(
1186 repo.stored_entry("cache-key").await.is_none(),
1187 "entry must be removed after invalidation"
1188 );
1189 }
1190
1191 #[tokio::test]
1192 async fn cache_invalidate_none_key_completes() {
1193 let repo = Arc::new(MockCacheRepository::new("mock"));
1194 let mut svc = CacheInvalidateService::new(repo.clone(), none_key());
1195
1196 let outcome = svc.run(exchange()).await;
1197
1198 let _ex = match outcome {
1199 PipelineOutcome::Completed(ex) => ex,
1200 other => panic!("expected Completed, got {other:?}"),
1201 };
1202 assert_eq!(
1203 repo.invalidate_call_count(),
1204 0,
1205 "invalidate must NOT be called when key_expr returns None"
1206 );
1207 }
1208
1209 type CounterRecording = Vec<(String, f64, Vec<(String, String)>)>;
1213
1214 #[derive(Clone)]
1215 struct RecordingMetricsCollector {
1216 counters: Arc<Mutex<CounterRecording>>,
1217 }
1218
1219 impl RecordingMetricsCollector {
1220 fn new() -> Self {
1221 Self {
1222 counters: Arc::new(Mutex::new(Vec::new())),
1223 }
1224 }
1225 }
1226
1227 impl camel_api::metrics::MetricsCollector for RecordingMetricsCollector {
1228 fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
1229 fn increment_errors(&self, _route_id: &str, _error_type: &str) {}
1230 fn increment_exchanges(&self, _route_id: &str) {}
1231 fn set_queue_depth(&self, _route_id: &str, _depth: usize) {}
1232 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
1233 fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
1234 self.counters.lock().unwrap().push((
1235 name.to_string(),
1236 value,
1237 labels
1238 .iter()
1239 .map(|(k, v)| (k.to_string(), v.to_string()))
1240 .collect(),
1241 ));
1242 }
1243 }
1244
1245 #[derive(Clone)]
1246 struct TestOtelmRt {
1247 collector: Arc<RecordingMetricsCollector>,
1248 }
1249
1250 impl camel_component_api::health_registry::HealthCheckRegistry for TestOtelmRt {
1251 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
1252 }
1253
1254 impl RuntimeObservability for TestOtelmRt {
1255 fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
1256 self.collector.clone()
1257 }
1258 fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
1259 Arc::new(NoOpHealthCheckRegistry)
1260 }
1261 }
1262
1263 #[tokio::test]
1264 async fn cache_step_hit_increments_otel_counter() {
1265 let repo = Arc::new(MockCacheRepository::new("mock"));
1266 repo.seed(
1267 "cache-key",
1268 CacheEntry {
1269 bytes: b"cached".to_vec(),
1270 content_type: ContentType::Bytes,
1271 expires_at: None,
1272 },
1273 )
1274 .await;
1275 let collector = RecordingMetricsCollector::new();
1276 let counters = collector.counters.clone();
1277 let rt = Arc::new(TestOtelmRt {
1278 collector: Arc::new(collector),
1279 });
1280 let (mut svc, _invoked) = build_service(
1281 repo,
1282 fixed_key(),
1283 1024,
1284 None,
1285 ScriptedOutcome::Complete,
1286 None,
1287 rt,
1288 );
1289
1290 let outcome = svc.run(exchange()).await;
1291 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
1292
1293 let recorded = counters.lock().unwrap().clone();
1294 assert!(
1295 recorded.contains(&(
1296 "camel.cache.hits".to_string(),
1297 1.0,
1298 vec![("repository".to_string(), "mock".to_string())]
1299 )),
1300 "expected camel.cache.hits counter, got: {recorded:?}"
1301 );
1302 }
1303
1304 #[tokio::test]
1305 async fn cache_step_miss_increments_otel_counter() {
1306 let repo = Arc::new(MockCacheRepository::new("mock"));
1307 let collector = RecordingMetricsCollector::new();
1308 let counters = collector.counters.clone();
1309 let rt = Arc::new(TestOtelmRt {
1310 collector: Arc::new(collector),
1311 });
1312 let (mut svc, _invoked) = build_service(
1313 repo.clone(),
1314 fixed_key(),
1315 1024,
1316 Some(Body::Bytes(Bytes::from_static(b"x"))),
1317 ScriptedOutcome::Complete,
1318 None,
1319 rt,
1320 );
1321
1322 let outcome = svc.run(exchange()).await;
1323 assert!(matches!(outcome, PipelineOutcome::Completed(_)));
1324
1325 let recorded = counters.lock().unwrap().clone();
1326 assert!(
1327 recorded.contains(&(
1328 "camel.cache.misses".to_string(),
1329 1.0,
1330 vec![("repository".to_string(), "mock".to_string())]
1331 )),
1332 "expected camel.cache.misses counter, got: {recorded:?}"
1333 );
1334 }
1335}