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, SystemTime};
27
28use bytes::Bytes;
29
30use camel_api::body::Body;
31use camel_api::cache::{CacheEntry, CacheRepository, ContentType};
32use camel_api::{CamelError, Exchange, OutcomePipeline, OutcomeSegment, PipelineOutcome};
33use camel_component_api::RuntimeObservability;
34
35use crate::MessageIdExpression;
36
37// ── Singleflight miss coalescing (cache-admin task 2.4) ──
38
39/// Terminal state a coalescing leader publishes for its waiters.
40///
41/// Mirrors the leader's own `PipelineOutcome` in a clonable shape so
42/// every waiter of the wave receives it (waiters clone-read; nobody
43/// consumes the slot).
44#[derive(Clone)]
45pub(crate) enum CoalesceTerminal {
46    /// Leader completed: waiters adopt the leader's resulting body.
47    Completed(Body),
48    /// Leader failed: waiters fail with the same error (anti-burst).
49    Failed(CamelError),
50    /// Leader stopped: waiters stop their own exchanges (branch-filter).
51    Stopped,
52}
53
54/// One in-flight coalescing wave for a resolved cache key.
55///
56/// `terminal` is a write-once slot filled BEFORE `notify_waiters()` is
57/// called, so a woken waiter always re-reads a filled slot (no lost
58/// wakeup: `notify_waiters` alone wakes only currently-registered
59/// waiters).
60struct InFlight {
61    terminal: std::sync::Mutex<Option<CoalesceTerminal>>,
62    notify: tokio::sync::Notify,
63}
64
65impl Default for InFlight {
66    fn default() -> Self {
67        Self {
68            terminal: std::sync::Mutex::new(None),
69            notify: tokio::sync::Notify::new(),
70        }
71    }
72}
73
74impl InFlight {
75    /// Publish the terminal state. Write-once: an already-filled slot is
76    /// never cleared or overwritten (a late `LeaderGuard::drop` after a
77    /// normal completion is a no-op here).
78    fn publish(&self, terminal: CoalesceTerminal) {
79        if let Ok(mut slot) = self.terminal.lock()
80            && slot.is_none()
81        {
82            *slot = Some(terminal);
83        }
84    }
85
86    /// Clone-read the terminal state, if published.
87    fn terminal_snapshot(&self) -> Option<CoalesceTerminal> {
88        match self.terminal.lock() {
89            Ok(slot) => (*slot).clone(),
90            Err(_) => None,
91        }
92    }
93}
94
95/// In-flight coalescing waves, keyed by resolved cache key, scoped per
96/// compiled route-step instance (shared across `CacheService` clones).
97type InFlightMap = std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<InFlight>>>;
98
99/// Cancellation guard for the coalescing leader.
100///
101/// If the leader future is dropped before it publishes its terminal
102/// state (route shutdown, task abort), `Drop` publishes a cancellation
103/// terminal (`Failed`) into the write-once slot, wakes waiters, and
104/// removes the map entry — so waiters are never stranded. On normal
105/// completion the slot is already filled (publish is skipped) and the
106/// entry is already retired; both `Drop` actions become no-ops.
107struct LeaderGuard {
108    key: String,
109    map: Arc<InFlightMap>,
110    cell: Arc<InFlight>,
111}
112
113impl LeaderGuard {
114    /// Remove the map entry iff it still identifies `cell`.
115    ///
116    /// The `Arc::ptr_eq` identity check keeps a late guard (or a
117    /// completed leader) from evicting a NEWER wave's entry for the
118    /// same key.
119    fn retire(map: &Arc<InFlightMap>, key: &str, cell: &Arc<InFlight>) {
120        if let Ok(mut map) = map.lock()
121            && map
122                .get(key)
123                .is_some_and(|current| Arc::ptr_eq(current, cell))
124        {
125            map.remove(key);
126        }
127    }
128}
129
130impl Drop for LeaderGuard {
131    fn drop(&mut self) {
132        // Write-once: no-op when the leader already published a terminal.
133        self.cell
134            .publish(CoalesceTerminal::Failed(CamelError::Config(
135                "cache coalesce leader cancelled".into(),
136            )));
137        self.cell.notify.notify_waiters();
138        Self::retire(&self.map, &self.key, &self.cell);
139    }
140}
141
142/// Outcome-aware Cache segment (Caching EIP).
143///
144/// Wraps a named [`CacheRepository`] and an on-miss sub-pipeline
145/// ([`OutcomeSegment`]). On each exchange:
146///
147/// 1. Evaluate `key_expr`. `None` → not cacheable; forward directly to the
148///    on-miss sub-pipeline (no lookup, no write-back).
149/// 2. `repository.get(&key)`:
150///    - `Err(e)` → `Failed(e)` (contract C1).
151///    - `Ok(Some(entry))` → HIT: reconstruct `Body` from the entry, set it on
152///      the exchange, return `Completed` (skip on-miss).
153///    - `Ok(None)` → MISS: proceed to step 3.
154/// 3. Run the on-miss sub-pipeline.
155///    - `Stopped(ex)` / `Failed(e)` → propagate as-is (NO write-back).
156///    - `Completed(ex)` → proceed to write-back.
157/// 4. Write-back the resulting body (when it fits `max_entry_bytes`):
158///    - materialized variants (`Bytes`/`Text`/`Json`/`Xml`) → serialize, store.
159///    - `Stream` → materialize via [`Body::into_bytes`] (consumes the body,
160///      replaces it with `Body::Bytes`); `StreamLimitExceeded` propagates.
161///    - `Empty` / oversized body → pass through uncached, return `Completed`.
162///
163/// With `coalesce_misses` enabled ([`CacheService::with_coalesce`]),
164/// concurrent misses on the same resolved key are coalesced
165/// (singleflight): the first exchange (leader) runs `on_miss` and the
166/// single write-back `set`; concurrent exchanges (waiters) await the
167/// leader's terminal state instead of running `on_miss`. HIT, key-`None`,
168/// and `coalesce_misses == false` paths bypass the in-flight map
169/// entirely.
170pub struct CacheService {
171    repository: Arc<dyn CacheRepository>,
172    /// Cached `repository.name()` for OTel span tagging (Task 3.3).
173    repository_name: String,
174    key_expr: MessageIdExpression,
175    ttl: Option<Duration>,
176    max_entry_bytes: usize,
177    on_miss: OutcomeSegment,
178    rt: Arc<dyn RuntimeObservability>,
179    /// Singleflight miss coalescing toggle (default `false`).
180    coalesce_misses: bool,
181    /// In-flight coalescing waves. `Clone` clones the `Arc`, so every
182    /// service clone of one compiled route-step shares the same map.
183    inflight: Arc<InFlightMap>,
184}
185
186impl CacheService {
187    /// Build a new cache segment.
188    ///
189    /// `repository_name` is derived from `repository.name()` so OTel tags stay
190    /// in sync with the resolved backend.
191    pub fn new(
192        repository: Arc<dyn CacheRepository>,
193        key_expr: MessageIdExpression,
194        ttl: Option<Duration>,
195        max_entry_bytes: usize,
196        on_miss: OutcomeSegment,
197        rt: Arc<dyn RuntimeObservability>,
198    ) -> Self {
199        let repository_name = repository.name().to_string();
200        Self {
201            repository,
202            repository_name,
203            key_expr,
204            ttl,
205            max_entry_bytes,
206            on_miss,
207            rt,
208            coalesce_misses: false,
209            inflight: Arc::new(InFlightMap::default()),
210        }
211    }
212
213    /// Enable (or explicitly disable) singleflight miss coalescing.
214    ///
215    /// With coalescing on, concurrent misses on the same resolved key
216    /// run the `on_miss` sub-pipeline exactly once per wave (leader
217    /// runs + writes back; waiters receive the leader's terminal state).
218    pub fn with_coalesce(mut self, coalesce_misses: bool) -> Self {
219        self.coalesce_misses = coalesce_misses;
220        self
221    }
222
223    /// The configured repository name (for OTel tagging).
224    pub fn repository_name(&self) -> &str {
225        &self.repository_name
226    }
227}
228
229/// Shared write-back tail for materialized bodies.
230///
231/// Checks `max_entry_bytes`, builds a [`CacheEntry`], stores via
232/// the repository, and returns `Completed(exchange)`. The exchange body
233/// is not modified — it passes through as-is. On oversized body, logs a
234/// debug! skip message and returns `Completed(exchange)` without storing.
235/// On repository error, returns `Failed(e)`.
236#[allow(clippy::too_many_arguments)]
237async fn write_back(
238    repository: &Arc<dyn CacheRepository>,
239    repository_name: &str,
240    max_entry_bytes: usize,
241    ttl: Option<Duration>,
242    exchange: Exchange,
243    key: &str,
244    serialized: Vec<u8>,
245    content_type: ContentType,
246) -> PipelineOutcome {
247    if serialized.len() <= max_entry_bytes {
248        let entry = CacheEntry {
249            bytes: serialized,
250            content_type,
251            expires_at: None,
252        };
253        match repository.set(key, entry, ttl).await {
254            Ok(()) => {}
255            Err(e) => {
256                if matches!(&e, CamelError::Config(msg) if msg.starts_with("cache: max_entries")) {
257                    tracing::debug!(
258                        repository = %repository_name,
259                        key = %key,
260                        "cache at capacity, skipping write-back"
261                    ); // log-policy: g:cache:capacity-full-skip
262                } else {
263                    return PipelineOutcome::Failed(e);
264                }
265            }
266        }
267    } else {
268        // log-policy: g:cache:oversized-skip
269        tracing::debug!(
270            repository = %repository_name,
271            key = %key,
272            len = serialized.len(),
273            max = max_entry_bytes,
274            "cache write-back skipped: body exceeds max_entry_bytes"
275        );
276    }
277    PipelineOutcome::Completed(exchange)
278}
279
280impl Clone for CacheService {
281    fn clone(&self) -> Self {
282        Self {
283            repository: Arc::clone(&self.repository),
284            repository_name: self.repository_name.clone(),
285            key_expr: Arc::clone(&self.key_expr),
286            ttl: self.ttl,
287            max_entry_bytes: self.max_entry_bytes,
288            on_miss: self.on_miss.clone(),
289            rt: Arc::clone(&self.rt),
290            coalesce_misses: self.coalesce_misses,
291            inflight: Arc::clone(&self.inflight),
292        }
293    }
294}
295
296impl OutcomePipeline for CacheService {
297    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
298        Box::new(self.clone())
299    }
300
301    fn run<'a>(
302        &'a mut self,
303        exchange: Exchange,
304    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
305        Box::pin(async move {
306            // 1. Evaluate key. None → not cacheable, bypass straight to on_miss.
307            let key = match (self.key_expr)(&exchange) {
308                Some(k) => k,
309                None => return self.on_miss.run(exchange).await,
310            };
311
312            // 2. Lookup (contract C1: propagate Err, never treat as miss).
313            match self.repository.get(&key).await {
314                Err(e) => return PipelineOutcome::Failed(e),
315                Ok(Some(entry)) => {
316                    // HIT: record metric, reconstruct body, skip on-miss sub-pipeline.
317                    self.rt.metrics().record_counter(
318                        "camel.cache.hits",
319                        1.0_f64,
320                        &[("repository", &self.repository_name)],
321                    );
322                    match reconstruct_body(&entry) {
323                        Ok(body) => {
324                            let mut exchange = exchange;
325                            exchange.input.body = body;
326                            return PipelineOutcome::Completed(exchange);
327                        }
328                        Err(e) => return PipelineOutcome::Failed(e),
329                    }
330                }
331                Ok(None) => {
332                    // MISS: record metric, fall through to on-miss sub-pipeline.
333                    self.rt.metrics().record_counter(
334                        "camel.cache.misses",
335                        1.0_f64,
336                        &[("repository", &self.repository_name)],
337                    );
338                }
339            }
340
341            // 3./4. MISS flow, singleflight-coalesced when enabled.
342            if self.coalesce_misses {
343                return self.coalesced_miss(exchange, key).await;
344            }
345            self.run_miss(exchange, key).await
346        })
347    }
348}
349
350impl CacheService {
351    /// The un-coalesced MISS flow (spec steps 3-4): run the on-miss
352    /// sub-pipeline, then write the resulting body back (subject to
353    /// `max_entry_bytes` and the materialization policy). Also the
354    /// leader's flow under coalescing.
355    async fn run_miss(&mut self, exchange: Exchange, key: String) -> PipelineOutcome {
356        // 3. Run the on-miss sub-pipeline.
357        let mut exchange = match self.on_miss.run(exchange).await {
358            PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
359            PipelineOutcome::Failed(e) => return PipelineOutcome::Failed(e),
360            PipelineOutcome::Completed(ex) => ex,
361        };
362
363        // 4. Write-back. Take the body out so the Stream arm can consume it.
364        let body = std::mem::replace(&mut exchange.input.body, Body::Empty);
365        match body {
366            Body::Bytes(b) => {
367                let serialized = b.to_vec();
368                exchange.input.body = Body::Bytes(b);
369                write_back(
370                    &self.repository,
371                    &self.repository_name,
372                    self.max_entry_bytes,
373                    self.ttl,
374                    exchange,
375                    &key,
376                    serialized,
377                    ContentType::Bytes,
378                )
379                .await
380            }
381            Body::Text(s) => {
382                let serialized = s.as_bytes().to_vec();
383                exchange.input.body = Body::Text(s);
384                write_back(
385                    &self.repository,
386                    &self.repository_name,
387                    self.max_entry_bytes,
388                    self.ttl,
389                    exchange,
390                    &key,
391                    serialized,
392                    ContentType::Text,
393                )
394                .await
395            }
396            Body::Json(v) => {
397                let serialized = match serde_json::to_vec(&v) {
398                    Ok(b) => b,
399                    Err(e) => {
400                        exchange.input.body = Body::Json(v);
401                        return PipelineOutcome::Failed(CamelError::TypeConversionFailed(
402                            e.to_string(),
403                        ));
404                    }
405                };
406                exchange.input.body = Body::Json(v);
407                write_back(
408                    &self.repository,
409                    &self.repository_name,
410                    self.max_entry_bytes,
411                    self.ttl,
412                    exchange,
413                    &key,
414                    serialized,
415                    ContentType::Json,
416                )
417                .await
418            }
419            Body::Xml(s) => {
420                let serialized = s.as_bytes().to_vec();
421                exchange.input.body = Body::Xml(s);
422                write_back(
423                    &self.repository,
424                    &self.repository_name,
425                    self.max_entry_bytes,
426                    self.ttl,
427                    exchange,
428                    &key,
429                    serialized,
430                    ContentType::Xml,
431                )
432                .await
433            }
434            Body::Stream(stream_body) => {
435                // Materialize (consumes the stream). StreamLimitExceeded propagates.
436                let materialized = match Body::Stream(stream_body)
437                    .into_bytes(self.max_entry_bytes)
438                    .await
439                {
440                    Ok(b) => b,
441                    Err(e) => return PipelineOutcome::Failed(e),
442                };
443                // into_bytes already enforced max_entry_bytes, so it fits by construction.
444                let entry = CacheEntry {
445                    bytes: materialized.to_vec(),
446                    content_type: ContentType::Bytes,
447                    expires_at: None,
448                };
449                if let Err(e) = self.repository.set(&key, entry, self.ttl).await {
450                    // Degrade capacity-exceeded to uncached — same policy as write_back.
451                    if matches!(&e, CamelError::Config(msg) if msg.starts_with("cache: max_entries"))
452                    {
453                        tracing::debug!(
454                            repository = %self.repository_name,
455                            key = %key,
456                            "cache at capacity, skipping write-back for stream"
457                        ); // log-policy: g:cache:capacity-full-skip
458                        exchange.input.body = Body::Bytes(materialized);
459                        return PipelineOutcome::Completed(exchange);
460                    }
461                    exchange.input.body = Body::Bytes(materialized);
462                    return PipelineOutcome::Failed(e);
463                }
464                exchange.input.body = Body::Bytes(materialized);
465                PipelineOutcome::Completed(exchange)
466            }
467            _ => {
468                // Empty (or any future variant): pass through uncached.
469                exchange.input.body = body;
470                PipelineOutcome::Completed(exchange)
471            }
472        }
473    }
474
475    /// The coalesced MISS flow (singleflight, cache-admin task 2.4).
476    ///
477    /// The first exchange on a key (leader) inserts the in-flight cell
478    /// and runs [`CacheService::run_miss`] (on_miss + the single
479    /// write-back `set`) under a [`LeaderGuard`]; concurrent misses on
480    /// the same key (waiters) do NOT run on_miss — they await the
481    /// leader's terminal state and clone-read it.
482    ///
483    /// Protocol (cancellation-safe, race-free):
484    /// - Waiter registration is atomic with the map lookup: the
485    ///   pinned-`Notified` `enable()` happens while STILL holding the
486    ///   map lock, so the leader's publish+notify cannot slip between
487    ///   the lookup and the registration.
488    /// - The terminal slot is filled BEFORE `notify_waiters()`, and a
489    ///   woken waiter re-reads the slot (no lost wakeup —
490    ///   `notify_waiters` alone wakes only currently-registered
491    ///   waiters).
492    /// - Map removal happens only on `Arc::ptr_eq` identity with this
493    ///   leader's cell (a late guard cannot evict a newer wave's entry).
494    /// - The slot is write-once: once filled it is never cleared or
495    ///   overwritten.
496    async fn coalesced_miss(&mut self, exchange: Exchange, key: String) -> PipelineOutcome {
497        let inflight = Arc::clone(&self.inflight);
498
499        // Resolve the role under ONE short lock scope; the guard (and
500        // the lock Result) never crosses an await. A waiter registers
501        // its Notified (Box::pin + enable) while STILL holding the map
502        // lock — registration atomic with the lookup. The registration
503        // borrows the outer `wave` binding (which outlives the guard),
504        // so it can be awaited after the guard is gone.
505        let mut wave: Option<Arc<InFlight>> = None;
506        let mut registered = None;
507        match inflight.lock() {
508            Ok(mut map) => {
509                match map.get(&key).cloned() {
510                    Some(existing) => {
511                        // WAITER: claim the existing wave, then register
512                        // (pin + enable) while STILL holding the lock.
513                        wave = Some(existing);
514                        if let Some(cell) = wave.as_ref() {
515                            let mut notified = Box::pin(cell.notify.notified());
516                            notified.as_mut().enable();
517                            registered = Some(notified);
518                        }
519                    }
520                    None => {
521                        // LEADER: claim the key.
522                        let cell = Arc::new(InFlight::default());
523                        map.insert(key.clone(), Arc::clone(&cell));
524                        wave = Some(cell);
525                    }
526                }
527            }
528            Err(poisoned) => drop(poisoned),
529        }
530        let Some(cell_ref) = wave.as_ref() else {
531            // Unreachable on the Ok path (both arms set `wave`); a
532            // poisoned in-flight map degrades to un-coalesced
533            // execution rather than stranding exchanges.
534            return self.run_miss(exchange, key).await;
535        };
536
537        if let Some(notified) = registered {
538            // WAITER. The slot may already be filled (leader finished
539            // between registration and this read): clone-read without
540            // parking. Otherwise await the leader's notify and re-read.
541            let terminal = match cell_ref.terminal_snapshot() {
542                Some(t) => t,
543                None => {
544                    notified.await;
545                    cell_ref.terminal_snapshot().unwrap_or_else(|| {
546                        CoalesceTerminal::Failed(CamelError::Config(
547                            "cache coalesce waiter woke without a terminal state".into(),
548                        ))
549                    })
550                }
551            };
552            match terminal {
553                CoalesceTerminal::Completed(body) => {
554                    let mut exchange = exchange;
555                    exchange.input.body = body;
556                    PipelineOutcome::Completed(exchange)
557                }
558                CoalesceTerminal::Failed(e) => PipelineOutcome::Failed(e),
559                CoalesceTerminal::Stopped => PipelineOutcome::Stopped(exchange),
560            }
561        } else {
562            // LEADER. Run the miss flow under a cancellation guard,
563            // publish the terminal state, wake waiters, retire the
564            // map entry.
565            let cell = Arc::clone(cell_ref);
566            let _guard = LeaderGuard {
567                key: key.clone(),
568                map: Arc::clone(&inflight),
569                cell: Arc::clone(&cell),
570            };
571            let outcome = self.run_miss(exchange, key.clone()).await;
572            let terminal = match &outcome {
573                PipelineOutcome::Completed(ex) => {
574                    CoalesceTerminal::Completed(ex.input.body.clone())
575                }
576                PipelineOutcome::Failed(e) => CoalesceTerminal::Failed(e.clone()),
577                PipelineOutcome::Stopped(_) => CoalesceTerminal::Stopped,
578            };
579            // Slot BEFORE notify: woken waiters re-read a filled slot.
580            cell.publish(terminal);
581            cell.notify.notify_waiters();
582            LeaderGuard::retire(&inflight, &key, &cell);
583            // Guard drop is a no-op now: the write-once slot is
584            // filled and the entry is retired.
585            outcome
586        }
587    }
588}
589
590/// Reconstruct a [`Body`] from a stored [`CacheEntry`].
591///
592/// Maps each [`ContentType`] back to the matching `Body` variant, decoding
593/// UTF-8 / JSON failures into `CamelError::TypeConversionFailed`.
594fn reconstruct_body(entry: &CacheEntry) -> Result<Body, CamelError> {
595    match entry.content_type {
596        ContentType::Bytes => Ok(Body::Bytes(Bytes::from(entry.bytes.clone()))),
597        ContentType::Text => {
598            let s = String::from_utf8(entry.bytes.clone()).map_err(|e| {
599                CamelError::TypeConversionFailed(format!("cached text is not valid UTF-8: {e}"))
600            })?;
601            Ok(Body::Text(s))
602        }
603        ContentType::Json => {
604            let v = serde_json::from_slice(&entry.bytes).map_err(|e| {
605                CamelError::TypeConversionFailed(format!("cached bytes are not valid JSON: {e}"))
606            })?;
607            Ok(Body::Json(v))
608        }
609        ContentType::Xml => {
610            let s = String::from_utf8(entry.bytes.clone()).map_err(|e| {
611                CamelError::TypeConversionFailed(format!("cached xml is not valid UTF-8: {e}"))
612            })?;
613            Ok(Body::Xml(s))
614        }
615    }
616}
617
618// ===========================================================================
619// CacheInvalidateService — invalidate a single cache entry or a namespace
620// ===========================================================================
621
622/// Exchange property set to the number of entries removed by a successful
623/// `cache_invalidate` step — always `1` for exact-key (removal is not
624/// observable by the backend), the returned count for a namespace purge. Not
625/// set when the key/prefix expression resolves to `None` or the backend
626/// reports an error.
627pub const CAMEL_CACHE_INVALIDATED_COUNT: &str = "CamelCacheInvalidatedCount";
628
629/// The invalidation target of a [`CacheInvalidateService`]: an exact key or a
630/// namespace prefix.
631#[derive(Clone)]
632pub enum CacheInvalidateTarget {
633    /// Invalidate the single entry under the resolved key.
634    Key(MessageIdExpression),
635    /// Invalidate every entry whose key starts with the resolved prefix.
636    Prefix(MessageIdExpression),
637}
638
639/// Outcome-aware segment that invalidates a single cache entry or a namespace.
640///
641/// Evaluates the configured [`CacheInvalidateTarget`]:
642/// - [`Key`](CacheInvalidateTarget::Key): expression `None` → `Completed`
643///   (nothing to invalidate); `Some(key)` → `repository.invalidate(&key).await`.
644///   - `Err(e)` → `Failed(e)`.
645///   - `Ok(())` → sets `CAMEL_CACHE_INVALIDATED_COUNT = 1`, emits
646///     `camel.cache.invalidations` +1, `Completed(exchange)`.
647/// - [`Prefix`](CacheInvalidateTarget::Prefix): expression `None` → `Completed`;
648///   `Some(prefix)` → `repository.invalidate_prefix(&prefix).await`.
649///   - `Err(e)` → `Failed(e)` (an unsupported backend surfaces as failure —
650///     fail-closed).
651///   - `Ok(count)` → sets `CAMEL_CACHE_INVALIDATED_COUNT = count`, emits
652///     `camel.cache.invalidations` +1, `Completed(exchange)`.
653pub struct CacheInvalidateService {
654    repository: Arc<dyn CacheRepository>,
655    target: CacheInvalidateTarget,
656    rt: Arc<dyn RuntimeObservability>,
657    repository_name: String,
658}
659
660impl CacheInvalidateService {
661    pub fn new(
662        repository: Arc<dyn CacheRepository>,
663        target: CacheInvalidateTarget,
664        rt: Arc<dyn RuntimeObservability>,
665    ) -> Self {
666        let repository_name = repository.name().to_string();
667        Self {
668            repository,
669            target,
670            rt,
671            repository_name,
672        }
673    }
674}
675
676impl Clone for CacheInvalidateService {
677    fn clone(&self) -> Self {
678        Self {
679            repository: Arc::clone(&self.repository),
680            target: self.target.clone(),
681            rt: Arc::clone(&self.rt),
682            repository_name: self.repository_name.clone(),
683        }
684    }
685}
686
687impl OutcomePipeline for CacheInvalidateService {
688    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
689        Box::new(self.clone())
690    }
691
692    fn run<'a>(
693        &'a mut self,
694        exchange: Exchange,
695    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
696        Box::pin(async move {
697            match self.target.clone() {
698                CacheInvalidateTarget::Key(key_expr) => {
699                    let key = match key_expr(&exchange) {
700                        Some(k) => k,
701                        None => return PipelineOutcome::Completed(exchange),
702                    };
703                    match self.repository.invalidate(&key).await {
704                        Err(e) => PipelineOutcome::Failed(e),
705                        Ok(()) => {
706                            self.rt.metrics().record_counter(
707                                "camel.cache.invalidations",
708                                1.0_f64,
709                                &[("repository", &self.repository_name)],
710                            );
711                            let mut exchange = exchange;
712                            exchange.set_property(
713                                CAMEL_CACHE_INVALIDATED_COUNT,
714                                serde_json::Value::from(1u64),
715                            );
716                            PipelineOutcome::Completed(exchange)
717                        }
718                    }
719                }
720                CacheInvalidateTarget::Prefix(prefix_expr) => {
721                    let prefix = match prefix_expr(&exchange) {
722                        Some(p) => p,
723                        None => return PipelineOutcome::Completed(exchange),
724                    };
725                    match self.repository.invalidate_prefix(&prefix).await {
726                        Err(e) => PipelineOutcome::Failed(e),
727                        Ok(count) => {
728                            self.rt.metrics().record_counter(
729                                "camel.cache.invalidations",
730                                1.0_f64,
731                                &[("repository", &self.repository_name)],
732                            );
733                            let mut exchange = exchange;
734                            exchange.set_property(
735                                CAMEL_CACHE_INVALIDATED_COUNT,
736                                serde_json::Value::from(count),
737                            );
738                            PipelineOutcome::Completed(exchange)
739                        }
740                    }
741                }
742            }
743        })
744    }
745}
746
747// ===========================================================================
748// CachePeekStaleService — serve a stale entry after expiry
749// ===========================================================================
750
751/// Exchange property set to `true` when a `cache_peek_stale` HIT occurred.
752pub const CAMEL_CACHE_PEEK_HIT: &str = "CamelCachePeekHit";
753/// Exchange property set to `true` when the served entry was stale (post-expiry).
754pub const CAMEL_CACHE_PEEK_STALE: &str = "CamelCachePeekStale";
755
756/// On-miss policy for [`CachePeekStaleService`].
757///
758/// - [`Stop`](PeekStaleMissPolicy::Stop) (default) preserves the
759///   `CircuitBreaker.fallback` absence-Stops contract.
760/// - [`Continue`](PeekStaleMissPolicy::Continue) leaves the body untouched on
761///   MISS so `choice` can branch on [`CAMEL_CACHE_PEEK_HIT`].
762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763pub enum PeekStaleMissPolicy {
764    /// MISS Stops the branch (no stale available — `CircuitBreaker.fallback`).
765    Stop,
766    /// MISS continues with the body unchanged.
767    Continue,
768}
769
770impl PeekStaleMissPolicy {
771    /// Parses the canonical/DSL `cache_peek_stale.on_miss` knob:
772    /// absent or `"stop"` → [`Stop`](Self::Stop), `"continue"` →
773    /// [`Continue`](Self::Continue). Any other value fails closed naming
774    /// the step.
775    pub fn parse_on_miss(raw: Option<&str>) -> Result<Self, CamelError> {
776        match raw {
777            None | Some("stop") => Ok(Self::Stop),
778            Some("continue") => Ok(Self::Continue),
779            Some(other) => Err(CamelError::Config(format!(
780                "cache_peek_stale: invalid on_miss '{other}'; must be \"stop\" or \"continue\""
781            ))),
782        }
783    }
784}
785
786/// Outcome-aware segment that serves a stale (post-expiry) cache entry.
787///
788/// Evaluates `key_expr`:
789/// - `None` → `Stopped(exchange)` with a `debug` log (an anomalous key
790///   resolution is fail-closed, not a miss).
791/// - `Some(key)` → `repository.peek_stale(&key).await`.
792///   - `Err(e)` → `Failed(e)`.
793///   - `Ok(Some(entry))` → reconstruct body from entry, set
794///     `CamelCachePeekHit=true` and `CamelCachePeekStale` (true when the
795///     entry's `expires_at` has elapsed at evaluation time; false when absent
796///     or not elapsed), return `Completed(exchange)`.
797///   - `Ok(None)` → MISS (absence), governed by [`PeekStaleMissPolicy`]:
798///     - `Stop` (default): set `CamelCachePeekHit=false` and
799///       `CamelCachePeekStale=false`, log at `debug`, return `Stopped(exchange)`
800///       (absence in `CircuitBreaker.fallback` means "no stale available").
801///     - `Continue`: set `CamelCachePeekHit=false` and
802///       `CamelCachePeekStale=false`, leave the body unchanged, return
803///       `Completed(exchange)` so `choice` can branch on `CamelCachePeekHit`.
804pub struct CachePeekStaleService {
805    repository: Arc<dyn CacheRepository>,
806    key_expr: MessageIdExpression,
807    miss_policy: PeekStaleMissPolicy,
808    rt: Arc<dyn RuntimeObservability>,
809    repository_name: String,
810}
811
812impl CachePeekStaleService {
813    pub fn new(
814        repository: Arc<dyn CacheRepository>,
815        key_expr: MessageIdExpression,
816        miss_policy: PeekStaleMissPolicy,
817        rt: Arc<dyn RuntimeObservability>,
818    ) -> Self {
819        let repository_name = repository.name().to_string();
820        Self {
821            repository,
822            key_expr,
823            miss_policy,
824            rt,
825            repository_name,
826        }
827    }
828}
829
830impl Clone for CachePeekStaleService {
831    fn clone(&self) -> Self {
832        Self {
833            repository: Arc::clone(&self.repository),
834            key_expr: Arc::clone(&self.key_expr),
835            miss_policy: self.miss_policy,
836            rt: Arc::clone(&self.rt),
837            repository_name: self.repository_name.clone(),
838        }
839    }
840}
841
842/// Write the peek-result exchange properties under [`CAMEL_CACHE_PEEK_HIT`] and
843/// [`CAMEL_CACHE_PEEK_STALE`] as `serde_json::Value::Bool` values.
844fn set_peek_properties(exchange: &mut Exchange, hit: bool, stale: bool) {
845    exchange.set_property(CAMEL_CACHE_PEEK_HIT, serde_json::Value::Bool(hit));
846    exchange.set_property(CAMEL_CACHE_PEEK_STALE, serde_json::Value::Bool(stale));
847}
848
849impl OutcomePipeline for CachePeekStaleService {
850    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
851        Box::new(self.clone())
852    }
853
854    fn run<'a>(
855        &'a mut self,
856        exchange: Exchange,
857    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
858        Box::pin(async move {
859            let key = match (self.key_expr)(&exchange) {
860                Some(k) => k,
861                None => {
862                    tracing::debug!(
863                        step = "cache_peek_stale",
864                        repository = %self.repository.name(),
865                        "key expression resolved to None; stopping branch"
866                    );
867                    return PipelineOutcome::Stopped(exchange);
868                }
869            };
870            match self.repository.peek_stale(&key).await {
871                Err(e) => PipelineOutcome::Failed(e),
872                Ok(Some(entry)) => {
873                    // Emit peek_stale_served (fresh or stale — both are serves).
874                    self.rt.metrics().record_counter(
875                        "camel.cache.peek_stale_served",
876                        1.0_f64,
877                        &[("repository", &self.repository_name)],
878                    );
879                    // Staleness read before body reconstruction; reconstruct_body borrows the entry, so ordering is stylistic.
880                    let stale = entry
881                        .expires_at
882                        .map(|t| t <= SystemTime::now())
883                        .unwrap_or(false);
884                    match reconstruct_body(&entry) {
885                        Ok(body) => {
886                            let mut exchange = exchange;
887                            exchange.input.body = body;
888                            set_peek_properties(&mut exchange, true, stale);
889                            PipelineOutcome::Completed(exchange)
890                        }
891                        Err(e) => PipelineOutcome::Failed(e),
892                    }
893                }
894                Ok(None) => match self.miss_policy {
895                    PeekStaleMissPolicy::Stop => {
896                        let mut exchange = exchange;
897                        set_peek_properties(&mut exchange, false, false);
898                        tracing::debug!(
899                            step = "cache_peek_stale",
900                            repository = %self.repository.name(),
901                            "peek miss; stopping branch per on_miss=stop"
902                        );
903                        PipelineOutcome::Stopped(exchange)
904                    }
905                    PeekStaleMissPolicy::Continue => {
906                        let mut exchange = exchange;
907                        set_peek_properties(&mut exchange, false, false);
908                        PipelineOutcome::Completed(exchange)
909                    }
910                },
911            }
912        })
913    }
914}
915
916// ===========================================================================
917// CacheClearService — remove all entries from the repository
918// ===========================================================================
919
920/// Outcome-aware segment that clears the entire cache repository.
921///
922/// - `Err(e)` → `Failed(e)`.
923/// - `Ok(())` → `Completed(exchange)` with the body unchanged.
924pub struct CacheClearService {
925    repository: Arc<dyn CacheRepository>,
926}
927
928impl CacheClearService {
929    pub fn new(repository: Arc<dyn CacheRepository>) -> Self {
930        Self { repository }
931    }
932}
933
934impl Clone for CacheClearService {
935    fn clone(&self) -> Self {
936        Self {
937            repository: Arc::clone(&self.repository),
938        }
939    }
940}
941
942impl OutcomePipeline for CacheClearService {
943    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
944        Box::new(self.clone())
945    }
946
947    fn run<'a>(
948        &'a mut self,
949        exchange: Exchange,
950    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
951        Box::pin(async move {
952            match self.repository.clear().await {
953                Err(e) => PipelineOutcome::Failed(e),
954                Ok(()) => PipelineOutcome::Completed(exchange),
955            }
956        })
957    }
958}
959
960// ===========================================================================
961// CacheStatsService — emit the repository stats as a JSON body
962// ===========================================================================
963
964/// Outcome-aware segment that replaces the exchange body with a JSON snapshot
965/// of the repository's [`CacheStats`].
966pub struct CacheStatsService {
967    repository: Arc<dyn CacheRepository>,
968    repository_name: String,
969}
970
971impl CacheStatsService {
972    pub fn new(repository: Arc<dyn CacheRepository>) -> Self {
973        let repository_name = repository.name().to_string();
974        Self {
975            repository,
976            repository_name,
977        }
978    }
979}
980
981impl Clone for CacheStatsService {
982    fn clone(&self) -> Self {
983        Self {
984            repository: Arc::clone(&self.repository),
985            repository_name: self.repository_name.clone(),
986        }
987    }
988}
989
990impl OutcomePipeline for CacheStatsService {
991    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
992        Box::new(self.clone())
993    }
994
995    fn run<'a>(
996        &'a mut self,
997        mut exchange: Exchange,
998    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
999        Box::pin(async move {
1000            let s = self.repository.stats().await;
1001            exchange.input.body = Body::Json(serde_json::json!({
1002                "repository": self.repository_name,
1003                "hits": s.hits,
1004                "misses": s.misses,
1005                "evictions": s.evictions,
1006                "entries": s.entries,
1007                "peek_stale_served": s.peek_stale_served,
1008                "invalidations": s.invalidations,
1009                "bytes": s.bytes,
1010            }));
1011            PipelineOutcome::Completed(exchange)
1012        })
1013    }
1014}
1015
1016// ===========================================================================
1017// Test utilities
1018// ===========================================================================
1019
1020#[cfg(test)]
1021mod test_utils {
1022    use super::*;
1023    use async_trait::async_trait;
1024    use camel_api::cache::CacheStats;
1025    use std::collections::HashMap;
1026    use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
1027    use tokio::sync::Mutex;
1028
1029    /// In-memory mock [`CacheRepository`] for cache segment tests. Allows tests
1030    /// to pre-seed entries, force `get`/`set` failures, and inspect the last
1031    /// `set` call (entry + TTL).
1032    #[derive(Debug, Default)]
1033    pub struct MockCacheRepository {
1034        name: String,
1035        entries: Arc<Mutex<HashMap<String, CacheEntry>>>,
1036        get_should_fail: Arc<AtomicBool>,
1037        set_should_fail: Arc<AtomicBool>,
1038        set_call_count: Arc<AtomicU32>,
1039        last_set_ttl: Arc<Mutex<Option<Duration>>>,
1040        invalidate_call_count: Arc<AtomicU32>,
1041        last_invalidate_key: Arc<Mutex<Option<String>>>,
1042        clear_call_count: Arc<AtomicU64>,
1043        clear_should_fail: Arc<AtomicBool>,
1044        invalidate_should_fail: Arc<AtomicBool>,
1045        prefix_unsupported: Arc<AtomicBool>,
1046        stats_override: std::sync::Mutex<CacheStats>,
1047    }
1048
1049    impl MockCacheRepository {
1050        pub fn new(name: &str) -> Self {
1051            Self {
1052                name: name.to_string(),
1053                ..Default::default()
1054            }
1055        }
1056
1057        pub fn invalidate_call_count(&self) -> u32 {
1058            self.invalidate_call_count.load(Ordering::SeqCst)
1059        }
1060
1061        pub async fn last_invalidate_key(&self) -> Option<String> {
1062            self.last_invalidate_key.lock().await.clone()
1063        }
1064
1065        pub fn clear_call_count(&self) -> u64 {
1066            self.clear_call_count.load(Ordering::SeqCst)
1067        }
1068
1069        pub fn set_should_fail_clear(&self, v: bool) {
1070            self.clear_should_fail.store(v, Ordering::SeqCst);
1071        }
1072
1073        pub fn set_should_fail_invalidate(&self, v: bool) {
1074            self.invalidate_should_fail.store(v, Ordering::SeqCst);
1075        }
1076
1077        /// Force `invalidate_prefix` to report the backend-naming unsupported
1078        /// error (mirrors the default `CacheRepository::invalidate_prefix`).
1079        pub fn set_prefix_unsupported(&self, v: bool) {
1080            self.prefix_unsupported.store(v, Ordering::SeqCst);
1081        }
1082
1083        pub fn set_stats(&self, stats: CacheStats) {
1084            *self.stats_override.lock().unwrap() = stats; // allow-unwrap: test-only
1085        }
1086
1087        /// Pre-seed a key so `get` returns a HIT.
1088        pub async fn seed(&self, key: &str, entry: CacheEntry) {
1089            self.entries.lock().await.insert(key.to_string(), entry);
1090        }
1091
1092        pub fn set_get_should_fail(&self, v: bool) {
1093            self.get_should_fail.store(v, Ordering::SeqCst);
1094        }
1095
1096        pub fn set_set_should_fail(&self, v: bool) {
1097            self.set_should_fail.store(v, Ordering::SeqCst);
1098        }
1099
1100        pub fn set_call_count(&self) -> u32 {
1101            self.set_call_count.load(Ordering::SeqCst)
1102        }
1103
1104        /// The TTL passed to the most recent `set` call.
1105        pub async fn last_set_ttl(&self) -> Option<Duration> {
1106            *self.last_set_ttl.lock().await
1107        }
1108
1109        /// Inspect the entry currently stored for `key` (if any).
1110        pub async fn stored_entry(&self, key: &str) -> Option<CacheEntry> {
1111            self.entries.lock().await.get(key).cloned()
1112        }
1113    }
1114
1115    #[async_trait]
1116    impl CacheRepository for MockCacheRepository {
1117        fn name(&self) -> &str {
1118            &self.name
1119        }
1120
1121        async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
1122            if self.get_should_fail.load(Ordering::SeqCst) {
1123                return Err(CamelError::ProcessorError("synthetic get failure".into()));
1124            }
1125            // Read first, then yield: concurrent same-key lookups that
1126            // reach `get` before a write-back all observe the miss
1127            // (without the yield the uncontended tokio Mutex fast path
1128            // completes without rescheduling, serializing "concurrent"
1129            // exchanges and hiding the per-exchange behavior under test).
1130            let found = self.entries.lock().await.get(key).cloned();
1131            tokio::task::yield_now().await;
1132            Ok(found)
1133        }
1134
1135        async fn set(
1136            &self,
1137            key: &str,
1138            value: CacheEntry,
1139            ttl: Option<Duration>,
1140        ) -> Result<(), CamelError> {
1141            self.set_call_count.fetch_add(1, Ordering::SeqCst);
1142            *self.last_set_ttl.lock().await = ttl;
1143            if self.set_should_fail.load(Ordering::SeqCst) {
1144                return Err(CamelError::ProcessorError("synthetic set failure".into()));
1145            }
1146            self.entries.lock().await.insert(key.to_string(), value);
1147            Ok(())
1148        }
1149
1150        async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
1151            self.get(key).await
1152        }
1153
1154        async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
1155            self.invalidate_call_count.fetch_add(1, Ordering::SeqCst);
1156            *self.last_invalidate_key.lock().await = Some(key.to_string());
1157            if self.invalidate_should_fail.load(Ordering::SeqCst) {
1158                return Err(CamelError::ProcessorError(
1159                    "synthetic invalidate failure".into(),
1160                ));
1161            }
1162            self.entries.lock().await.remove(key);
1163            Ok(())
1164        }
1165
1166        async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
1167            if self.prefix_unsupported.load(Ordering::SeqCst) {
1168                return Err(CamelError::Config(format!(
1169                    "cache backend '{}' does not support invalidate_prefix (no key iteration)",
1170                    self.name()
1171                )));
1172            }
1173            let mut entries = self.entries.lock().await;
1174            let keys: Vec<String> = entries
1175                .keys()
1176                .filter(|k| k.starts_with(prefix))
1177                .cloned()
1178                .collect();
1179            let count = keys.len() as u64;
1180            for k in keys {
1181                entries.remove(&k);
1182            }
1183            Ok(count)
1184        }
1185
1186        async fn clear(&self) -> Result<(), CamelError> {
1187            self.clear_call_count.fetch_add(1, Ordering::SeqCst);
1188            if self.clear_should_fail.load(Ordering::SeqCst) {
1189                return Err(CamelError::ProcessorError("synthetic clear failure".into()));
1190            }
1191            self.entries.lock().await.clear();
1192            Ok(())
1193        }
1194
1195        async fn stats(&self) -> CacheStats {
1196            self.stats_override.lock().unwrap().clone() // allow-unwrap: test-only
1197        }
1198    }
1199}
1200
1201// ===========================================================================
1202// Tests
1203// ===========================================================================
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::test_utils::MockCacheRepository;
1208    use super::*;
1209    use camel_api::body::{StreamBody, StreamMetadata};
1210    use camel_api::cache::CacheStats;
1211    use camel_api::metrics::NoOpMetrics;
1212    use camel_api::{Message, Value};
1213    use camel_component_api::health_registry::NoOpHealthCheckRegistry;
1214    use futures::stream;
1215    use std::sync::Mutex;
1216    use std::sync::atomic::{AtomicBool, Ordering};
1217    use std::time::SystemTime;
1218
1219    #[test]
1220    fn parse_on_miss_maps_absent_stop_and_continue() {
1221        assert_eq!(
1222            PeekStaleMissPolicy::parse_on_miss(None).unwrap(),
1223            PeekStaleMissPolicy::Stop
1224        );
1225        assert_eq!(
1226            PeekStaleMissPolicy::parse_on_miss(Some("stop")).unwrap(),
1227            PeekStaleMissPolicy::Stop
1228        );
1229        assert_eq!(
1230            PeekStaleMissPolicy::parse_on_miss(Some("continue")).unwrap(),
1231            PeekStaleMissPolicy::Continue
1232        );
1233    }
1234
1235    #[test]
1236    fn parse_on_miss_rejects_unknown_value_naming_the_step() {
1237        let err = PeekStaleMissPolicy::parse_on_miss(Some("explode")).unwrap_err();
1238        let msg = format!("{err}");
1239        assert!(msg.contains("cache_peek_stale"), "got: {msg}");
1240        assert!(msg.contains("explode"), "got: {msg}");
1241    }
1242
1243    /// Minimal no-op RuntimeObservability for tests that don't need OTel.
1244    #[derive(Clone)]
1245    struct NoopRt;
1246
1247    impl camel_component_api::HealthCheckRegistry for NoopRt {
1248        fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
1249    }
1250
1251    impl RuntimeObservability for NoopRt {
1252        fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
1253            Arc::new(NoOpMetrics)
1254        }
1255        fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
1256            Arc::new(NoOpHealthCheckRegistry)
1257        }
1258    }
1259
1260    fn noop_rt() -> Arc<dyn RuntimeObservability> {
1261        Arc::new(NoopRt)
1262    }
1263
1264    // ── Scripted on-miss sub-pipeline ──
1265
1266    #[derive(Clone)]
1267    enum ScriptedOutcome {
1268        Complete,
1269        Stop,
1270        Fail(CamelError),
1271    }
1272
1273    /// Test sub-pipeline: optionally replaces the body, then returns a
1274    /// scripted outcome. Records whether it was invoked.
1275    struct ScriptedOnMiss {
1276        body: Option<Body>,
1277        outcome: ScriptedOutcome,
1278        invoked: Arc<AtomicBool>,
1279    }
1280
1281    impl OutcomePipeline for ScriptedOnMiss {
1282        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
1283            // clone_box is required by the trait but unused by these tests.
1284            unreachable!("clone_box not used in cache_eip tests")
1285        }
1286
1287        fn run<'a>(
1288            &'a mut self,
1289            mut exchange: Exchange,
1290        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1291            self.invoked.store(true, Ordering::SeqCst);
1292            let body = self.body.take();
1293            let outcome = self.outcome.clone();
1294            Box::pin(async move {
1295                if let Some(b) = body {
1296                    exchange.input.body = b;
1297                }
1298                match outcome {
1299                    ScriptedOutcome::Complete => PipelineOutcome::Completed(exchange),
1300                    ScriptedOutcome::Stop => PipelineOutcome::Stopped(exchange),
1301                    ScriptedOutcome::Fail(e) => PipelineOutcome::Failed(e),
1302                }
1303            })
1304        }
1305    }
1306
1307    // ── Builders ──
1308
1309    fn fixed_key() -> MessageIdExpression {
1310        Arc::new(|_| Some("cache-key".to_string()))
1311    }
1312
1313    fn none_key() -> MessageIdExpression {
1314        Arc::new(|_| None)
1315    }
1316
1317    fn prefix_key() -> MessageIdExpression {
1318        Arc::new(|_| Some("ns:".to_string()))
1319    }
1320
1321    /// Build a CacheService whose on-miss sets `body` and returns `outcome`.
1322    fn build_service(
1323        repo: Arc<MockCacheRepository>,
1324        key_expr: MessageIdExpression,
1325        max_entry_bytes: usize,
1326        body: Option<Body>,
1327        outcome: ScriptedOutcome,
1328        ttl: Option<Duration>,
1329        rt: Arc<dyn RuntimeObservability>,
1330    ) -> (CacheService, Arc<AtomicBool>) {
1331        let invoked = Arc::new(AtomicBool::new(false));
1332        let on_miss = OutcomeSegment::new(Box::new(ScriptedOnMiss {
1333            body,
1334            outcome,
1335            invoked: invoked.clone(),
1336        }));
1337        let svc = CacheService::new(repo, key_expr, ttl, max_entry_bytes, on_miss, rt);
1338        (svc, invoked)
1339    }
1340
1341    fn exchange() -> Exchange {
1342        let mut ex = Exchange::new(Message::new(""));
1343        ex.input.set_header("ignored", Value::String("v".into()));
1344        ex
1345    }
1346
1347    fn stream_body(data: &'static [u8]) -> Body {
1348        let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from_static(data))];
1349        let s = stream::iter(chunks);
1350        Body::Stream(StreamBody {
1351            stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(s)))),
1352            metadata: StreamMetadata::default(),
1353        })
1354    }
1355
1356    fn stub_error(msg: &str) -> CamelError {
1357        CamelError::ProcessorError(msg.into())
1358    }
1359
1360    // ── Test 1: cache HIT short-circuits, on_miss NOT executed ──
1361
1362    #[tokio::test]
1363    async fn cache_hit_short_circuits_on_miss() {
1364        let repo = Arc::new(MockCacheRepository::new("mock"));
1365        repo.seed(
1366            "cache-key",
1367            CacheEntry {
1368                bytes: b"cached-payload".to_vec(),
1369                content_type: ContentType::Bytes,
1370                expires_at: None,
1371            },
1372        )
1373        .await;
1374        let (mut svc, on_miss_invoked) = build_service(
1375            repo,
1376            fixed_key(),
1377            1024,
1378            Some(Body::Bytes(Bytes::from_static(b"unreached"))),
1379            ScriptedOutcome::Complete,
1380            None,
1381            noop_rt(),
1382        );
1383
1384        let outcome = svc.run(exchange()).await;
1385
1386        let ex = match outcome {
1387            PipelineOutcome::Completed(ex) => ex,
1388            other => panic!("expected Completed, got {other:?}"),
1389        };
1390        assert_eq!(
1391            ex.input.body,
1392            Body::Bytes(Bytes::from_static(b"cached-payload"))
1393        );
1394        assert!(
1395            !on_miss_invoked.load(Ordering::SeqCst),
1396            "on_miss must NOT run on a cache HIT"
1397        );
1398    }
1399
1400    // ── Test 2: cache MISS runs on_miss, writes back, continues ──
1401
1402    #[tokio::test]
1403    async fn cache_miss_runs_on_miss_sets_continues() {
1404        let ttl = Duration::from_secs(30);
1405        let repo = Arc::new(MockCacheRepository::new("mock"));
1406        let (mut svc, on_miss_invoked) = build_service(
1407            repo.clone(),
1408            fixed_key(),
1409            1024,
1410            Some(Body::Bytes(Bytes::from_static(b"x"))),
1411            ScriptedOutcome::Complete,
1412            Some(ttl),
1413            noop_rt(),
1414        );
1415
1416        let outcome = svc.run(exchange()).await;
1417
1418        let ex = match outcome {
1419            PipelineOutcome::Completed(ex) => ex,
1420            other => panic!("expected Completed, got {other:?}"),
1421        };
1422        assert!(on_miss_invoked.load(Ordering::SeqCst));
1423        assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
1424        assert_eq!(repo.set_call_count(), 1, "set must be called once on miss");
1425        let stored = repo
1426            .stored_entry("cache-key")
1427            .await
1428            .expect("entry must be stored");
1429        assert_eq!(stored.bytes, b"x");
1430        assert_eq!(stored.content_type, ContentType::Bytes);
1431        assert_eq!(repo.last_set_ttl().await, Some(ttl));
1432    }
1433
1434    // ── Test 3: oversized materialized body skips write-back ──
1435
1436    #[tokio::test]
1437    async fn cache_miss_oversized_materialized_body_skips_writeback() {
1438        let repo = Arc::new(MockCacheRepository::new("mock"));
1439        // max_entry_bytes = 4; on_miss produces 9 bytes.
1440        let (mut svc, _invoked) = build_service(
1441            repo.clone(),
1442            fixed_key(),
1443            4,
1444            Some(Body::Bytes(Bytes::from_static(b"oversized"))),
1445            ScriptedOutcome::Complete,
1446            None,
1447            noop_rt(),
1448        );
1449
1450        let outcome = svc.run(exchange()).await;
1451
1452        let ex = match outcome {
1453            PipelineOutcome::Completed(ex) => ex,
1454            other => panic!("expected Completed, got {other:?}"),
1455        };
1456        // Body passes through unchanged.
1457        assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"oversized")));
1458        assert_eq!(
1459            repo.set_call_count(),
1460            0,
1461            "set must NOT be called for oversized body"
1462        );
1463        assert!(repo.stored_entry("cache-key").await.is_none());
1464    }
1465
1466    // ── Test 4: oversized Stream propagates StreamLimitExceeded ──
1467
1468    #[tokio::test]
1469    async fn cache_miss_oversized_stream_propagates_err() {
1470        let repo = Arc::new(MockCacheRepository::new("mock"));
1471        let (mut svc, _invoked) = build_service(
1472            repo.clone(),
1473            fixed_key(),
1474            4,
1475            Some(stream_body(b"way-too-big-stream")),
1476            ScriptedOutcome::Complete,
1477            None,
1478            noop_rt(),
1479        );
1480
1481        let outcome = svc.run(exchange()).await;
1482
1483        match outcome {
1484            PipelineOutcome::Failed(CamelError::StreamLimitExceeded(n)) => {
1485                assert_eq!(n, 4);
1486            }
1487            other => panic!("expected Failed(StreamLimitExceeded(4)), got {other:?}"),
1488        }
1489        assert_eq!(
1490            repo.set_call_count(),
1491            0,
1492            "set must NOT be called when stream exceeds limit"
1493        );
1494    }
1495
1496    // ── Test 5: on_miss Stopped propagates without write-back ──
1497
1498    #[tokio::test]
1499    async fn cache_on_miss_stopped_propagates_without_writeback() {
1500        let repo = Arc::new(MockCacheRepository::new("mock"));
1501        let (mut svc, _invoked) = build_service(
1502            repo.clone(),
1503            fixed_key(),
1504            1024,
1505            None,
1506            ScriptedOutcome::Stop,
1507            None,
1508            noop_rt(),
1509        );
1510
1511        let outcome = svc.run(exchange()).await;
1512
1513        assert!(
1514            matches!(outcome, PipelineOutcome::Stopped(_)),
1515            "Stopped from on_miss MUST propagate as Stopped"
1516        );
1517        assert_eq!(
1518            repo.set_call_count(),
1519            0,
1520            "set must NOT be called when on_miss Stops"
1521        );
1522    }
1523
1524    // ── Test 6: on_miss Err propagates without write-back ──
1525
1526    #[tokio::test]
1527    async fn cache_on_miss_err_propagates_without_writeback() {
1528        let repo = Arc::new(MockCacheRepository::new("mock"));
1529        let (mut svc, _invoked) = build_service(
1530            repo.clone(),
1531            fixed_key(),
1532            1024,
1533            None,
1534            ScriptedOutcome::Fail(stub_error("on-miss blew up")),
1535            None,
1536            noop_rt(),
1537        );
1538
1539        let outcome = svc.run(exchange()).await;
1540
1541        match outcome {
1542            PipelineOutcome::Failed(e) => {
1543                assert!(e.to_string().contains("on-miss blew up"), "got: {e}");
1544            }
1545            other => panic!("expected Failed, got {other:?}"),
1546        }
1547        assert_eq!(
1548            repo.set_call_count(),
1549            0,
1550            "set must NOT be called when on_miss fails"
1551        );
1552    }
1553
1554    // ── Test 7: repository get Err propagates ──
1555
1556    #[tokio::test]
1557    async fn cache_repository_get_err_propagates() {
1558        let repo = Arc::new(MockCacheRepository::new("mock"));
1559        repo.set_get_should_fail(true);
1560        let (mut svc, on_miss_invoked) = build_service(
1561            repo,
1562            fixed_key(),
1563            1024,
1564            Some(Body::Bytes(Bytes::from_static(b"x"))),
1565            ScriptedOutcome::Complete,
1566            None,
1567            noop_rt(),
1568        );
1569
1570        let outcome = svc.run(exchange()).await;
1571
1572        match outcome {
1573            PipelineOutcome::Failed(e) => {
1574                assert!(e.to_string().contains("synthetic get failure"), "got: {e}");
1575            }
1576            other => panic!("expected Failed, got {other:?}"),
1577        }
1578        assert!(
1579            !on_miss_invoked.load(Ordering::SeqCst),
1580            "on_miss must NOT run when get fails"
1581        );
1582    }
1583
1584    // ── Test 8: repository set Err propagates ──
1585
1586    #[tokio::test]
1587    async fn cache_repository_set_err_propagates() {
1588        let repo = Arc::new(MockCacheRepository::new("mock"));
1589        repo.set_set_should_fail(true);
1590        let (mut svc, _invoked) = build_service(
1591            repo.clone(),
1592            fixed_key(),
1593            1024,
1594            Some(Body::Bytes(Bytes::from_static(b"x"))),
1595            ScriptedOutcome::Complete,
1596            None,
1597            noop_rt(),
1598        );
1599
1600        let outcome = svc.run(exchange()).await;
1601
1602        match outcome {
1603            PipelineOutcome::Failed(e) => {
1604                assert!(e.to_string().contains("synthetic set failure"), "got: {e}");
1605            }
1606            other => panic!("expected Failed, got {other:?}"),
1607        }
1608        assert_eq!(repo.set_call_count(), 1, "set was attempted (and failed)");
1609    }
1610
1611    // ── Test 9: None key bypasses to on_miss, no set ──
1612
1613    #[tokio::test]
1614    async fn cache_none_key_bypasses_to_on_miss() {
1615        let repo = Arc::new(MockCacheRepository::new("mock"));
1616        let (mut svc, on_miss_invoked) = build_service(
1617            repo.clone(),
1618            none_key(),
1619            1024,
1620            Some(Body::Bytes(Bytes::from_static(b"x"))),
1621            ScriptedOutcome::Complete,
1622            None,
1623            noop_rt(),
1624        );
1625
1626        let outcome = svc.run(exchange()).await;
1627
1628        let ex = match outcome {
1629            PipelineOutcome::Completed(ex) => ex,
1630            other => panic!("expected Completed, got {other:?}"),
1631        };
1632        assert!(
1633            on_miss_invoked.load(Ordering::SeqCst),
1634            "on_miss MUST run when key is None"
1635        );
1636        assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"x")));
1637        assert_eq!(
1638            repo.set_call_count(),
1639            0,
1640            "set must NOT be called when key_expr returns None"
1641        );
1642    }
1643
1644    // ── Extra: HIT reconstruction for each ContentType ──
1645
1646    #[tokio::test]
1647    async fn cache_content_type_reconstruction() {
1648        async fn run_case(entry: CacheEntry, expected: Body) {
1649            let repo = Arc::new(MockCacheRepository::new("mock"));
1650            repo.seed("cache-key", entry).await;
1651            let (mut svc, on_miss_invoked) = build_service(
1652                repo,
1653                fixed_key(),
1654                1024,
1655                Some(Body::Bytes(Bytes::from_static(b"unreached"))),
1656                ScriptedOutcome::Complete,
1657                None,
1658                noop_rt(),
1659            );
1660            let outcome = svc.run(exchange()).await;
1661            let ex = match outcome {
1662                PipelineOutcome::Completed(ex) => ex,
1663                other => panic!("expected Completed, got {other:?}"),
1664            };
1665            assert_eq!(ex.input.body, expected);
1666            assert!(!on_miss_invoked.load(Ordering::SeqCst));
1667        }
1668
1669        run_case(
1670            CacheEntry {
1671                bytes: b"raw".to_vec(),
1672                content_type: ContentType::Bytes,
1673                expires_at: None,
1674            },
1675            Body::Bytes(Bytes::from_static(b"raw")),
1676        )
1677        .await;
1678        run_case(
1679            CacheEntry {
1680                bytes: b"hi".to_vec(),
1681                content_type: ContentType::Text,
1682                expires_at: None,
1683            },
1684            Body::Text("hi".into()),
1685        )
1686        .await;
1687        run_case(
1688            CacheEntry {
1689                bytes: br#"{"k":1}"#.to_vec(),
1690                content_type: ContentType::Json,
1691                expires_at: None,
1692            },
1693            Body::Json(serde_json::json!({"k": 1})),
1694        )
1695        .await;
1696        run_case(
1697            CacheEntry {
1698                bytes: b"<a/>".to_vec(),
1699                content_type: ContentType::Xml,
1700                expires_at: None,
1701            },
1702            Body::Xml("<a/>".into()),
1703        )
1704        .await;
1705    }
1706
1707    // ── Extra: Stream body write-back materializes into Body::Bytes ──
1708
1709    #[tokio::test]
1710    async fn cache_miss_stream_body_is_materialized_and_cached() {
1711        let repo = Arc::new(MockCacheRepository::new("mock"));
1712        let (mut svc, _invoked) = build_service(
1713            repo.clone(),
1714            fixed_key(),
1715            1024,
1716            Some(stream_body(b"chunky")),
1717            ScriptedOutcome::Complete,
1718            None,
1719            noop_rt(),
1720        );
1721
1722        let outcome = svc.run(exchange()).await;
1723
1724        let ex = match outcome {
1725            PipelineOutcome::Completed(ex) => ex,
1726            other => panic!("expected Completed, got {other:?}"),
1727        };
1728        // Stream is replaced by materialized Bytes.
1729        assert_eq!(ex.input.body, Body::Bytes(Bytes::from_static(b"chunky")));
1730        assert_eq!(repo.set_call_count(), 1);
1731        let stored = repo.stored_entry("cache-key").await.expect("stored");
1732        assert_eq!(stored.bytes, b"chunky");
1733        assert_eq!(stored.content_type, ContentType::Bytes);
1734    }
1735
1736    // ── CachePeekStaleService tests ──
1737
1738    #[tokio::test]
1739    async fn cache_peek_stale_serves_post_expiry_entry() {
1740        let repo = Arc::new(MockCacheRepository::new("mock"));
1741        repo.seed(
1742            "cache-key",
1743            CacheEntry {
1744                bytes: b"stale-payload".to_vec(),
1745                content_type: ContentType::Text,
1746                expires_at: None,
1747            },
1748        )
1749        .await;
1750        let mut svc =
1751            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1752
1753        let outcome = svc.run(exchange()).await;
1754
1755        let ex = match outcome {
1756            PipelineOutcome::Completed(ex) => ex,
1757            other => panic!("expected Completed, got {other:?}"),
1758        };
1759        assert_eq!(ex.input.body, Body::Text("stale-payload".into()));
1760    }
1761
1762    #[tokio::test]
1763    async fn cache_peek_stale_on_absence_stops_branch() {
1764        let repo = Arc::new(MockCacheRepository::new("mock"));
1765        let mut svc =
1766            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1767
1768        let outcome = svc.run(exchange()).await;
1769
1770        assert!(
1771            matches!(outcome, PipelineOutcome::Stopped(_)),
1772            "expected Stopped when no stale entry, got {outcome:?}"
1773        );
1774    }
1775
1776    #[tokio::test]
1777    async fn cache_peek_stale_none_key_stops() {
1778        let repo = Arc::new(MockCacheRepository::new("mock"));
1779        let mut svc =
1780            CachePeekStaleService::new(repo, none_key(), PeekStaleMissPolicy::Stop, noop_rt());
1781
1782        let outcome = svc.run(exchange()).await;
1783
1784        assert!(
1785            matches!(outcome, PipelineOutcome::Stopped(_)),
1786            "expected Stopped when key_expr returns None, got {outcome:?}"
1787        );
1788    }
1789
1790    #[tokio::test]
1791    async fn peek_stale_miss_stop_sets_properties_and_stops() {
1792        let repo = Arc::new(MockCacheRepository::new("mock"));
1793        let mut svc =
1794            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1795
1796        let outcome = svc.run(exchange()).await;
1797
1798        let ex = match outcome {
1799            PipelineOutcome::Stopped(ex) => ex,
1800            other => panic!("expected Stopped, got {other:?}"),
1801        };
1802        assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(false)));
1803        assert_eq!(
1804            ex.property(CAMEL_CACHE_PEEK_STALE),
1805            Some(&Value::Bool(false))
1806        );
1807    }
1808
1809    #[tokio::test]
1810    async fn peek_stale_miss_continue_completes_with_body_untouched() {
1811        let repo = Arc::new(MockCacheRepository::new("mock"));
1812        let mut svc =
1813            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Continue, noop_rt());
1814
1815        let mut ex = exchange();
1816        ex.input.body = Body::Text("orig".into());
1817
1818        let outcome = svc.run(ex).await;
1819
1820        let ex = match outcome {
1821            PipelineOutcome::Completed(ex) => ex,
1822            other => panic!("expected Completed, got {other:?}"),
1823        };
1824        assert_eq!(ex.input.body, Body::Text("orig".into()));
1825        assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(false)));
1826        assert_eq!(
1827            ex.property(CAMEL_CACHE_PEEK_STALE),
1828            Some(&Value::Bool(false))
1829        );
1830    }
1831
1832    #[tokio::test]
1833    async fn peek_stale_hit_sets_hit_and_stale_properties() {
1834        let repo = Arc::new(MockCacheRepository::new("mock"));
1835        repo.seed(
1836            "cache-key",
1837            CacheEntry {
1838                bytes: b"stale-payload".to_vec(),
1839                content_type: ContentType::Bytes,
1840                expires_at: Some(SystemTime::now() - Duration::from_millis(1)),
1841            },
1842        )
1843        .await;
1844        let mut svc =
1845            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1846
1847        let outcome = svc.run(exchange()).await;
1848
1849        let ex = match outcome {
1850            PipelineOutcome::Completed(ex) => ex,
1851            other => panic!("expected Completed, got {other:?}"),
1852        };
1853        assert_eq!(
1854            ex.input.body,
1855            Body::Bytes(Bytes::from_static(b"stale-payload"))
1856        );
1857        assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(true)));
1858        assert_eq!(
1859            ex.property(CAMEL_CACHE_PEEK_STALE),
1860            Some(&Value::Bool(true))
1861        );
1862    }
1863
1864    #[tokio::test]
1865    async fn peek_stale_hit_fresh_sets_stale_false() {
1866        let repo = Arc::new(MockCacheRepository::new("mock"));
1867        repo.seed(
1868            "cache-key",
1869            CacheEntry {
1870                bytes: b"fresh-payload".to_vec(),
1871                content_type: ContentType::Bytes,
1872                expires_at: Some(SystemTime::now() + Duration::from_secs(3600)),
1873            },
1874        )
1875        .await;
1876        let mut svc =
1877            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1878
1879        let outcome = svc.run(exchange()).await;
1880
1881        let ex = match outcome {
1882            PipelineOutcome::Completed(ex) => ex,
1883            other => panic!("expected Completed, got {other:?}"),
1884        };
1885        assert_eq!(ex.property(CAMEL_CACHE_PEEK_HIT), Some(&Value::Bool(true)));
1886        assert_eq!(
1887            ex.property(CAMEL_CACHE_PEEK_STALE),
1888            Some(&Value::Bool(false))
1889        );
1890    }
1891
1892    // --- Tracing capture helper for debug-log assertions ---
1893
1894    // ── log-capture harness ──────────────────────────────────────────────────
1895
1896    /// Records dispatched events from this module as `LEVEL field=value…`
1897    /// lines. `on_event` fires only for events actually dispatched to this
1898    /// layer: unlike fmt-writer capture it has no registration-time side
1899    /// effects and does not depend on the process-wide callsite interest
1900    /// cache, which parallel tests rebuild concurrently (bd rc-pna5).
1901    #[derive(Default)]
1902    struct EventRecorder {
1903        records: Arc<Mutex<Vec<String>>>,
1904    }
1905
1906    impl EventRecorder {
1907        /// Installs the recorder as this thread's default subscriber and
1908        /// returns the shared record list plus the dispatcher guard.
1909        fn install(self) -> (Arc<Mutex<Vec<String>>>, tracing::subscriber::DefaultGuard) {
1910            use tracing_subscriber::prelude::*;
1911            let records = Arc::clone(&self.records);
1912            let guard = tracing_subscriber::registry().with(self).set_default();
1913            (records, guard)
1914        }
1915    }
1916
1917    /// Formats each visited field as `name=debug-value`, preserving str
1918    /// quoting so `step="cache_peek_stale"`-style assertions keep working.
1919    struct FieldFmt<'a>(&'a mut String);
1920
1921    impl tracing::field::Visit for FieldFmt<'_> {
1922        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1923            use std::fmt::Write as _;
1924            let _ = write!(self.0, " {}={:?}", field.name(), value);
1925        }
1926        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
1927            self.record_debug(field, &value);
1928        }
1929    }
1930
1931    impl<C> tracing_subscriber::Layer<C> for EventRecorder
1932    where
1933        C: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
1934    {
1935        fn on_event(
1936            &self,
1937            event: &tracing::Event<'_>,
1938            _ctx: tracing_subscriber::layer::Context<'_, C>,
1939        ) {
1940            let meta = event.metadata();
1941            if meta.target() != "camel_processor::cache_eip" {
1942                return;
1943            }
1944            let mut line = format!("{} ", meta.level());
1945            event.record(&mut FieldFmt(&mut line));
1946            self.records.lock().unwrap().push(line); // allow-unwrap: test-only
1947        }
1948    }
1949
1950    #[tokio::test]
1951    async fn peek_stale_miss_stop_emits_debug_log() {
1952        let repo = Arc::new(MockCacheRepository::new("mock"));
1953        let mut svc =
1954            CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, noop_rt());
1955
1956        let (records, _guard) = EventRecorder::default().install();
1957        let outcome = svc.run(exchange()).await;
1958        drop(_guard);
1959
1960        assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
1961
1962        let captured = records.lock().unwrap().join("\n"); // allow-unwrap: test-only
1963        let miss_records: Vec<&str> = captured
1964            .lines()
1965            .filter(|l| l.contains("peek miss"))
1966            .collect();
1967        assert_eq!(
1968            miss_records.len(),
1969            1,
1970            "expected exactly one DEBUG record containing \"peek miss\"; got: {captured}"
1971        );
1972        assert!(
1973            miss_records[0].contains("DEBUG"),
1974            "expected DEBUG level record; got: {captured}"
1975        );
1976        assert!(
1977            miss_records[0].contains("repository=mock"),
1978            "expected repository field in record; got: {captured}"
1979        );
1980        assert!(
1981            miss_records[0].contains("step=\"cache_peek_stale\""),
1982            "expected step field in record; got: {captured}"
1983        );
1984    }
1985
1986    #[tokio::test]
1987    async fn peek_stale_key_none_stops_with_debug_log() {
1988        let repo = Arc::new(MockCacheRepository::new("mock"));
1989        let mut svc =
1990            CachePeekStaleService::new(repo, none_key(), PeekStaleMissPolicy::Stop, noop_rt());
1991
1992        let (records, _guard) = EventRecorder::default().install();
1993        let outcome = svc.run(exchange()).await;
1994        drop(_guard);
1995
1996        assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
1997
1998        let captured = records.lock().unwrap().join("\n"); // allow-unwrap: test-only
1999        let none_records: Vec<&str> = captured
2000            .lines()
2001            .filter(|l| l.contains("resolved to None"))
2002            .collect();
2003        assert_eq!(
2004            none_records.len(),
2005            1,
2006            "expected exactly one DEBUG record containing \"resolved to None\"; got: {captured}"
2007        );
2008        assert!(
2009            none_records[0].contains("DEBUG"),
2010            "expected DEBUG level record; got: {captured}"
2011        );
2012        assert!(
2013            none_records[0].contains("repository=mock"),
2014            "expected repository field in record; got: {captured}"
2015        );
2016        assert!(
2017            none_records[0].contains("step=\"cache_peek_stale\""),
2018            "expected step field in record; got: {captured}"
2019        );
2020    }
2021
2022    // ── CacheInvalidateService tests ──
2023
2024    #[tokio::test]
2025    async fn cache_invalidate_calls_repository_invalidate() {
2026        let repo = Arc::new(MockCacheRepository::new("mock"));
2027        repo.seed(
2028            "cache-key",
2029            CacheEntry {
2030                bytes: b"to-go".to_vec(),
2031                content_type: ContentType::Bytes,
2032                expires_at: None,
2033            },
2034        )
2035        .await;
2036        let mut svc = CacheInvalidateService::new(
2037            repo.clone(),
2038            CacheInvalidateTarget::Key(fixed_key()),
2039            noop_rt(),
2040        );
2041
2042        let outcome = svc.run(exchange()).await;
2043
2044        let ex = match outcome {
2045            PipelineOutcome::Completed(ex) => ex,
2046            other => panic!("expected Completed, got {other:?}"),
2047        };
2048        assert_eq!(
2049            ex.property(CAMEL_CACHE_INVALIDATED_COUNT),
2050            Some(&serde_json::Value::from(1u64)),
2051            "exact-key success must set CamelCacheInvalidatedCount = 1"
2052        );
2053        assert_eq!(
2054            repo.invalidate_call_count(),
2055            1,
2056            "invalidate must be called once"
2057        );
2058        assert_eq!(
2059            repo.last_invalidate_key().await,
2060            Some("cache-key".to_string()),
2061            "invalidate must be called with the correct key"
2062        );
2063        assert!(
2064            repo.stored_entry("cache-key").await.is_none(),
2065            "entry must be removed after invalidation"
2066        );
2067    }
2068
2069    #[tokio::test]
2070    async fn cache_invalidate_none_key_completes() {
2071        let repo = Arc::new(MockCacheRepository::new("mock"));
2072        let mut svc = CacheInvalidateService::new(
2073            repo.clone(),
2074            CacheInvalidateTarget::Key(none_key()),
2075            noop_rt(),
2076        );
2077
2078        let outcome = svc.run(exchange()).await;
2079
2080        let _ex = match outcome {
2081            PipelineOutcome::Completed(ex) => ex,
2082            other => panic!("expected Completed, got {other:?}"),
2083        };
2084        assert_eq!(
2085            repo.invalidate_call_count(),
2086            0,
2087            "invalidate must NOT be called when key_expr returns None"
2088        );
2089    }
2090
2091    // ── OTel metrics tests ──
2092
2093    /// Records every `record_counter` call for test assertions.
2094    type CounterRecording = Vec<(String, f64, Vec<(String, String)>)>;
2095
2096    #[derive(Clone)]
2097    struct RecordingMetricsCollector {
2098        counters: Arc<Mutex<CounterRecording>>,
2099    }
2100
2101    impl RecordingMetricsCollector {
2102        fn new() -> Self {
2103            Self {
2104                counters: Arc::new(Mutex::new(Vec::new())),
2105            }
2106        }
2107    }
2108
2109    impl camel_api::metrics::MetricsCollector for RecordingMetricsCollector {
2110        fn record_exchange_duration(&self, _route_id: &str, _duration: Duration) {}
2111        fn increment_errors(&self, _route_id: &str, _error_type: &str) {}
2112        fn increment_exchanges(&self, _route_id: &str) {}
2113        fn set_queue_depth(&self, _route_id: &str, _depth: usize) {}
2114        fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
2115        fn record_counter(&self, name: &str, value: f64, labels: &[(&str, &str)]) {
2116            self.counters.lock().unwrap().push((
2117                name.to_string(),
2118                value,
2119                labels
2120                    .iter()
2121                    .map(|(k, v)| (k.to_string(), v.to_string()))
2122                    .collect(),
2123            ));
2124        }
2125    }
2126
2127    #[derive(Clone)]
2128    struct TestOtelmRt {
2129        collector: Arc<RecordingMetricsCollector>,
2130    }
2131
2132    impl camel_component_api::health_registry::HealthCheckRegistry for TestOtelmRt {
2133        fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
2134    }
2135
2136    impl RuntimeObservability for TestOtelmRt {
2137        fn metrics(&self) -> Arc<dyn camel_api::metrics::MetricsCollector> {
2138            self.collector.clone()
2139        }
2140        fn health(&self) -> Arc<dyn camel_component_api::health_registry::HealthCheckRegistry> {
2141            Arc::new(NoOpHealthCheckRegistry)
2142        }
2143    }
2144
2145    #[tokio::test]
2146    async fn cache_step_hit_increments_otel_counter() {
2147        let repo = Arc::new(MockCacheRepository::new("mock"));
2148        repo.seed(
2149            "cache-key",
2150            CacheEntry {
2151                bytes: b"cached".to_vec(),
2152                content_type: ContentType::Bytes,
2153                expires_at: None,
2154            },
2155        )
2156        .await;
2157        let collector = RecordingMetricsCollector::new();
2158        let counters = collector.counters.clone();
2159        let rt = Arc::new(TestOtelmRt {
2160            collector: Arc::new(collector),
2161        });
2162        let (mut svc, _invoked) = build_service(
2163            repo,
2164            fixed_key(),
2165            1024,
2166            None,
2167            ScriptedOutcome::Complete,
2168            None,
2169            rt,
2170        );
2171
2172        let outcome = svc.run(exchange()).await;
2173        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2174
2175        let recorded = counters.lock().unwrap().clone();
2176        assert!(
2177            recorded.contains(&(
2178                "camel.cache.hits".to_string(),
2179                1.0,
2180                vec![("repository".to_string(), "mock".to_string())]
2181            )),
2182            "expected camel.cache.hits counter, got: {recorded:?}"
2183        );
2184    }
2185
2186    #[tokio::test]
2187    async fn cache_step_miss_increments_otel_counter() {
2188        let repo = Arc::new(MockCacheRepository::new("mock"));
2189        let collector = RecordingMetricsCollector::new();
2190        let counters = collector.counters.clone();
2191        let rt = Arc::new(TestOtelmRt {
2192            collector: Arc::new(collector),
2193        });
2194        let (mut svc, _invoked) = build_service(
2195            repo.clone(),
2196            fixed_key(),
2197            1024,
2198            Some(Body::Bytes(Bytes::from_static(b"x"))),
2199            ScriptedOutcome::Complete,
2200            None,
2201            rt,
2202        );
2203
2204        let outcome = svc.run(exchange()).await;
2205        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2206
2207        let recorded = counters.lock().unwrap().clone();
2208        assert!(
2209            recorded.contains(&(
2210                "camel.cache.misses".to_string(),
2211                1.0,
2212                vec![("repository".to_string(), "mock".to_string())]
2213            )),
2214            "expected camel.cache.misses counter, got: {recorded:?}"
2215        );
2216    }
2217
2218    // ── CacheClearService tests ──
2219
2220    #[tokio::test]
2221    async fn cache_clear_calls_repository_clear() {
2222        let repo = Arc::new(MockCacheRepository::new("mock"));
2223        repo.seed(
2224            "k",
2225            CacheEntry {
2226                bytes: b"v".to_vec(),
2227                content_type: ContentType::Bytes,
2228                expires_at: None,
2229            },
2230        )
2231        .await;
2232        let mut svc = CacheClearService::new(repo.clone());
2233
2234        let outcome = svc.run(exchange()).await;
2235
2236        match outcome {
2237            PipelineOutcome::Completed(_) => {}
2238            other => panic!("expected Completed, got {other:?}"),
2239        }
2240        assert_eq!(repo.clear_call_count(), 1, "clear must be called once");
2241        assert!(
2242            repo.stored_entry("k").await.is_none(),
2243            "entry must be removed after clear"
2244        );
2245    }
2246
2247    #[tokio::test]
2248    async fn cache_clear_err_propagates_failed() {
2249        let repo = Arc::new(MockCacheRepository::new("mock"));
2250        repo.set_should_fail_clear(true);
2251        let mut svc = CacheClearService::new(repo);
2252
2253        let outcome = svc.run(exchange()).await;
2254
2255        match outcome {
2256            PipelineOutcome::Failed(e) => {
2257                assert!(
2258                    e.to_string().contains("synthetic clear failure"),
2259                    "got: {e}"
2260                );
2261            }
2262            other => panic!("expected Failed, got {other:?}"),
2263        }
2264    }
2265
2266    // ── CacheStatsService tests ──
2267
2268    #[tokio::test]
2269    async fn cache_stats_sets_json_body() {
2270        let repo = Arc::new(MockCacheRepository::new("mock"));
2271        repo.set_stats(CacheStats {
2272            hits: 2,
2273            misses: 1,
2274            evictions: 0,
2275            entries: 3,
2276            peek_stale_served: 4,
2277            invalidations: 1,
2278            bytes: None,
2279        });
2280        let mut svc = CacheStatsService::new(repo);
2281
2282        let outcome = svc.run(exchange()).await;
2283
2284        let ex = match outcome {
2285            PipelineOutcome::Completed(ex) => ex,
2286            other => panic!("expected Completed, got {other:?}"),
2287        };
2288        let expected = serde_json::json!({
2289            "repository": "mock",
2290            "hits": 2,
2291            "misses": 1,
2292            "evictions": 0,
2293            "entries": 3,
2294            "peek_stale_served": 4,
2295            "invalidations": 1,
2296            "bytes": null
2297        });
2298        assert_eq!(ex.input.body, Body::Json(expected));
2299
2300        // Exact key-set assertion: the stats JSON snapshot contract is frozen
2301        // to the eight canonical keys (bd rc-22wj) — no extras, none missing.
2302        let Body::Json(v) = &ex.input.body else {
2303            panic!("expected Json body");
2304        };
2305        let keys: std::collections::BTreeSet<&str> = v
2306            .as_object()
2307            .expect("stats body must be a JSON object")
2308            .keys()
2309            .map(String::as_str)
2310            .collect();
2311        let expected_keys: std::collections::BTreeSet<&str> = [
2312            "repository",
2313            "hits",
2314            "misses",
2315            "evictions",
2316            "entries",
2317            "peek_stale_served",
2318            "invalidations",
2319            "bytes",
2320        ]
2321        .into_iter()
2322        .collect();
2323        assert_eq!(
2324            keys, expected_keys,
2325            "stats body must have exactly the eight canonical keys"
2326        );
2327    }
2328
2329    // ── Peek/invalidate OTel counter tests ──
2330
2331    #[tokio::test]
2332    async fn peek_stale_hit_emits_peek_served_counter() {
2333        let repo = Arc::new(MockCacheRepository::new("mock"));
2334        repo.seed(
2335            "cache-key",
2336            CacheEntry {
2337                bytes: b"cached".to_vec(),
2338                content_type: ContentType::Bytes,
2339                expires_at: None,
2340            },
2341        )
2342        .await;
2343        let collector = RecordingMetricsCollector::new();
2344        let counters = collector.counters.clone();
2345        let rt = Arc::new(TestOtelmRt {
2346            collector: Arc::new(collector),
2347        });
2348        let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, rt);
2349
2350        let outcome = svc.run(exchange()).await;
2351        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2352
2353        let recorded = counters.lock().unwrap().clone();
2354        assert!(
2355            recorded.contains(&(
2356                "camel.cache.peek_stale_served".to_string(),
2357                1.0,
2358                vec![("repository".to_string(), "mock".to_string())]
2359            )),
2360            "expected camel.cache.peek_stale_served counter, got: {recorded:?}"
2361        );
2362    }
2363
2364    #[tokio::test]
2365    async fn invalidate_emits_invalidations_counter() {
2366        let repo = Arc::new(MockCacheRepository::new("mock"));
2367        repo.seed(
2368            "cache-key",
2369            CacheEntry {
2370                bytes: b"to-go".to_vec(),
2371                content_type: ContentType::Bytes,
2372                expires_at: None,
2373            },
2374        )
2375        .await;
2376        let collector = RecordingMetricsCollector::new();
2377        let counters = collector.counters.clone();
2378        let rt = Arc::new(TestOtelmRt {
2379            collector: Arc::new(collector),
2380        });
2381        let mut svc =
2382            CacheInvalidateService::new(repo, CacheInvalidateTarget::Key(fixed_key()), rt);
2383
2384        let outcome = svc.run(exchange()).await;
2385        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
2386
2387        let recorded = counters.lock().unwrap().clone();
2388        assert!(
2389            recorded.contains(&(
2390                "camel.cache.invalidations".to_string(),
2391                1.0,
2392                vec![("repository".to_string(), "mock".to_string())]
2393            )),
2394            "expected camel.cache.invalidations counter, got: {recorded:?}"
2395        );
2396    }
2397
2398    #[tokio::test]
2399    async fn peek_stale_miss_emits_no_peek_served_counter() {
2400        // Absent key -> MISS path must NOT emit camel.cache.peek_stale_served.
2401        let repo = Arc::new(MockCacheRepository::new("mock"));
2402        let collector = RecordingMetricsCollector::new();
2403        let counters = collector.counters.clone();
2404        let rt = Arc::new(TestOtelmRt {
2405            collector: Arc::new(collector),
2406        });
2407        let mut svc = CachePeekStaleService::new(repo, fixed_key(), PeekStaleMissPolicy::Stop, rt);
2408
2409        let outcome = svc.run(exchange()).await;
2410        assert!(matches!(outcome, PipelineOutcome::Stopped(_)));
2411
2412        let recorded = counters.lock().unwrap().clone();
2413        assert!(
2414            !recorded
2415                .iter()
2416                .any(|(name, _, _)| name == "camel.cache.peek_stale_served"),
2417            "expected zero camel.cache.peek_stale_served counters, got: {recorded:?}"
2418        );
2419    }
2420
2421    #[tokio::test]
2422    async fn invalidate_err_emits_no_invalidations_counter() {
2423        // Failing invalidate -> Failed outcome must NOT emit camel.cache.invalidations.
2424        let repo = Arc::new(MockCacheRepository::new("mock"));
2425        repo.seed(
2426            "cache-key",
2427            CacheEntry {
2428                bytes: b"to-go".to_vec(),
2429                content_type: ContentType::Bytes,
2430                expires_at: None,
2431            },
2432        )
2433        .await;
2434        repo.set_should_fail_invalidate(true);
2435        let collector = RecordingMetricsCollector::new();
2436        let counters = collector.counters.clone();
2437        let rt = Arc::new(TestOtelmRt {
2438            collector: Arc::new(collector),
2439        });
2440        let mut svc =
2441            CacheInvalidateService::new(repo, CacheInvalidateTarget::Key(fixed_key()), rt);
2442
2443        let outcome = svc.run(exchange()).await;
2444        assert!(matches!(outcome, PipelineOutcome::Failed(_)));
2445
2446        let recorded = counters.lock().unwrap().clone();
2447        assert!(
2448            !recorded
2449                .iter()
2450                .any(|(name, _, _)| name == "camel.cache.invalidations"),
2451            "expected zero camel.cache.invalidations counters, got: {recorded:?}"
2452        );
2453    }
2454
2455    // ── CacheInvalidateService prefix tests ──
2456
2457    #[tokio::test]
2458    async fn cache_invalidate_prefix_removes_namespace_sets_count() {
2459        let repo = Arc::new(MockCacheRepository::new("mock"));
2460        for key in ["ns:one", "ns:two", "other:x"] {
2461            repo.seed(
2462                key,
2463                CacheEntry {
2464                    bytes: key.as_bytes().to_vec(),
2465                    content_type: ContentType::Bytes,
2466                    expires_at: None,
2467                },
2468            )
2469            .await;
2470        }
2471
2472        let collector = RecordingMetricsCollector::new();
2473        let counters = collector.counters.clone();
2474        let rt = Arc::new(TestOtelmRt {
2475            collector: Arc::new(collector),
2476        });
2477        let mut svc = CacheInvalidateService::new(
2478            repo.clone(),
2479            CacheInvalidateTarget::Prefix(prefix_key()),
2480            rt,
2481        );
2482
2483        let outcome = svc.run(exchange()).await;
2484
2485        let ex = match outcome {
2486            PipelineOutcome::Completed(ex) => ex,
2487            other => panic!("expected Completed, got {other:?}"),
2488        };
2489        assert_eq!(
2490            ex.property(CAMEL_CACHE_INVALIDATED_COUNT),
2491            Some(&serde_json::Value::from(2u64)),
2492            "prefix purge must report the removed count"
2493        );
2494        assert!(
2495            repo.stored_entry("ns:one").await.is_none(),
2496            "ns:one must be removed"
2497        );
2498        assert!(
2499            repo.stored_entry("ns:two").await.is_none(),
2500            "ns:two must be removed"
2501        );
2502        assert!(
2503            repo.stored_entry("other:x").await.is_some(),
2504            "other:x must be preserved"
2505        );
2506
2507        let recorded = counters.lock().unwrap().clone();
2508        assert!(
2509            recorded.contains(&(
2510                "camel.cache.invalidations".to_string(),
2511                1.0,
2512                vec![("repository".to_string(), "mock".to_string())]
2513            )),
2514            "expected one camel.cache.invalidations counter, got: {recorded:?}"
2515        );
2516    }
2517
2518    #[tokio::test]
2519    async fn cache_invalidate_prefix_none_expr_completes() {
2520        let repo = Arc::new(MockCacheRepository::new("mock"));
2521        let mut svc = CacheInvalidateService::new(
2522            repo.clone(),
2523            CacheInvalidateTarget::Prefix(none_key()),
2524            noop_rt(),
2525        );
2526
2527        let outcome = svc.run(exchange()).await;
2528
2529        let ex = match outcome {
2530            PipelineOutcome::Completed(ex) => ex,
2531            other => panic!("expected Completed, got {other:?}"),
2532        };
2533        assert_eq!(
2534            repo.invalidate_call_count(),
2535            0,
2536            "no invalidate calls when prefix expr resolves to None"
2537        );
2538        assert!(
2539            ex.property(CAMEL_CACHE_INVALIDATED_COUNT).is_none(),
2540            "no count property when prefix expr resolves to None"
2541        );
2542    }
2543
2544    #[tokio::test]
2545    async fn cache_invalidate_prefix_unsupported_fails_closed() {
2546        let repo = Arc::new(MockCacheRepository::new("mock"));
2547        repo.set_prefix_unsupported(true);
2548        let mut svc = CacheInvalidateService::new(
2549            repo,
2550            CacheInvalidateTarget::Prefix(prefix_key()),
2551            noop_rt(),
2552        );
2553
2554        let outcome = svc.run(exchange()).await;
2555
2556        match outcome {
2557            PipelineOutcome::Failed(e) => {
2558                let msg = format!("{e}");
2559                assert!(
2560                    msg.contains("mock"),
2561                    "error must name the backend, got: {msg}"
2562                );
2563            }
2564            other => panic!("expected Failed, got {other:?}"),
2565        }
2566    }
2567
2568    // ── Task 2.4: singleflight miss coalescing ──
2569
2570    use std::sync::atomic::AtomicUsize;
2571    use tokio::sync::Notify;
2572
2573    /// Terminal behavior of the gated on-miss sub-pipeline.
2574    #[derive(Clone)]
2575    enum GatedOutcome {
2576        Complete(Body),
2577        Fail(CamelError),
2578        Stop,
2579    }
2580
2581    /// Test on-miss sub-pipeline with two Notify gates: signals
2582    /// `leader_entered` as its FIRST action, parks on `release.notified()`,
2583    /// then bumps the invocation counter and returns the scripted outcome.
2584    /// Both gates are optional so the same struct serves ungated tests.
2585    #[derive(Clone)]
2586    struct GatedOnMiss {
2587        leader_entered: Option<Arc<Notify>>,
2588        release: Option<Arc<Notify>>,
2589        invocations: Arc<AtomicUsize>,
2590        outcome: GatedOutcome,
2591    }
2592
2593    impl OutcomePipeline for GatedOnMiss {
2594        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
2595            Box::new(self.clone())
2596        }
2597
2598        fn run<'a>(
2599            &'a mut self,
2600            mut exchange: Exchange,
2601        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
2602            let leader_entered = self.leader_entered.clone();
2603            let release = self.release.clone();
2604            let invocations = Arc::clone(&self.invocations);
2605            let outcome = self.outcome.clone();
2606            Box::pin(async move {
2607                // FIRST action: the leader is inside on_miss.
2608                if let Some(entered) = leader_entered.as_ref() {
2609                    entered.notify_one();
2610                }
2611                // Park until the test releases the wave.
2612                if let Some(release) = release.as_ref() {
2613                    release.notified().await;
2614                }
2615                invocations.fetch_add(1, Ordering::SeqCst);
2616                match outcome {
2617                    GatedOutcome::Complete(body) => {
2618                        exchange.input.body = body;
2619                        PipelineOutcome::Completed(exchange)
2620                    }
2621                    GatedOutcome::Fail(e) => PipelineOutcome::Failed(e),
2622                    GatedOutcome::Stop => PipelineOutcome::Stopped(exchange),
2623                }
2624            })
2625        }
2626    }
2627
2628    /// Build a coalescing CacheService around a `GatedOnMiss`.
2629    fn build_gated_service(
2630        repo: Arc<MockCacheRepository>,
2631        outcome: GatedOutcome,
2632        leader_entered: Option<Arc<Notify>>,
2633        release: Option<Arc<Notify>>,
2634    ) -> (CacheService, Arc<AtomicUsize>) {
2635        let invocations = Arc::new(AtomicUsize::new(0));
2636        let on_miss = OutcomeSegment::new(Box::new(GatedOnMiss {
2637            leader_entered,
2638            release,
2639            invocations: Arc::clone(&invocations),
2640            outcome,
2641        }));
2642        let svc = CacheService::new(repo, fixed_key(), None, 1024, on_miss, noop_rt())
2643            .with_coalesce(true);
2644        (svc, invocations)
2645    }
2646
2647    fn exchange_with_body(text: &str) -> Exchange {
2648        Exchange::new(Message::new(text))
2649    }
2650
2651    #[tokio::test]
2652    async fn coalesce_three_concurrent_misses_fetch_once() {
2653        let repo = Arc::new(MockCacheRepository::new("mock"));
2654        let leader_entered = Arc::new(Notify::new());
2655        let release = Arc::new(Notify::new());
2656        let (svc, invocations) = build_gated_service(
2657            repo.clone(),
2658            GatedOutcome::Complete(Body::Text("fetched".into())),
2659            Some(Arc::clone(&leader_entered)),
2660            Some(Arc::clone(&release)),
2661        );
2662
2663        // Register on leader_entered BEFORE spawning the leader so its
2664        // notify_one cannot be missed (enable-before-check).
2665        let entered = leader_entered.notified();
2666        tokio::pin!(entered);
2667        entered.as_mut().enable();
2668
2669        let mut leader_svc = svc.clone();
2670        let leader = tokio::spawn(async move { leader_svc.run(exchange()).await });
2671        entered.await; // deterministic proof: the leader is inside on_miss.
2672
2673        let mut w1_svc = svc.clone();
2674        let mut w1 = tokio::spawn(async move { w1_svc.run(exchange()).await });
2675        let mut w2_svc = svc.clone();
2676        let mut w2 = tokio::spawn(async move { w2_svc.run(exchange()).await });
2677
2678        // Both waiters park (registered on the wave, not running on_miss).
2679        for waiter in [&mut w1, &mut w2] {
2680            if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), waiter).await {
2681                panic!("waiter resolved before release: {done:?}")
2682            }
2683        }
2684
2685        release.notify_waiters();
2686
2687        let leader_ex = match leader.await.expect("leader task join") {
2688            PipelineOutcome::Completed(ex) => ex,
2689            other => panic!("expected leader Completed, got {other:?}"),
2690        };
2691        let w1_ex = match w1.await.expect("waiter 1 task join") {
2692            PipelineOutcome::Completed(ex) => ex,
2693            other => panic!("expected waiter 1 Completed, got {other:?}"),
2694        };
2695        let w2_ex = match w2.await.expect("waiter 2 task join") {
2696            PipelineOutcome::Completed(ex) => ex,
2697            other => panic!("expected waiter 2 Completed, got {other:?}"),
2698        };
2699        assert_eq!(leader_ex.input.body, Body::Text("fetched".into()));
2700        assert_eq!(w1_ex.input.body, Body::Text("fetched".into()));
2701        assert_eq!(w2_ex.input.body, Body::Text("fetched".into()));
2702        assert_eq!(invocations.load(Ordering::SeqCst), 1, "on_miss ran once");
2703        assert_eq!(repo.set_call_count(), 1, "single write-back set");
2704    }
2705
2706    #[tokio::test]
2707    async fn coalesce_leader_failure_fails_waiters_once() {
2708        let repo = Arc::new(MockCacheRepository::new("mock"));
2709        let leader_entered = Arc::new(Notify::new());
2710        let release = Arc::new(Notify::new());
2711        let (svc, invocations) = build_gated_service(
2712            repo.clone(),
2713            GatedOutcome::Fail(stub_error("coalesce-boom")),
2714            Some(Arc::clone(&leader_entered)),
2715            Some(Arc::clone(&release)),
2716        );
2717
2718        let entered = leader_entered.notified();
2719        tokio::pin!(entered);
2720        entered.as_mut().enable();
2721
2722        let mut leader_svc = svc.clone();
2723        let leader = tokio::spawn(async move { leader_svc.run(exchange()).await });
2724        entered.await;
2725
2726        let mut w1_svc = svc.clone();
2727        let mut w1 = tokio::spawn(async move { w1_svc.run(exchange()).await });
2728        let mut w2_svc = svc.clone();
2729        let mut w2 = tokio::spawn(async move { w2_svc.run(exchange()).await });
2730
2731        for waiter in [&mut w1, &mut w2] {
2732            if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), waiter).await {
2733                panic!("waiter resolved before release: {done:?}")
2734            }
2735        }
2736
2737        release.notify_waiters();
2738
2739        let leader_err = match leader.await.expect("leader task join") {
2740            PipelineOutcome::Failed(e) => e,
2741            other => panic!("expected leader Failed, got {other:?}"),
2742        };
2743        let w1_err = match w1.await.expect("waiter 1 task join") {
2744            PipelineOutcome::Failed(e) => e,
2745            other => panic!("expected waiter 1 Failed, got {other:?}"),
2746        };
2747        let w2_err = match w2.await.expect("waiter 2 task join") {
2748            PipelineOutcome::Failed(e) => e,
2749            other => panic!("expected waiter 2 Failed, got {other:?}"),
2750        };
2751        assert_eq!(format!("{leader_err}"), format!("{w1_err}"));
2752        assert_eq!(format!("{leader_err}"), format!("{w2_err}"));
2753        assert_eq!(invocations.load(Ordering::SeqCst), 1, "on_miss ran once");
2754        assert_eq!(repo.set_call_count(), 0, "no write-back on failure");
2755    }
2756
2757    #[tokio::test]
2758    async fn coalesce_leader_stopped_stops_waiters() {
2759        let repo = Arc::new(MockCacheRepository::new("mock"));
2760        let leader_entered = Arc::new(Notify::new());
2761        let release = Arc::new(Notify::new());
2762        let (svc, invocations) = build_gated_service(
2763            repo.clone(),
2764            GatedOutcome::Stop,
2765            Some(Arc::clone(&leader_entered)),
2766            Some(Arc::clone(&release)),
2767        );
2768
2769        let entered = leader_entered.notified();
2770        tokio::pin!(entered);
2771        entered.as_mut().enable();
2772
2773        let mut leader_svc = svc.clone();
2774        let leader =
2775            tokio::spawn(async move { leader_svc.run(exchange_with_body("leader-orig")).await });
2776        entered.await;
2777
2778        let mut w1_svc = svc.clone();
2779        let mut w1 =
2780            tokio::spawn(async move { w1_svc.run(exchange_with_body("waiter-orig")).await });
2781
2782        if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), &mut w1).await {
2783            panic!("waiter resolved before release: {done:?}")
2784        }
2785
2786        release.notify_waiters();
2787
2788        let leader_ex = match leader.await.expect("leader task join") {
2789            PipelineOutcome::Stopped(ex) => ex,
2790            other => panic!("expected leader Stopped, got {other:?}"),
2791        };
2792        let waiter_ex = match w1.await.expect("waiter task join") {
2793            PipelineOutcome::Stopped(ex) => ex,
2794            other => panic!("expected waiter Stopped, got {other:?}"),
2795        };
2796        assert_eq!(
2797            leader_ex.input.body,
2798            Body::Text("leader-orig".into()),
2799            "leader stopped with its own exchange"
2800        );
2801        assert_eq!(
2802            waiter_ex.input.body,
2803            Body::Text("waiter-orig".into()),
2804            "waiter stopped with its own exchange, body untouched"
2805        );
2806        assert_eq!(invocations.load(Ordering::SeqCst), 1, "on_miss ran once");
2807        assert_eq!(repo.set_call_count(), 0, "no write-back on stop");
2808    }
2809
2810    #[tokio::test]
2811    async fn coalesce_leader_dropped_does_not_strand_waiters() {
2812        let repo = Arc::new(MockCacheRepository::new("mock"));
2813        let leader_entered = Arc::new(Notify::new());
2814        let release = Arc::new(Notify::new());
2815        let (svc, _invocations) = build_gated_service(
2816            repo,
2817            GatedOutcome::Complete(Body::Text("fetched".into())),
2818            Some(Arc::clone(&leader_entered)),
2819            Some(Arc::clone(&release)),
2820        );
2821
2822        let entered = leader_entered.notified();
2823        tokio::pin!(entered);
2824        entered.as_mut().enable();
2825
2826        let mut leader_svc = svc.clone();
2827        let leader = tokio::spawn(async move { leader_svc.run(exchange()).await });
2828        entered.await;
2829
2830        let mut w1_svc = svc.clone();
2831        let mut w1 = tokio::spawn(async move { w1_svc.run(exchange()).await });
2832
2833        if let Ok(done) = tokio::time::timeout(Duration::from_millis(50), &mut w1).await {
2834            panic!("waiter resolved before leader abort: {done:?}")
2835        }
2836
2837        // Drop the leader future mid-flight: the cancellation guard must
2838        // publish a Failed terminal and retire the wave's map entry.
2839        leader.abort();
2840
2841        let joined = tokio::time::timeout(Duration::from_secs(1), w1)
2842            .await
2843            .expect("waiter completes within 1s after leader drop")
2844            .expect("waiter task join");
2845        match joined {
2846            PipelineOutcome::Failed(e) => {
2847                let msg = format!("{e}");
2848                assert!(
2849                    msg.contains("cancelled"),
2850                    "expected cancellation terminal, got: {msg}"
2851                );
2852            }
2853            other => panic!("expected waiter Failed, got {other:?}"),
2854        }
2855        assert!(
2856            svc.inflight.lock().unwrap().is_empty(),
2857            "in-flight map must not retain the aborted wave's entry"
2858        );
2859    }
2860
2861    #[tokio::test]
2862    async fn no_coalesce_runs_per_exchange() {
2863        let repo = Arc::new(MockCacheRepository::new("mock"));
2864        let invocations = Arc::new(AtomicUsize::new(0));
2865        let on_miss = OutcomeSegment::new(Box::new(GatedOnMiss {
2866            leader_entered: None,
2867            release: None,
2868            invocations: Arc::clone(&invocations),
2869            outcome: GatedOutcome::Complete(Body::Text("per-exchange".into())),
2870        }));
2871        // Default construction (no with_coalesce): per-exchange execution.
2872        let svc = CacheService::new(repo.clone(), fixed_key(), None, 1024, on_miss, noop_rt());
2873
2874        let mut a = svc.clone();
2875        let mut b = svc.clone();
2876        let mut c = svc.clone();
2877        let (ra, rb, rc) = tokio::join!(a.run(exchange()), b.run(exchange()), c.run(exchange()));
2878
2879        for outcome in [ra, rb, rc] {
2880            match outcome {
2881                PipelineOutcome::Completed(ex) => {
2882                    assert_eq!(ex.input.body, Body::Text("per-exchange".into()));
2883                }
2884                other => panic!("expected Completed, got {other:?}"),
2885            }
2886        }
2887        assert_eq!(
2888            invocations.load(Ordering::SeqCst),
2889            3,
2890            "on_miss ran per exchange"
2891        );
2892        assert_eq!(repo.set_call_count(), 3, "set called per exchange");
2893    }
2894}