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