Skip to main content

camel_processor/
cache_eip.rs

1//! Cache EIP — outcome-aware Segment implementation.
2//!
3//! Implements the Caching pattern (lookup → on-miss sub-pipeline → write-back)
4//! at the `OutcomePipeline` layer (one layer above Tower), mirroring
5//! [`IdempotentConsumerSegment`]. On a cache HIT the body is reconstructed from
6//! the stored [`CacheEntry`] and the on-miss sub-pipeline is skipped entirely;
7//! on a MISS the sub-pipeline runs and its result body is written back into the
8//! repository (subject to `max_entry_bytes`).
9//!
10//! # Why Segment-mode (NOT Process-mode)
11//!
12//! Same rationale as the idempotent consumer: a Tower `Service<Exchange>` cannot
13//! propagate `PipelineOutcome::Stopped` distinctly from `Ok(ex)`. By implementing
14//! [`OutcomePipeline`] directly, a `Stopped` from the on-miss sub-pipeline flows
15//! out with the Exchange intact and NO write-back occurs (ADR-0024, ADR-0025).
16//!
17//! # Contract C1 (ADR-0023)
18//!
19//! [`CacheRepository::get`] / [`CacheRepository::set`] surface backend failures
20//! as `Err(CamelError)`. The segment propagates those as `PipelineOutcome::Failed`
21//! — it NEVER treats a failed read as a miss.
22
23use 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
37/// Outcome-aware Cache segment (Caching EIP).
38///
39/// Wraps a named [`CacheRepository`] and an on-miss sub-pipeline
40/// ([`OutcomeSegment`]). On each exchange:
41///
42/// 1. Evaluate `key_expr`. `None` → not cacheable; forward directly to the
43///    on-miss sub-pipeline (no lookup, no write-back).
44/// 2. `repository.get(&key)`:
45///    - `Err(e)` → `Failed(e)` (contract C1).
46///    - `Ok(Some(entry))` → HIT: reconstruct `Body` from the entry, set it on
47///      the exchange, return `Completed` (skip on-miss).
48///    - `Ok(None)` → MISS: proceed to step 3.
49/// 3. Run the on-miss sub-pipeline.
50///    - `Stopped(ex)` / `Failed(e)` → propagate as-is (NO write-back).
51///    - `Completed(ex)` → proceed to write-back.
52/// 4. Write-back the resulting body (when it fits `max_entry_bytes`):
53///    - materialized variants (`Bytes`/`Text`/`Json`/`Xml`) → serialize, store.
54///    - `Stream` → materialize via [`Body::into_bytes`] (consumes the body,
55///      replaces it with `Body::Bytes`); `StreamLimitExceeded` propagates.
56///    - `Empty` / oversized body → pass through uncached, return `Completed`.
57pub struct CacheService {
58    repository: Arc<dyn CacheRepository>,
59    /// Cached `repository.name()` for OTel span tagging (Task 3.3).
60    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    /// Build a new cache segment.
70    ///
71    /// `repository_name` is derived from `repository.name()` so OTel tags stay
72    /// in sync with the resolved backend.
73    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    /// The configured repository name (for OTel tagging).
94    pub fn repository_name(&self) -> &str {
95        &self.repository_name
96    }
97}
98
99/// Shared write-back tail for materialized bodies.
100///
101/// Checks `max_entry_bytes`, builds a [`CacheEntry`], stores via
102/// the repository, and returns `Completed(exchange)`. The exchange body
103/// is not modified — it passes through as-is. On oversized body, logs a
104/// debug! skip message and returns `Completed(exchange)` without storing.
105/// On repository error, returns `Failed(e)`.
106#[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                    ); // log-policy: g:cache:capacity-full-skip
132                } else {
133                    return PipelineOutcome::Failed(e);
134                }
135            }
136        }
137    } else {
138        // log-policy: g:cache:oversized-skip
139        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            // 1. Evaluate key. None → not cacheable, bypass straight to on_miss.
175            let key = match (self.key_expr)(&exchange) {
176                Some(k) => k,
177                None => return self.on_miss.run(exchange).await,
178            };
179
180            // 2. Lookup (contract C1: propagate Err, never treat as miss).
181            match self.repository.get(&key).await {
182                Err(e) => return PipelineOutcome::Failed(e),
183                Ok(Some(entry)) => {
184                    // HIT: record metric, reconstruct body, skip on-miss sub-pipeline.
185                    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                    // MISS: record metric, fall through to on-miss sub-pipeline.
201                    self.rt.metrics().record_counter(
202                        "camel.cache.misses",
203                        1.0_f64,
204                        &[("repository", &self.repository_name)],
205                    );
206                }
207            }
208
209            // 3. Run the on-miss sub-pipeline.
210            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            // 4. Write-back. Take the body out so the Stream arm can consume it.
217            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                    // Materialize (consumes the stream). StreamLimitExceeded propagates.
289                    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                    // into_bytes already enforced max_entry_bytes, so it fits by construction.
297                    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                        // Degrade capacity-exceeded to uncached — same policy as write_back.
304                        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                            ); // log-policy: g:cache:capacity-full-skip
311                            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                    // Empty (or any future variant): pass through uncached.
322                    exchange.input.body = body;
323                    PipelineOutcome::Completed(exchange)
324                }
325            }
326        })
327    }
328}
329
330/// Reconstruct a [`Body`] from a stored [`CacheEntry`].
331///
332/// Maps each [`ContentType`] back to the matching `Body` variant, decoding
333/// UTF-8 / JSON failures into `CamelError::TypeConversionFailed`.
334fn 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
358// ===========================================================================
359// CacheInvalidateService — invalidate a single cache entry
360// ===========================================================================
361
362/// Outcome-aware segment that invalidates a single cache entry.
363///
364/// Evaluates `key_expr`:
365/// - `None` → `Completed(exchange)` (nothing to invalidate).
366/// - `Some(key)` → `repository.invalidate(&key).await`.
367///   - `Err(e)` → `Failed(e)`.
368///   - `Ok(())` → `Completed(exchange)`.
369pub 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
414// ===========================================================================
415// CachePeekStaleService — serve a stale entry after expiry
416// ===========================================================================
417
418/// Outcome-aware segment that serves a stale (post-expiry) cache entry.
419///
420/// Evaluates `key_expr`:
421/// - `None` → `Stopped(exchange)` (no key = no stale available).
422/// - `Some(key)` → `repository.peek_stale(&key).await`.
423///   - `Err(e)` → `Failed(e)`.
424///   - `Ok(Some(entry))` → reconstruct body from entry, set on exchange,
425///     return `Completed(exchange)`.
426///   - `Ok(None)` → `Stopped(exchange)` (absence = no stale available — spec R6).
427pub 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// ===========================================================================
481// Test utilities
482// ===========================================================================
483
484#[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    /// In-memory mock [`CacheRepository`] for cache segment tests. Allows tests
493    /// to pre-seed entries, force `get`/`set` failures, and inspect the last
494    /// `set` call (entry + TTL).
495    #[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        /// Pre-seed a key so `get` returns a HIT.
524        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        /// The TTL passed to the most recent `set` call.
541        pub async fn last_set_ttl(&self) -> Option<Duration> {
542            *self.last_set_ttl.lock().await
543        }
544
545        /// Inspect the entry currently stored for `key` (if any).
546        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// ===========================================================================
598// Tests
599// ===========================================================================
600
601#[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    /// Minimal no-op RuntimeObservability for tests that don't need OTel.
614    #[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    // ── Scripted on-miss sub-pipeline ──
635
636    #[derive(Clone)]
637    enum ScriptedOutcome {
638        Complete,
639        Stop,
640        Fail(CamelError),
641    }
642
643    /// Test sub-pipeline: optionally replaces the body, then returns a
644    /// scripted outcome. Records whether it was invoked.
645    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            // clone_box is required by the trait but unused by these tests.
654            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    // ── Builders ──
678
679    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    /// Build a CacheService whose on-miss sets `body` and returns `outcome`.
688    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    // ── Test 1: cache HIT short-circuits, on_miss NOT executed ──
727
728    #[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    // ── Test 2: cache MISS runs on_miss, writes back, continues ──
767
768    #[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    // ── Test 3: oversized materialized body skips write-back ──
801
802    #[tokio::test]
803    async fn cache_miss_oversized_materialized_body_skips_writeback() {
804        let repo = Arc::new(MockCacheRepository::new("mock"));
805        // max_entry_bytes = 4; on_miss produces 9 bytes.
806        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        // Body passes through unchanged.
823        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    // ── Test 4: oversized Stream propagates StreamLimitExceeded ──
833
834    #[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    // ── Test 5: on_miss Stopped propagates without write-back ──
863
864    #[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    // ── Test 6: on_miss Err propagates without write-back ──
891
892    #[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    // ── Test 7: repository get Err propagates ──
921
922    #[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    // ── Test 8: repository set Err propagates ──
951
952    #[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    // ── Test 9: None key bypasses to on_miss, no set ──
978
979    #[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    // ── Extra: HIT reconstruction for each ContentType ──
1011
1012    #[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    // ── Extra: Stream body write-back materializes into Body::Bytes ──
1074
1075    #[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        // Stream is replaced by materialized Bytes.
1095        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    // ── CachePeekStaleService tests ──
1103
1104    #[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    // ── CacheInvalidateService tests ──
1154
1155    #[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    // ── OTel metrics tests ──
1210
1211    /// Records every `record_counter` call for test assertions.
1212    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}