Skip to main content

autumn_web/cache/
mod.rs

1//! Caching infrastructure for the Autumn framework.
2//!
3//! This module provides:
4//!
5//! - [`Cache`] — a trait abstracting over cache backends (moka by default,
6//!   swap in Redis, memcached, etc.)
7//! - [`MokaCache`] — the default, lock-free, in-process cache powered by
8//!   [moka](https://docs.rs/moka) (behind the `cache-moka` feature)
9//! - [`CacheResponseLayer`] — a Tower middleware that caches HTTP GET
10//!   responses, usable via `#[intercept(CacheResponseLayer::new(...))]`
11//! - [`CacheableResult`] — helper trait used by `#[cached(result)]` to
12//!   only cache `Ok` values
13//!
14//! The `#[cached]` proc macro generates a per-function static `MokaCache`
15//! for function-level memoization. The `CacheResponseLayer` operates at
16//! the HTTP level using a shared `Arc<dyn Cache>`.
17//!
18//! # Swapping backends
19//!
20//! Implement the [`Cache`] trait for your backend:
21//!
22//! ```rust,ignore
23//! use autumn_web::cache::Cache;
24//!
25//! #[derive(Clone)]
26//! struct RedisCache { /* ... */ }
27//!
28//! impl Cache for RedisCache {
29//!     fn get_value(&self, key: &str) -> Option<Box<dyn std::any::Any + Send + Sync>> { /* ... */ }
30//!     fn insert_value(&self, key: &str, value: Box<dyn std::any::Any + Send + Sync>) { /* ... */ }
31//!     fn invalidate(&self, key: &str) { /* ... */ }
32//!     fn clear(&self) { /* ... */ }
33//! }
34//! ```
35
36pub mod coherence;
37#[cfg(feature = "maud")]
38mod fragment;
39mod layer;
40#[cfg(feature = "cache-moka")]
41mod moka_impl;
42mod read_through;
43
44#[cfg(feature = "maud")]
45pub use fragment::{
46    cache_fragment, cache_fragment_global, cache_fragment_global_in, cache_fragment_in,
47};
48pub use layer::{CacheResponseLayer, CacheResponseService};
49#[cfg(feature = "cache-moka")]
50pub use moka_impl::MokaCache;
51pub use read_through::{
52    CacheFillError, GetOrComputeOptions, ReadThroughMetrics, ReadThroughMetricsSnapshot,
53    get_or_compute, get_or_compute_with, jittered_ttl, read_through_metrics,
54};
55
56use std::any::Any;
57use std::hash::{DefaultHasher, Hash, Hasher};
58use std::sync::{Arc, RwLock};
59use std::time::Duration;
60
61// ── Global cache registry ────────────────────────────────────────────
62
63/// Process-level shared cache backend.
64///
65/// Set once at startup by [`set_global_cache`]; read by every
66/// `#[cached]`-annotated function to decide which store to use.
67static GLOBAL_CACHE: RwLock<Option<Arc<dyn Cache>>> = RwLock::new(None);
68
69/// Register (or replace) the process-level shared cache.
70///
71/// Called automatically by [`crate::app::AppBuilder`] when
72/// `.with_cache_backend(...)` has been used. Also called by
73/// [`crate::state::AppState::set_cache`] when a plugin installs a backend
74/// during the startup-hook phase.
75///
76/// With the `reporting` feature the backend is wrapped once, so a failure
77/// capsule records its removals and a replay never reaches it. Thus
78/// [`global_cache`] returns the wrapper, not the `Arc` given here.
79///
80/// # Panics
81///
82/// Panics if the internal `RwLock` is poisoned.
83pub fn set_global_cache(cache: Arc<dyn Cache>) {
84    *GLOBAL_CACHE.write().expect("global cache lock poisoned") = Some(with_capsule_seam(cache));
85}
86
87/// Return a clone of the process-level shared cache, if one is registered.
88///
89/// `None` means no global backend has been set and `#[cached]` functions
90/// fall back to their per-function Moka stores.
91///
92/// # Panics
93///
94/// Panics if the internal `RwLock` is poisoned.
95#[must_use]
96pub fn global_cache() -> Option<Arc<dyn Cache>> {
97    GLOBAL_CACHE
98        .read()
99        .expect("global cache lock poisoned")
100        .clone()
101}
102
103/// Set the global cache for `autumn replay`, before the state initializers
104/// run (#2351).
105///
106/// Replay builds no cache backend. When the app builder has one
107/// (`with_cache_backend`), production installed it before the initializers,
108/// so replay installs the capsule seam over a backend that stores nothing, in
109/// the global cache and the app state. A cache call then takes the path it
110/// took in production, and the tape answers it. Otherwise the global cache is
111/// cleared, as the build clears it in production.
112///
113/// Returns the cache for the app state.
114#[cfg(feature = "reporting")]
115pub(crate) fn install_replay_cache(builder_cache: bool) -> Option<Arc<dyn Cache>> {
116    if !builder_cache {
117        clear_global_cache();
118        return None;
119    }
120    // Wrapped once, so the global and the state hold the same `Arc`.
121    let cache = with_capsule_seam(Arc::new(ReplayBackend));
122    set_global_cache(Arc::clone(&cache));
123    Some(cache)
124}
125
126/// After the state initializers: install the replay seam for a cache the
127/// capsule recorded and nothing has installed yet. It came from a startup
128/// hook (`RedisCachePlugin`), which replay does not run. Production's
129/// initializers ran before that hook, so they did not see it either.
130#[cfg(feature = "reporting")]
131pub(crate) fn install_late_replay_cache(
132    state: &crate::state::AppState,
133    recorded: &crate::capsule::CapsuleEffects,
134) {
135    if recorded.state_cache && state.cache().is_none() {
136        state.set_cache(Arc::new(ReplayBackend));
137    }
138}
139
140/// A replay's answer to a coherence namespace removal (#2351).
141///
142/// The tape answers it, as it does for the installed cache, and no store is
143/// reached: a registered store is not on the seam. During `autumn replay`
144/// with no tape, nothing is reached either. `None` outside a replay.
145#[cfg(feature = "reporting")]
146pub(crate) fn replayed_namespace_removal(namespace: &str) -> Option<bool> {
147    if let Some(tape) = crate::capsule::effects::current_tape() {
148        return Some(tape.cache_invalidate_namespace(namespace).is_ok());
149    }
150    replay_blocked().then_some(true)
151}
152
153/// No capsule support compiled in: never a replay.
154#[cfg(not(feature = "reporting"))]
155pub(crate) const fn replayed_namespace_removal(_namespace: &str) -> Option<bool> {
156    None
157}
158
159/// A coherence namespace removal's place on the capsule seam (#2351).
160///
161/// Coherence clears the registered stores, which are not on the seam, and
162/// asks the installed cache. It returns `false` when either part fails. The
163/// capsule records that combined answer once, as a replay returns it, so the
164/// installed cache's own record of its part is suppressed: call it through
165/// [`call_installed`](Self::call_installed).
166#[cfg(feature = "reporting")]
167pub(crate) struct NamespaceRemoval(Option<(Arc<crate::capsule::CaptureScope>, usize)>);
168
169#[cfg(feature = "reporting")]
170impl NamespaceRemoval {
171    /// Take the removal's slot before any store is reached.
172    pub(crate) fn reserve() -> Self {
173        Self(
174            crate::capsule::current_scope()
175                .and_then(|scope| scope.reserve_cache().map(|index| (scope, index))),
176        )
177    }
178
179    /// Call the installed cache without it recording the removal. A future
180    /// it returns has already read the flag.
181    pub(crate) fn call_installed<R>(call: impl FnOnce() -> R) -> R {
182        let outer = COHERENCE_REMOVAL.with(|flag| flag.replace(true));
183        let restore = RestoreFlag(outer);
184        let result = call();
185        drop(restore);
186        result
187    }
188
189    /// Record the combined answer: `failure` names the part that failed.
190    pub(crate) fn record(self, namespace: &str, failure: Option<&str>) {
191        if let Some((scope, index)) = self.0 {
192            scope.fill_cache(
193                index,
194                crate::capsule::CacheEffect::InvalidateNamespace {
195                    namespace: namespace.to_owned(),
196                    error: failure.map(|reason| crate::capsule::CacheInvalidationError {
197                        attempts: 1,
198                        reason: reason.to_owned(),
199                    }),
200                },
201            );
202        }
203    }
204}
205
206// Set while coherence calls the installed cache for a removal it records.
207#[cfg(feature = "reporting")]
208thread_local! {
209    static COHERENCE_REMOVAL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
210}
211
212/// Restores [`COHERENCE_REMOVAL`], also when the backend panics.
213#[cfg(feature = "reporting")]
214struct RestoreFlag(bool);
215
216#[cfg(feature = "reporting")]
217impl Drop for RestoreFlag {
218    fn drop(&mut self) {
219        let outer = self.0;
220        COHERENCE_REMOVAL.with(|flag| flag.set(outer));
221    }
222}
223
224/// No capsule support compiled in: nothing to record.
225#[cfg(not(feature = "reporting"))]
226pub(crate) struct NamespaceRemoval;
227
228#[cfg(not(feature = "reporting"))]
229impl NamespaceRemoval {
230    pub(crate) const fn reserve() -> Self {
231        Self
232    }
233
234    pub(crate) fn call_installed<R>(call: impl FnOnce() -> R) -> R {
235        call()
236    }
237
238    pub(crate) const fn record(self, _namespace: &str, _failure: Option<&str>) {}
239}
240
241/// The backend under the replay seam. It stores nothing.
242#[cfg(feature = "reporting")]
243struct ReplayBackend;
244
245#[cfg(feature = "reporting")]
246impl Cache for ReplayBackend {
247    fn get_value(&self, _key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
248        None
249    }
250    fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {}
251    fn invalidate(&self, _key: &str) {}
252    fn clear(&self) {}
253    fn invalidate_namespace(&self, _namespace: &str) -> bool {
254        true
255    }
256}
257
258/// Remove the process-level shared cache.
259///
260/// Primarily useful in tests that need per-test isolation.
261///
262/// # Panics
263///
264/// Panics if the internal `RwLock` is poisoned.
265pub fn clear_global_cache() {
266    *GLOBAL_CACHE.write().expect("global cache lock poisoned") = None;
267}
268
269/// A boxed future returned by the async [`Cache`] methods.
270///
271/// Boxed so that `Cache` stays usable as `dyn Cache`.
272pub type CacheFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
273
274/// An invalidation that did not complete. Stale data can still be served.
275///
276/// Returned by [`Cache::invalidate_async`] and
277/// [`Cache::invalidate_namespace_async`].
278#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
279#[error("cache invalidation failed after {attempts} attempt(s): {reason}")]
280pub struct InvalidationError {
281    attempts: u32,
282    reason: String,
283}
284
285impl InvalidationError {
286    /// Make an error for an invalidation that failed after `attempts` tries.
287    pub fn new(attempts: u32, reason: impl Into<String>) -> Self {
288        Self {
289            attempts,
290            reason: reason.into(),
291        }
292    }
293
294    /// How many times the backend tried.
295    #[must_use]
296    pub const fn attempts(&self) -> u32 {
297        self.attempts
298    }
299
300    /// The last backend error, as text.
301    #[must_use]
302    pub fn reason(&self) -> &str {
303        &self.reason
304    }
305}
306
307/// A backend read or write that did not complete.
308///
309/// Returned by [`Cache::try_get_value`], [`Cache::try_insert_raw_bytes`],
310/// [`try_get_cached`] and [`try_insert_cached`].
311#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
312#[error("cache backend failed: {reason}")]
313pub struct CacheBackendError {
314    reason: String,
315}
316
317impl CacheBackendError {
318    /// Make an error with the backend's `reason`.
319    pub fn new(reason: impl Into<String>) -> Self {
320        Self {
321            reason: reason.into(),
322        }
323    }
324
325    /// The backend error, as text.
326    #[must_use]
327    pub fn reason(&self) -> &str {
328        &self.reason
329    }
330}
331
332/// Invalidations that failed after all retries, in this process.
333static INVALIDATION_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
334
335/// Count one invalidation that failed after all retries.
336///
337/// Shown as `autumn_cache_invalidation_failures_total`. Call it only where you
338/// drop the error. Do not call it where you return the error. Then each
339/// failure counts one time. A custom backend calls it from a sync method that
340/// cannot return the error.
341pub fn record_invalidation_failure() {
342    INVALIDATION_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
343}
344
345/// Total of [`record_invalidation_failure`] calls in this process.
346#[must_use]
347pub fn invalidation_failures_total() -> u64 {
348    INVALIDATION_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
349}
350
351/// Serializes same-process test code that mutates [`GLOBAL_CACHE`] via
352/// [`set_global_cache`]/[`clear_global_cache`].
353///
354/// `cargo test` runs a crate's unit tests on parallel threads in one
355/// process, so two tests racing on this process-wide singleton can
356/// interleave — e.g. one test's `clear_global_cache()` landing between
357/// another's set-then-read sequence. Every same-process caller that
358/// mutates the global cache for test purposes — [`crate::test::TestApp::build`]
359/// and the `cache::fragment` unit tests — acquires this lock for the
360/// duration of its critical section so they mutually exclude each other.
361/// Poison-tolerant: acquire with `.lock().unwrap_or_else(PoisonError::into_inner)`
362/// so one panicking test doesn't cascade into failing an unrelated one. See
363/// issue #2218.
364pub(crate) static GLOBAL_CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
365
366// ── Cache trait ──────────────────────────────────────────────────────
367
368/// Raw JSON bytes stored by serializing cache backends (e.g. Redis).
369///
370/// Backends that cannot store `Arc<dyn Any>` directly (because values must
371/// survive across process boundaries) return this from [`Cache::get_value`]
372/// instead. [`get_cached`] and [`insert_cached`] transparently deserialize it
373/// back into the concrete type `V` using `serde_json`.
374#[derive(Clone)]
375pub struct RawCacheBytes(pub Vec<u8>);
376
377/// A type-erased, thread-safe cache store.
378///
379/// Implementations must be `Send + Sync` so they can be shared across
380/// handlers and tasks. Values are stored as `Arc<dyn Any>` for type
381/// erasure, allowing a single cache instance to store heterogeneous
382/// types from different `#[cached]` functions.
383///
384/// Use the free functions [`get`] / [`insert`] for in-process-only values,
385/// or [`get_cached`] / [`insert_cached`] for types that also implement
386/// `serde`, which is required for cross-replica backends like Redis.
387/// [`CacheResponseLayer`] uses the serde-aware path so HTTP response caching
388/// works with both in-process and raw-byte backends.
389pub trait Cache: Send + Sync + 'static {
390    /// Retrieve a type-erased value by key. Returns `None` on miss.
391    ///
392    /// Backends that store serialized data (e.g. Redis) may return
393    /// <code>Arc<[RawCacheBytes]></code> here; [`get_cached`] handles the
394    /// JSON deserialization transparently.
395    fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>>;
396
397    /// Store a type-erased value by key.
398    fn insert_value(&self, key: &str, value: Arc<dyn Any + Send + Sync>);
399
400    /// Remove a specific key.
401    ///
402    /// This method cannot report a failure. A backend that can fail must log
403    /// the failure and call [`record_invalidation_failure`]. Prefer
404    /// [`invalidate_async`](Cache::invalidate_async).
405    fn invalidate(&self, key: &str);
406
407    /// Remove all entries.
408    fn clear(&self);
409
410    /// Drop every entry belonging to one cached read — i.e. every key under
411    /// `"{namespace}:"`, the prefix
412    /// [`make_cache_key`] stamps on each of that read's keys.
413    ///
414    /// Returns whether the backend could actually do it. The default is
415    /// `false`: a backend that cannot enumerate or pattern-match its key space
416    /// must say so rather than silently leave stale entries behind, because the
417    /// build-time coherence gate (issue #1716) reports this answer to the
418    /// caller verbatim. Backends that CAN do it — `MokaCache` by iteration,
419    /// `RedisCache` by `SCAN MATCH` — override it and return `true`.
420    fn invalidate_namespace(&self, _namespace: &str) -> bool {
421        false
422    }
423
424    /// Store pre-serialized JSON bytes for backends that persist data across
425    /// process boundaries (e.g. Redis). The default is a no-op; in-process
426    /// backends store values via [`insert_value`] instead.
427    ///
428    /// `ttl` carries the same time-to-live that was declared on the
429    /// `#[cached(ttl = "…")]` attribute so backends can apply native expiry
430    /// (e.g. Redis `SET EX`). `None` means no expiry.
431    ///
432    /// [`insert_value`]: Cache::insert_value
433    fn insert_raw_bytes(&self, _key: &str, _bytes: Vec<u8>, _ttl: Option<std::time::Duration>) {}
434
435    /// Whether this backend keeps a shared fill epoch.
436    ///
437    /// Return `true` if you override [`fill_epoch`](Cache::fill_epoch). Then a
438    /// failed [`invalidate_namespace`](Cache::invalidate_namespace) on a store
439    /// registered with `coherence::register_namespace_store` makes the
440    /// invalidation incomplete. The default is `false`.
441    fn shares_fill_epoch(&self) -> bool {
442        false
443    }
444
445    /// Read the namespace's **shared** fill epoch.
446    ///
447    /// A cross-replica backend keeps one epoch for each namespace in the
448    /// shared store.
449    /// [`invalidate_namespace`](Cache::invalidate_namespace) must raise it
450    /// **before** it sweeps. A fill reads it after a miss and before it
451    /// computes. Then the fill inserts with
452    /// [`insert_raw_bytes_if_epoch`](Cache::insert_raw_bytes_if_epoch). Then no
453    /// fill can write back a value that an invalidation has dropped. This holds
454    /// on all replicas.
455    ///
456    /// The default is [`FillEpoch::Unsupported`]. The fence stays per process.
457    fn fill_epoch(&self, _namespace: &str) -> FillEpoch {
458        FillEpoch::Unsupported
459    }
460
461    /// Store pre-serialized bytes only if the shared epoch still equals
462    /// `sampled`. The check and the store must be one atomic step.
463    ///
464    /// Returns `true` when stored. A backend that overrides
465    /// [`fill_epoch`](Cache::fill_epoch) must override this too. The default
466    /// stores without a check.
467    ///
468    /// When the epoch is [`FillEpoch::Sampled`], `insert_value` is not called.
469    /// Fill any local tier here.
470    fn insert_raw_bytes_if_epoch(
471        &self,
472        key: &str,
473        bytes: Vec<u8>,
474        ttl: Option<std::time::Duration>,
475        _namespace: &str,
476        _sampled: u64,
477    ) -> bool {
478        self.insert_raw_bytes(key, bytes, ttl);
479        true
480    }
481
482    /// Try to acquire a cross-replica fill lock for `key`, used by
483    /// [`get_or_compute_with`] to ensure at most one replica refills a hot
484    /// key at a time.
485    ///
486    /// `token` identifies the caller so [`release_fill_lock`] can safely
487    /// release only a lock it still owns. `ttl` bounds how long the lock is
488    /// held if the caller crashes before releasing it.
489    ///
490    /// The default implementation reports [`FillLockStatus::Unsupported`],
491    /// which degrades callers to in-process-only single-flight protection —
492    /// safe for backends (like the in-process Moka cache) that have no
493    /// cross-replica visibility.
494    ///
495    /// [`release_fill_lock`]: Cache::release_fill_lock
496    fn try_acquire_fill_lock(&self, _key: &str, _token: &str, _ttl: Duration) -> FillLockStatus {
497        FillLockStatus::Unsupported
498    }
499
500    /// Release the fill lock previously acquired with `token`, if this
501    /// caller still owns it. The default is a no-op, matching the default
502    /// [`try_acquire_fill_lock`] returning [`FillLockStatus::Unsupported`].
503    ///
504    /// [`try_acquire_fill_lock`]: Cache::try_acquire_fill_lock
505    fn release_fill_lock(&self, _key: &str, _token: &str) {}
506
507    /// Remove a specific key, and return a failure.
508    ///
509    /// The default calls [`invalidate`](Cache::invalidate) and returns `Ok`.
510    /// A backend that does I/O (for example Redis) overrides it with a real
511    /// async call that does not block a runtime worker, and returns the error.
512    ///
513    /// # Errors
514    ///
515    /// [`InvalidationError`] when the key could not be removed. The old value
516    /// can still be served.
517    fn invalidate_async<'a>(
518        &'a self,
519        key: &'a str,
520    ) -> CacheFuture<'a, Result<(), InvalidationError>> {
521        Box::pin(async move {
522            self.invalidate(key);
523            Ok(())
524        })
525    }
526
527    /// Read a value, and report a backend failure.
528    ///
529    /// [`get_value`](Cache::get_value) answers `None` for both a miss and an
530    /// outage. This method keeps them apart. The default calls `get_value` and
531    /// cannot fail. A backend that does I/O overrides it.
532    ///
533    /// # Errors
534    ///
535    /// [`CacheBackendError`] when the backend could not answer.
536    fn try_get_value(
537        &self,
538        key: &str,
539    ) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
540        Ok(self.get_value(key))
541    }
542
543    /// Store pre-serialized bytes, and report a backend failure.
544    ///
545    /// The default calls [`insert_raw_bytes`](Cache::insert_raw_bytes) and
546    /// returns `Ok`. A backend that does I/O overrides it.
547    ///
548    /// # Errors
549    ///
550    /// [`CacheBackendError`] when the write did not land.
551    fn try_insert_raw_bytes(
552        &self,
553        key: &str,
554        bytes: Vec<u8>,
555        ttl: Option<std::time::Duration>,
556    ) -> Result<(), CacheBackendError> {
557        self.insert_raw_bytes(key, bytes, ttl);
558        Ok(())
559    }
560
561    /// Remove a key, and report a failure.
562    ///
563    /// This method blocks, like [`invalidate`](Cache::invalidate). In async
564    /// code, use [`invalidate_async`](Cache::invalidate_async). The default
565    /// calls `invalidate` and returns `Ok`.
566    ///
567    /// A decorator that wraps another cache must forward `try_get_value`,
568    /// `try_insert_raw_bytes` and `try_invalidate`. If it does not, it uses the
569    /// defaults, and the backend failures are lost.
570    ///
571    /// # Errors
572    ///
573    /// [`InvalidationError`] when the key could not be removed.
574    fn try_invalidate(&self, key: &str) -> Result<(), InvalidationError> {
575        self.invalidate(key);
576        Ok(())
577    }
578
579    /// Whether this backend is the framework's failure-capsule wrapper.
580    /// Framework internal. A decorator that wraps an installed backend
581    /// forwards it, so the backend is not wrapped twice.
582    #[doc(hidden)]
583    fn is_capsule_seam(&self) -> bool {
584        false
585    }
586
587    /// Async form of [`invalidate_namespace`](Cache::invalidate_namespace).
588    ///
589    /// The default calls the sync method and maps `false` to an error.
590    ///
591    /// # Errors
592    ///
593    /// [`InvalidationError`] when the backend cannot drop a namespace, or its
594    /// sweep failed. Entries of the namespace can still be served.
595    fn invalidate_namespace_async<'a>(
596        &'a self,
597        namespace: &'a str,
598    ) -> CacheFuture<'a, Result<(), InvalidationError>> {
599        Box::pin(async move {
600            if self.invalidate_namespace(namespace) {
601                Ok(())
602            } else {
603                Err(InvalidationError::new(
604                    1,
605                    "the backend cannot drop a namespace, or its sweep failed",
606                ))
607            }
608        })
609    }
610}
611
612/// A namespace's shared fill epoch, as read by [`Cache::fill_epoch`].
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub enum FillEpoch {
615    /// The backend has no shared epoch. Only the per-process fence applies.
616    Unsupported,
617    /// The epoch at the time of the read.
618    Sampled(u64),
619    /// The backend has a shared epoch but could not read it. A fill must not
620    /// insert. The result is one extra cache miss.
621    Unavailable,
622}
623
624/// Outcome of [`Cache::try_acquire_fill_lock`].
625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub enum FillLockStatus {
627    /// The caller now holds the lock and must call
628    /// [`Cache::release_fill_lock`] when the fill completes (success or
629    /// failure).
630    Acquired,
631    /// Another replica currently holds the lock.
632    Held,
633    /// This backend has no cross-replica fill lock; callers fall back to
634    /// in-process-only single-flight protection.
635    Unsupported,
636}
637
638// ── Typed convenience functions ──────────────────────────────────────
639
640/// Typed get: retrieve and downcast a cached value.
641///
642/// Returns `None` if the key is absent or the stored type doesn't
643/// match `V`. Works with any `Cache` implementation.
644///
645/// For cross-replica backends (Redis) use [`get_cached`] instead, which
646/// also handles JSON deserialization of [`RawCacheBytes`].
647pub fn get<V: Clone + Send + Sync + 'static>(cache: &dyn Cache, key: &str) -> Option<V> {
648    // Failure-capsule seam (#2351 item 2). The value is not serializable, so
649    // a replay serves only a recorded miss, and capture can record only a
650    // miss.
651    if replayed_untyped_get(key) {
652        return None;
653    }
654    let value = helper_read(cache, key).and_then(|arc| arc.downcast_ref::<V>().cloned());
655    record_untyped_get(key, value.is_some());
656    value
657}
658
659/// Read through a helper that records the read. The capsule seam takes any
660/// other `get_value` call as a direct read that it cannot record.
661fn helper_read(cache: &dyn Cache, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
662    #[cfg(feature = "reporting")]
663    let _helper = HelperCall::enter();
664    cache.get_value(key)
665}
666
667// Set while a recording helper reads or writes, so the seam can tell a
668// direct call.
669#[cfg(feature = "reporting")]
670thread_local! {
671    static HELPER_CALL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
672}
673
674/// Marks a helper call for its lifetime, also when the backend panics.
675#[cfg(feature = "reporting")]
676struct HelperCall(bool);
677
678#[cfg(feature = "reporting")]
679impl HelperCall {
680    fn enter() -> Self {
681        Self(HELPER_CALL.with(|flag| flag.replace(true)))
682    }
683}
684
685#[cfg(feature = "reporting")]
686impl Drop for HelperCall {
687    fn drop(&mut self) {
688        let outer = self.0;
689        HELPER_CALL.with(|flag| flag.set(outer));
690    }
691}
692
693/// Why a capsule with a direct cache write is not replayable.
694#[cfg(feature = "reporting")]
695const DIRECT_WRITE_NOTE: &str = "the run wrote the cache with `Cache::insert_value` or \
696     `insert_raw_bytes` directly; the write is not recorded, so replay cannot check it";
697
698/// Why a capsule with a direct cache read is not replayable.
699#[cfg(feature = "reporting")]
700const DIRECT_READ_NOTE: &str = "the run read the cache with `Cache::get_value` directly; the \
701     value is not recorded, so replay cannot serve it";
702
703/// Typed insert: wrap the value in an `Arc` and store it.
704///
705/// Works with any `Cache` implementation.
706///
707/// For cross-replica backends (Redis) use [`insert_cached`] instead,
708/// which also serializes the value for storage across process boundaries.
709pub fn insert<V: Clone + Send + Sync + 'static>(cache: &dyn Cache, key: &str, value: V) {
710    // Failure-capsule seam (#2351 item 2): a replay never writes, and capture
711    // cannot record the value.
712    if record_or_replay_untyped_insert(key) {
713        return;
714    }
715    #[cfg(feature = "reporting")]
716    let _helper = HelperCall::enter();
717    cache.insert_value(key, Arc::new(value));
718}
719
720/// Serde-aware get: retrieve a cached value, deserializing from JSON if needed.
721///
722/// First tries a direct in-memory downcast (fast path for `MokaCache`). If
723/// that fails — because the backend stored [`RawCacheBytes`] (e.g. Redis) —
724/// the bytes are deserialized with `serde_json`. This is what the `#[cached]`
725/// macro uses so that values survive across replicas when a shared backend
726/// is configured.
727pub fn get_cached<V>(cache: &dyn Cache, key: &str) -> Option<V>
728where
729    V: Clone + serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static,
730{
731    // Failure-capsule seam (#1634). A replay is served entirely from the
732    // capsule: the live backend is never consulted, because the value it holds
733    // now is not the value the failing request read.
734    match replayed_cache_get::<V>(key) {
735        ReplayedRead::NoTape => {}
736        ReplayedRead::Miss => return None,
737        ReplayedRead::Hit(value) => return Some(value),
738    }
739    let arc = helper_read(cache, key);
740    let value = arc.and_then(|arc| {
741        // Fast path: in-memory backend stored the concrete type directly.
742        if let Some(value) = arc.downcast_ref::<V>() {
743            return Some(value.clone());
744        }
745        // Slow path: serializing backend (e.g. Redis) stored RawCacheBytes.
746        arc.downcast_ref::<RawCacheBytes>()
747            .and_then(|raw| serde_json::from_slice::<V>(&raw.0).ok())
748    });
749    // Recorded from the *typed* value rather than from the stored `Arc<dyn
750    // Any>`: the in-process backend (Moka) stores `Arc<V>` and never
751    // `RawCacheBytes`, so reading the erased value would record every hit from
752    // the default backend as valueless — and replay would then take the miss
753    // branch of a handler whose bug is on the hit branch. The `Serialize`
754    // bound is what makes the recording possible at all; every caller already
755    // pairs this with `insert_cached`, which requires it too.
756    record_cache_get(key, value.as_ref());
757    value
758}
759
760/// Serde-aware insert: store the value both in-memory and as JSON bytes.
761///
762/// Calls [`Cache::insert_value`] (for in-process backends like Moka) **and**
763/// [`Cache::insert_raw_bytes`] (for cross-replica backends like Redis). This
764/// is what the `#[cached]` macro uses so that the stored value is accessible
765/// both within the same process and on other replicas.
766///
767/// `ttl` is forwarded verbatim to [`Cache::insert_raw_bytes`] so backends
768/// like Redis can apply a native entry expiry (e.g. `SET EX`). In-process
769/// backends (Moka) manage TTL via the per-function static cache instance
770/// and ignore this parameter.
771pub fn insert_cached<V>(cache: &dyn Cache, key: &str, value: V, ttl: Option<std::time::Duration>)
772where
773    V: Clone + serde::Serialize + Send + Sync + 'static,
774{
775    let bytes = serde_json::to_vec(&value).ok();
776    // Failure-capsule seam (#1634): the write is recorded on capture, and on
777    // replay it lands in the tape (so a read-back in the same run finds it)
778    // instead of in a live backend.
779    if record_or_replay_cache_insert(key, bytes.as_deref(), ttl) {
780        return;
781    }
782    #[cfg(feature = "reporting")]
783    let _helper = HelperCall::enter();
784    // In-memory path (MokaCache, CountingCache in tests, …)
785    cache.insert_value(key, Arc::new(value));
786    // Serialized path (RedisCache, any cross-replica backend)
787    if let Some(bytes) = bytes {
788        cache.insert_raw_bytes(key, bytes, ttl);
789    }
790}
791
792/// [`get_cached`], with a backend failure kept apart from a miss.
793///
794/// Unlike [`get_cached`], stored bytes that cannot be decoded are an error,
795/// not a miss. The key stays unreadable until a writer replaces it. A failed
796/// read marks a failure capsule incomplete, so replay does not run it.
797///
798/// # Errors
799///
800/// [`CacheBackendError`] when the backend could not answer, or the stored
801/// bytes cannot be decoded.
802pub fn try_get_cached<V>(cache: &dyn Cache, key: &str) -> Result<Option<V>, CacheBackendError>
803where
804    V: Clone + serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static,
805{
806    match replayed_cache_get::<V>(key) {
807        ReplayedRead::NoTape => {}
808        ReplayedRead::Miss => return Ok(None),
809        ReplayedRead::Hit(value) => return Ok(Some(value)),
810    }
811    let read = {
812        #[cfg(feature = "reporting")]
813        let _helper = HelperCall::enter();
814        cache.try_get_value(key)
815    };
816    let found = match read {
817        Ok(found) => found,
818        Err(error) => {
819            mark_failed_cache_op(FAILED_READ_NOTE);
820            return Err(error);
821        }
822    };
823    let Some(arc) = found else {
824        record_cache_get::<V>(key, None);
825        return Ok(None);
826    };
827    if let Some(value) = arc.downcast_ref::<V>() {
828        let value = Some(value.clone());
829        record_cache_get(key, value.as_ref());
830        return Ok(value);
831    }
832    // Bytes the backend holds but this type cannot read are a fault, not an
833    // absent key. The text is fixed: a guest can read it.
834    let value = if let Some(raw) = arc.downcast_ref::<RawCacheBytes>() {
835        let Ok(value) = serde_json::from_slice::<V>(&raw.0) else {
836            mark_failed_cache_op(FAILED_READ_NOTE);
837            return Err(CacheBackendError::new("the stored value cannot be decoded"));
838        };
839        Some(value)
840    } else {
841        None
842    };
843    record_cache_get(key, value.as_ref());
844    Ok(value)
845}
846
847/// [`insert_cached`], with a backend failure reported.
848///
849/// # Errors
850///
851/// [`CacheBackendError`] when the value cannot be serialized, or the backend
852/// did not store it.
853pub fn try_insert_cached<V>(
854    cache: &dyn Cache,
855    key: &str,
856    value: V,
857    ttl: Option<std::time::Duration>,
858) -> Result<(), CacheBackendError>
859where
860    V: Clone + serde::Serialize + Send + Sync + 'static,
861{
862    let bytes = serde_json::to_vec(&value).map_err(|error| {
863        CacheBackendError::new(format!("the value cannot be serialized: {error}"))
864    })?;
865    if record_or_replay_cache_insert(key, Some(&bytes), ttl) {
866        return Ok(());
867    }
868    #[cfg(feature = "reporting")]
869    let _helper = HelperCall::enter();
870    cache.insert_value(key, Arc::new(value));
871    let stored = cache.try_insert_raw_bytes(key, bytes, ttl);
872    if stored.is_err() {
873        // The write is already recorded, but it did not land.
874        mark_failed_cache_op(FAILED_WRITE_NOTE);
875    }
876    stored
877}
878
879/// Why a capsule with a failed cache read is not replayable.
880const FAILED_READ_NOTE: &str = "a cache read failed; the failure is not recorded, so replay \
881     cannot take the same path";
882
883/// Why a capsule with a failed cache write is not replayable.
884const FAILED_WRITE_NOTE: &str = "a cache write failed; the capsule recorded it as stored, so \
885     replay cannot take the same path";
886
887/// Mark the capture scope incomplete after a failed cache read or write.
888#[cfg(feature = "reporting")]
889fn mark_failed_cache_op(note: &str) {
890    if let Some(scope) = crate::capsule::current_scope() {
891        scope.note(note);
892        scope.mark_truncated();
893    }
894}
895
896/// No capsule support compiled in: nothing records the failure.
897#[cfg(not(feature = "reporting"))]
898const fn mark_failed_cache_op(_note: &str) {}
899
900/// Like [`insert_cached`], but store only if the shared epoch did not change.
901///
902/// `epoch` is the [`Cache::fill_epoch`] read after the miss and before the
903/// compute. Returns `true` when the value was stored. `false` means the fill
904/// was fenced out, or the epoch was [`FillEpoch::Unavailable`].
905///
906/// With [`FillEpoch::Unsupported`] this is [`insert_cached`].
907pub fn insert_cached_fenced<V>(
908    cache: &dyn Cache,
909    key: &str,
910    value: V,
911    ttl: Option<std::time::Duration>,
912    namespace: &str,
913    epoch: FillEpoch,
914) -> bool
915where
916    V: Clone + serde::Serialize + Send + Sync + 'static,
917{
918    // A replay has no fence: `insert_cached` sends the write to the tape, or
919    // drops it during `autumn replay` with no tape. Only a sampled epoch
920    // reaches the backend, so only it is bypassed, and only after the value
921    // is encoded, as the fenced path does.
922    let epoch = match epoch {
923        FillEpoch::Sampled(_) if replaying() => {
924            if serde_json::to_vec(&value).is_err() {
925                return false;
926            }
927            FillEpoch::Unsupported
928        }
929        other => other,
930    };
931    let sampled = match epoch {
932        FillEpoch::Unsupported => {
933            insert_cached(cache, key, value, ttl);
934            return true;
935        }
936        FillEpoch::Unavailable => return false,
937        FillEpoch::Sampled(sampled) => sampled,
938    };
939    let Some(bytes) = serde_json::to_vec(&value).ok() else {
940        return false;
941    };
942    // Raw bytes only: `insert_value` would write without the check.
943    let stored = {
944        #[cfg(feature = "reporting")]
945        let _helper = HelperCall::enter();
946        cache.insert_raw_bytes_if_epoch(key, bytes.clone(), ttl, namespace, sampled)
947    };
948    if stored {
949        // Capture records the write after it happened. A replay never gets
950        // here.
951        let _ = record_or_replay_cache_insert(key, Some(&bytes), ttl);
952    }
953    stored
954}
955
956/// Read the shared fill epoch for a fill that just missed.
957///
958/// A replay serves cache effects from the tape (#1634), so it never reads the
959/// backend. It gets [`FillEpoch::Unsupported`], and [`insert_cached_fenced`]
960/// then writes to the tape. During `autumn replay`, code with no tape also
961/// gets `Unsupported`, and the write is dropped.
962#[must_use]
963pub fn sample_fill_epoch(cache: &dyn Cache, namespace: &str) -> FillEpoch {
964    if replaying() {
965        return FillEpoch::Unsupported;
966    }
967    cache.fill_epoch(namespace)
968}
969
970/// Whether an effect tape serves the current task, or this process replays a
971/// capsule.
972///
973/// Not `tape_active`: that marks the replay scope as entered. Only the
974/// reporting layer may do that.
975#[cfg(feature = "reporting")]
976fn replaying() -> bool {
977    crate::capsule::effects::current_tape().is_some() || replay_blocked()
978}
979
980/// No capsule support compiled in: there is no replay.
981#[cfg(not(feature = "reporting"))]
982const fn replaying() -> bool {
983    false
984}
985
986// ── Failure-capsule seam (#1634) ─────────────────────────────────────────────
987//
988// The `capsule` module is behind the `reporting` feature, so each helper here
989// has a no-op twin for builds without it — the seam stays one line at each
990// call site whatever the feature set.
991
992/// What a replay has to say about a cache read.
993///
994/// A named type rather than a nested `Option`: "no replay is in progress" and
995/// "the replay says this key missed" are opposite instructions to the caller,
996/// and `Option<Option<V>>` spells them the same way round as each other.
997enum ReplayedRead<V> {
998    /// No replay is in progress; read the real cache.
999    NoTape,
1000    /// The tape has no value to serve — a recorded miss, an unrecorded key
1001    /// (already logged as a divergence), or a hit the recording could not
1002    /// serialize. The read replays as a miss.
1003    Miss,
1004    /// The recorded hit.
1005    Hit(V),
1006}
1007
1008/// Serve a cache read from the capsule's effect tape, when one is active.
1009#[cfg(feature = "reporting")]
1010fn replayed_cache_get<V>(key: &str) -> ReplayedRead<V>
1011where
1012    V: Clone + serde::de::DeserializeOwned + Send + Sync + 'static,
1013{
1014    use crate::capsule::effects::CachedValue;
1015    let Some(tape) = crate::capsule::effects::current_tape() else {
1016        // Replay startup code has no tape. A raw backend it holds must not
1017        // be read, so the read is a miss.
1018        if replay_blocked() {
1019            return ReplayedRead::Miss;
1020        }
1021        return ReplayedRead::NoTape;
1022    };
1023    match tape.cache_get(key) {
1024        CachedValue::Hit(bytes) => {
1025            serde_json::from_slice::<V>(&bytes).map_or(ReplayedRead::Miss, ReplayedRead::Hit)
1026        }
1027        CachedValue::Miss | CachedValue::Unrecorded => ReplayedRead::Miss,
1028    }
1029}
1030
1031/// No capsule support compiled in: never a replay.
1032#[cfg(not(feature = "reporting"))]
1033const fn replayed_cache_get<V>(_key: &str) -> ReplayedRead<V>
1034where
1035    V: Clone + serde::de::DeserializeOwned + Send + Sync + 'static,
1036{
1037    ReplayedRead::NoTape
1038}
1039
1040/// Tee a cache read into the in-flight request's capsule.
1041///
1042/// A miss is recorded as a *keyed* entry with no value: replay then knows the
1043/// key was read (so it does not call it unrecorded) while still being honest
1044/// that it has nothing to serve, and the handler takes its miss branch.
1045///
1046/// A **hit** whose value will not serialize cannot be recorded that way. It
1047/// would be indistinguishable from a miss, so replay would take the miss branch
1048/// without a divergence and grade a run that never happened — the handler read
1049/// a value in production and reads nothing here. There is no honest recording
1050/// of a value that cannot be encoded, so the capsule declares itself
1051/// incomplete instead.
1052#[cfg(feature = "reporting")]
1053fn record_cache_get<V: serde::Serialize>(key: &str, value: Option<&V>) {
1054    let Some(scope) = crate::capsule::current_scope() else {
1055        return;
1056    };
1057    let encoded = value.map(|value| {
1058        serde_json::to_vec(value).map(|bytes| {
1059            use base64::Engine as _;
1060            base64::engine::general_purpose::STANDARD.encode(&bytes)
1061        })
1062    });
1063    let encoded = match encoded {
1064        None => None,
1065        Some(Ok(encoded)) => Some(encoded),
1066        Some(Err(_)) => {
1067            scope.note(UNRECORDABLE_CACHE_HIT_NOTE);
1068            scope.mark_truncated();
1069            None
1070        }
1071    };
1072    scope.record_cache(crate::capsule::CacheEffect::Get {
1073        key: key.to_owned(),
1074        value: encoded,
1075    });
1076}
1077
1078/// Why a capsule that wrote an unserializable cache value is not replayable.
1079#[cfg(feature = "reporting")]
1080const UNRECORDABLE_CACHE_WRITE_NOTE: &str = "a cache write held a value that could not be serialized into the capsule, so replay would \
1081     suppress a mutation the recorded run really made";
1082
1083/// Why a capsule that read an unserializable cache hit is not replayable.
1084#[cfg(feature = "reporting")]
1085const UNRECORDABLE_CACHE_HIT_NOTE: &str = "a cache read hit a value that could not be serialized into the capsule, and a hit with no \
1086     recorded value is indistinguishable from a miss; replay would take the miss branch the \
1087     recorded run never took";
1088
1089/// No capsule support compiled in: nothing to record.
1090#[cfg(not(feature = "reporting"))]
1091const fn record_cache_get<V: serde::Serialize>(_key: &str, _value: Option<&V>) {}
1092
1093/// Record a cache write, or divert it into the replay tape.
1094///
1095/// Returns `true` when a replay handled the write and the live backend must
1096/// not be touched.
1097#[cfg(feature = "reporting")]
1098fn record_or_replay_cache_insert(
1099    key: &str,
1100    bytes: Option<&[u8]>,
1101    ttl: Option<std::time::Duration>,
1102) -> bool {
1103    if let Some(tape) = crate::capsule::effects::current_tape() {
1104        if let Some(bytes) = bytes {
1105            tape.cache_insert(key, bytes, ttl.map(|ttl| ttl.as_secs()));
1106        }
1107        return true;
1108    }
1109    // Replay startup code has no tape. A raw backend it holds must not be
1110    // written.
1111    if replay_blocked() {
1112        return true;
1113    }
1114    if let Some(scope) = crate::capsule::current_scope() {
1115        if let Some(bytes) = bytes {
1116            use base64::Engine as _;
1117            scope.record_cache(crate::capsule::CacheEffect::Insert {
1118                key: key.to_owned(),
1119                value: base64::engine::general_purpose::STANDARD.encode(bytes),
1120                ttl_secs: ttl.map(|ttl| ttl.as_secs()),
1121            });
1122        } else {
1123            // The live backend accepted this write; the capsule cannot hold it.
1124            // Recording nothing would let replay suppress a real cache mutation
1125            // and still grade the run clean, which is the same falsification the
1126            // unserializable *read* refuses — caught here a function later.
1127            scope.note(UNRECORDABLE_CACHE_WRITE_NOTE);
1128            scope.mark_truncated();
1129        }
1130    }
1131    false
1132}
1133
1134/// No capsule support compiled in: the live backend always takes the write.
1135#[cfg(not(feature = "reporting"))]
1136const fn record_or_replay_cache_insert(
1137    _key: &str,
1138    _bytes: Option<&[u8]>,
1139    _ttl: Option<std::time::Duration>,
1140) -> bool {
1141    false
1142}
1143
1144/// Serve an untyped read during a replay. Returns `true` when a replay
1145/// handled it; the read is then a miss.
1146#[cfg(feature = "reporting")]
1147fn replayed_untyped_get(key: &str) -> bool {
1148    let Some(tape) = crate::capsule::effects::current_tape() else {
1149        // Replay startup code: a raw backend is not read.
1150        return replay_blocked();
1151    };
1152    tape.cache_untyped_get(key);
1153    true
1154}
1155
1156/// No capsule support compiled in: never a replay.
1157#[cfg(not(feature = "reporting"))]
1158const fn replayed_untyped_get(_key: &str) -> bool {
1159    false
1160}
1161
1162/// Record an untyped read. A miss records like a typed miss. A hit has no
1163/// value the capsule can hold, so the capsule is marked incomplete.
1164#[cfg(feature = "reporting")]
1165fn record_untyped_get(key: &str, hit: bool) {
1166    let Some(scope) = crate::capsule::current_scope() else {
1167        return;
1168    };
1169    if hit {
1170        scope.note(UNRECORDABLE_CACHE_HIT_NOTE);
1171        scope.mark_truncated();
1172    }
1173    scope.record_cache(crate::capsule::CacheEffect::Get {
1174        key: key.to_owned(),
1175        value: None,
1176    });
1177}
1178
1179/// No capsule support compiled in: nothing to record.
1180#[cfg(not(feature = "reporting"))]
1181const fn record_untyped_get(_key: &str, _hit: bool) {}
1182
1183/// Record an untyped write, or divert it during a replay. Returns `true` when
1184/// a replay handled it and the backend must not be touched.
1185#[cfg(feature = "reporting")]
1186fn record_or_replay_untyped_insert(key: &str) -> bool {
1187    if let Some(tape) = crate::capsule::effects::current_tape() {
1188        tape.cache_untyped_insert(key);
1189        return true;
1190    }
1191    // Replay startup code: a raw backend is not written.
1192    if replay_blocked() {
1193        return true;
1194    }
1195    if let Some(scope) = crate::capsule::current_scope() {
1196        scope.note(UNRECORDABLE_CACHE_WRITE_NOTE);
1197        scope.mark_truncated();
1198    }
1199    false
1200}
1201
1202/// No capsule support compiled in: the backend always takes the write.
1203#[cfg(not(feature = "reporting"))]
1204const fn record_or_replay_untyped_insert(_key: &str) -> bool {
1205    false
1206}
1207
1208// ── Capsule seam for the removal methods (#2351 item 2) ──────────────────
1209
1210/// Wrap `cache` so a capsule records its removals and a replay never reaches
1211/// it. Idempotent: a wrapped backend is returned as it is, so `Arc` identity
1212/// (which single-flight keys on) stays stable.
1213#[cfg(feature = "reporting")]
1214#[must_use]
1215pub(crate) fn with_capsule_seam(cache: Arc<dyn Cache>) -> Arc<dyn Cache> {
1216    if cache.is_capsule_seam() {
1217        return cache;
1218    }
1219    Arc::new(CapsuleSeamCache(cache))
1220}
1221
1222/// No capsule support compiled in: the backend is used as it is.
1223#[cfg(not(feature = "reporting"))]
1224#[must_use]
1225pub(crate) fn with_capsule_seam(cache: Arc<dyn Cache>) -> Arc<dyn Cache> {
1226    cache
1227}
1228
1229/// The installed backend, with the removal methods on the capsule seam.
1230///
1231/// Reads and writes pass through: `get_cached` / `insert_cached` and the
1232/// untyped functions carry their own seam.
1233#[cfg(feature = "reporting")]
1234struct CapsuleSeamCache(Arc<dyn Cache>);
1235
1236#[cfg(feature = "reporting")]
1237impl CapsuleSeamCache {
1238    /// Check a write that reaches the seam. A helper write with a tape never
1239    /// gets here, so with a tape this is a direct write: a divergence, as
1240    /// capture refuses such a capsule. Returns `true` when the backend must
1241    /// not be written.
1242    fn direct_write_blocked(key: &str) -> bool {
1243        let direct = !HELPER_CALL.with(std::cell::Cell::get);
1244        if let Some(tape) = crate::capsule::effects::current_tape() {
1245            if direct {
1246                tape.cache_untyped_insert(key);
1247            }
1248            return true;
1249        }
1250        if replay_blocked() {
1251            return true;
1252        }
1253        if direct && let Some(scope) = crate::capsule::current_scope() {
1254            scope.note(DIRECT_WRITE_NOTE);
1255            scope.mark_truncated();
1256        }
1257        false
1258    }
1259
1260    /// Mark the capsule incomplete: the fill fence stopped a fill.
1261    fn fence_stopped_fill() {
1262        if let Some(scope) = crate::capsule::current_scope() {
1263            scope.note(FILL_FENCE_NOTE);
1264            scope.mark_truncated();
1265        }
1266    }
1267
1268    /// Take a tape slot for a removal, in call order.
1269    fn reserve() -> Option<(Arc<crate::capsule::CaptureScope>, usize)> {
1270        crate::capsule::current_scope()
1271            .and_then(|scope| scope.reserve_cache().map(|index| (scope, index)))
1272    }
1273
1274    /// Fill a slot taken with [`reserve`](Self::reserve).
1275    fn fill(
1276        slot: Option<(Arc<crate::capsule::CaptureScope>, usize)>,
1277        effect: crate::capsule::CacheEffect,
1278    ) {
1279        if let Some((scope, index)) = slot {
1280            scope.fill_cache(index, effect);
1281        }
1282    }
1283
1284    /// Run a removal that returns no result. A backend counts a failed one
1285    /// with [`record_invalidation_failure`]. When the count goes up, replay
1286    /// cannot know the result, so the capsule is marked incomplete. A failure
1287    /// on another task can also do this; replay then only refuses.
1288    fn unchecked_removal(remove: impl FnOnce()) {
1289        let before = invalidation_failures_total();
1290        remove();
1291        if invalidation_failures_total() != before
1292            && let Some(scope) = crate::capsule::current_scope()
1293        {
1294            scope.note(UNCHECKED_REMOVAL_NOTE);
1295            scope.mark_truncated();
1296        }
1297    }
1298}
1299
1300/// Set while this process replays a capsule. The capsule seam then never
1301/// reaches a backend, also on a task with no tape (a state initializer, a
1302/// detached task). It is never unset.
1303#[cfg(feature = "reporting")]
1304static REPLAY_BLOCKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1305
1306/// Keep every cache backend offline from now on. `autumn replay` calls it
1307/// before it builds the app (#2351).
1308#[cfg(feature = "reporting")]
1309pub(crate) fn block_backends_for_replay() {
1310    REPLAY_BLOCKED.store(true, std::sync::atomic::Ordering::SeqCst);
1311}
1312
1313#[cfg(feature = "reporting")]
1314fn replay_blocked() -> bool {
1315    #[cfg(test)]
1316    if TEST_REPLAY_BLOCKED.with(std::cell::Cell::get) {
1317        return true;
1318    }
1319    REPLAY_BLOCKED.load(std::sync::atomic::Ordering::SeqCst)
1320}
1321
1322// The process-wide block cannot be set in a unit test: it would block every
1323// other test. This thread's block stands in for it.
1324#[cfg(all(test, feature = "reporting"))]
1325thread_local! {
1326    static TEST_REPLAY_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1327}
1328
1329/// Whether the seam must not reach the backend: a tape is active, or this
1330/// process replays a capsule.
1331#[cfg(feature = "reporting")]
1332fn offline() -> bool {
1333    // Not `tape_active`: that marks the replay scope as entered. Only the
1334    // reporting layer may do that.
1335    crate::capsule::effects::current_tape().is_some() || replay_blocked()
1336}
1337
1338/// Why a capsule with a removal of unknown result is not replayable.
1339#[cfg(feature = "reporting")]
1340const UNCHECKED_REMOVAL_NOTE: &str = "a cache removal with no result reported a failure; \
1341     replay cannot know whether the entry stayed";
1342
1343/// Why a capsule that met a distributed fill lock is not replayable.
1344#[cfg(feature = "reporting")]
1345const FILL_LOCK_NOTE: &str = "a cache fill met a distributed fill lock; the lock outcome \
1346     is not recorded, so replay cannot take the same path";
1347
1348/// Why a capsule whose fill the shared fence stopped is not replayable.
1349#[cfg(feature = "reporting")]
1350const FILL_FENCE_NOTE: &str = "the shared fill fence stopped a cache fill; replay \
1351     has no fence, so it cannot take the same path";
1352
1353/// A recorded [`InvalidationError`].
1354#[cfg(feature = "reporting")]
1355fn recorded_error(
1356    result: &Result<(), InvalidationError>,
1357) -> Option<crate::capsule::CacheInvalidationError> {
1358    result
1359        .as_ref()
1360        .err()
1361        .map(|error| crate::capsule::CacheInvalidationError {
1362            attempts: error.attempts(),
1363            reason: error.reason().to_owned(),
1364        })
1365}
1366
1367#[cfg(feature = "reporting")]
1368impl Cache for CapsuleSeamCache {
1369    // During a replay the backend is never reached. A typed or untyped read
1370    // or write is answered by its own seam before it gets here; anything
1371    // that still arrives is a miss, a dropped write, or no fill lock.
1372    fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
1373        let direct = !HELPER_CALL.with(std::cell::Cell::get);
1374        // A helper read with a tape never gets here, so with a tape this is a
1375        // direct read: a divergence, as capture refuses such a capsule. It
1376        // does not consume a read a helper recorded.
1377        if let Some(tape) = crate::capsule::effects::current_tape() {
1378            if direct {
1379                tape.cache_direct_get(key);
1380            }
1381            return None;
1382        }
1383        if replay_blocked() {
1384            return None;
1385        }
1386        if direct && let Some(scope) = crate::capsule::current_scope() {
1387            scope.note(DIRECT_READ_NOTE);
1388            scope.mark_truncated();
1389        }
1390        self.0.get_value(key)
1391    }
1392
1393    fn insert_value(&self, key: &str, value: Arc<dyn Any + Send + Sync>) {
1394        if Self::direct_write_blocked(key) {
1395            return;
1396        }
1397        self.0.insert_value(key, value);
1398    }
1399
1400    fn try_get_value(
1401        &self,
1402        key: &str,
1403    ) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
1404        let direct = !HELPER_CALL.with(std::cell::Cell::get);
1405        if let Some(tape) = crate::capsule::effects::current_tape() {
1406            if direct {
1407                tape.cache_direct_get(key);
1408            }
1409            return Ok(None);
1410        }
1411        if replay_blocked() {
1412            return Ok(None);
1413        }
1414        if direct && let Some(scope) = crate::capsule::current_scope() {
1415            scope.note(DIRECT_READ_NOTE);
1416            scope.mark_truncated();
1417        }
1418        self.0.try_get_value(key)
1419    }
1420
1421    fn try_insert_raw_bytes(
1422        &self,
1423        key: &str,
1424        bytes: Vec<u8>,
1425        ttl: Option<Duration>,
1426    ) -> Result<(), CacheBackendError> {
1427        if Self::direct_write_blocked(key) {
1428            return Ok(());
1429        }
1430        self.0.try_insert_raw_bytes(key, bytes, ttl)
1431    }
1432
1433    fn try_invalidate(&self, key: &str) -> Result<(), InvalidationError> {
1434        if let Some(tape) = crate::capsule::effects::current_tape() {
1435            return tape.cache_invalidate(key);
1436        }
1437        if replay_blocked() {
1438            return Ok(());
1439        }
1440        // `unchecked_removal` covers a backend that keeps the default
1441        // `try_invalidate` and counts its failure with
1442        // `record_invalidation_failure`. A returned error is recorded below.
1443        let slot = Self::reserve();
1444        let mut result = Ok(());
1445        Self::unchecked_removal(|| result = self.0.try_invalidate(key));
1446        Self::fill(
1447            slot,
1448            crate::capsule::CacheEffect::Invalidate {
1449                key: key.to_owned(),
1450                error: recorded_error(&result),
1451            },
1452        );
1453        result
1454    }
1455
1456    fn invalidate(&self, key: &str) {
1457        if let Some(tape) = crate::capsule::effects::current_tape() {
1458            let _ = tape.cache_invalidate(key);
1459            return;
1460        }
1461        if replay_blocked() {
1462            return;
1463        }
1464        // The slot is taken now and filled after the call, so a backend that
1465        // panics leaves it unfilled and the capsule incomplete.
1466        let slot = Self::reserve();
1467        Self::unchecked_removal(|| self.0.invalidate(key));
1468        Self::fill(
1469            slot,
1470            crate::capsule::CacheEffect::Invalidate {
1471                key: key.to_owned(),
1472                error: None,
1473            },
1474        );
1475    }
1476
1477    fn clear(&self) {
1478        if let Some(tape) = crate::capsule::effects::current_tape() {
1479            tape.cache_clear();
1480            return;
1481        }
1482        if replay_blocked() {
1483            return;
1484        }
1485        let slot = Self::reserve();
1486        Self::unchecked_removal(|| self.0.clear());
1487        Self::fill(slot, crate::capsule::CacheEffect::Clear);
1488    }
1489
1490    fn invalidate_namespace(&self, namespace: &str) -> bool {
1491        if let Some(tape) = crate::capsule::effects::current_tape() {
1492            return tape.cache_invalidate_namespace(namespace).is_ok();
1493        }
1494        if replay_blocked() {
1495            return true;
1496        }
1497        // Coherence records the removal with its registered stores' part.
1498        if COHERENCE_REMOVAL.with(std::cell::Cell::get) {
1499            return self.0.invalidate_namespace(namespace);
1500        }
1501        let slot = Self::reserve();
1502        let done = self.0.invalidate_namespace(namespace);
1503        let result = if done {
1504            Ok(())
1505        } else {
1506            Err(InvalidationError::new(
1507                1,
1508                "the backend cannot drop a namespace, or its sweep failed",
1509            ))
1510        };
1511        Self::fill(
1512            slot,
1513            crate::capsule::CacheEffect::InvalidateNamespace {
1514                namespace: namespace.to_owned(),
1515                error: recorded_error(&result),
1516            },
1517        );
1518        done
1519    }
1520
1521    fn insert_raw_bytes(&self, key: &str, bytes: Vec<u8>, ttl: Option<Duration>) {
1522        if Self::direct_write_blocked(key) {
1523            return;
1524        }
1525        self.0.insert_raw_bytes(key, bytes, ttl);
1526    }
1527
1528    fn shares_fill_epoch(&self) -> bool {
1529        self.0.shares_fill_epoch()
1530    }
1531
1532    // A replay takes the unfenced path. A capture that does not store its
1533    // fill cannot replay that path, so it is incomplete.
1534    fn fill_epoch(&self, namespace: &str) -> FillEpoch {
1535        if offline() {
1536            return FillEpoch::Unsupported;
1537        }
1538        let epoch = self.0.fill_epoch(namespace);
1539        if epoch == FillEpoch::Unavailable {
1540            Self::fence_stopped_fill();
1541        }
1542        epoch
1543    }
1544
1545    fn insert_raw_bytes_if_epoch(
1546        &self,
1547        key: &str,
1548        bytes: Vec<u8>,
1549        ttl: Option<Duration>,
1550        namespace: &str,
1551        sampled: u64,
1552    ) -> bool {
1553        if Self::direct_write_blocked(key) {
1554            return false;
1555        }
1556        let stored = self
1557            .0
1558            .insert_raw_bytes_if_epoch(key, bytes, ttl, namespace, sampled);
1559        if !stored {
1560            Self::fence_stopped_fill();
1561        }
1562        stored
1563    }
1564
1565    fn try_acquire_fill_lock(&self, key: &str, token: &str, ttl: Duration) -> FillLockStatus {
1566        if offline() {
1567            return FillLockStatus::Unsupported;
1568        }
1569        let status = self.0.try_acquire_fill_lock(key, token, ttl);
1570        // Replay answers `Unsupported`, so a lock path cannot replay.
1571        if status != FillLockStatus::Unsupported
1572            && let Some(scope) = crate::capsule::current_scope()
1573        {
1574            scope.note(FILL_LOCK_NOTE);
1575            scope.mark_truncated();
1576        }
1577        status
1578    }
1579
1580    fn release_fill_lock(&self, key: &str, token: &str) {
1581        if offline() {
1582            return;
1583        }
1584        self.0.release_fill_lock(key, token);
1585    }
1586
1587    fn invalidate_async<'a>(
1588        &'a self,
1589        key: &'a str,
1590    ) -> CacheFuture<'a, Result<(), InvalidationError>> {
1591        // The tape and the capture scope are task-locals, so they are read
1592        // here, on the calling task, and carried into the future.
1593        if let Some(tape) = crate::capsule::effects::current_tape() {
1594            return Box::pin(async move { tape.cache_invalidate(key) });
1595        }
1596        if replay_blocked() {
1597            return Box::pin(async { Ok(()) });
1598        }
1599        // The scope is read now; the tape position is taken when the future
1600        // first runs, which is when replay consumes its entry. It is filled
1601        // when the removal ends, so a cancelled removal leaves an unfilled
1602        // slot that marks the capsule incomplete.
1603        let scope = crate::capsule::current_scope();
1604        Box::pin(async move {
1605            let slot = scope.and_then(|scope| scope.reserve_cache().map(|index| (scope, index)));
1606            let result = self.0.invalidate_async(key).await;
1607            if let Some((scope, index)) = slot {
1608                scope.fill_cache(
1609                    index,
1610                    crate::capsule::CacheEffect::Invalidate {
1611                        key: key.to_owned(),
1612                        error: recorded_error(&result),
1613                    },
1614                );
1615            }
1616            result
1617        })
1618    }
1619
1620    fn invalidate_namespace_async<'a>(
1621        &'a self,
1622        namespace: &'a str,
1623    ) -> CacheFuture<'a, Result<(), InvalidationError>> {
1624        if let Some(tape) = crate::capsule::effects::current_tape() {
1625            return Box::pin(async move { tape.cache_invalidate_namespace(namespace) });
1626        }
1627        if replay_blocked() {
1628            return Box::pin(async { Ok(()) });
1629        }
1630        // Coherence records the removal with its registered stores' part.
1631        if COHERENCE_REMOVAL.with(std::cell::Cell::get) {
1632            return self.0.invalidate_namespace_async(namespace);
1633        }
1634        let scope = crate::capsule::current_scope();
1635        Box::pin(async move {
1636            let slot = scope.and_then(|scope| scope.reserve_cache().map(|index| (scope, index)));
1637            let result = self.0.invalidate_namespace_async(namespace).await;
1638            if let Some((scope, index)) = slot {
1639                scope.fill_cache(
1640                    index,
1641                    crate::capsule::CacheEffect::InvalidateNamespace {
1642                        namespace: namespace.to_owned(),
1643                        error: recorded_error(&result),
1644                    },
1645                );
1646            }
1647            result
1648        })
1649    }
1650
1651    fn is_capsule_seam(&self) -> bool {
1652        true
1653    }
1654}
1655
1656// ── CacheableResult trait ────────────────────────────────────────────
1657
1658/// Helper trait used by `#[cached(result)]` to extract the `Ok` type
1659/// from a `Result<T, E>` return type at the type level.
1660///
1661/// This avoids the need for the proc macro to syntactically parse
1662/// generic arguments out of the return type.
1663pub trait CacheableResult {
1664    /// The success type to cache.
1665    type Ok: Clone;
1666    /// The error type (passed through uncached).
1667    type Err;
1668
1669    /// Convert into a standard `Result` for pattern matching.
1670    ///
1671    /// # Errors
1672    ///
1673    /// Returns `Err` if the original result was an error.
1674    fn into_result(self) -> Result<Self::Ok, Self::Err>;
1675    /// Wrap a cached `Ok` value back into the original result type.
1676    fn from_ok(ok: Self::Ok) -> Self;
1677}
1678
1679impl<T: Clone, E> CacheableResult for Result<T, E> {
1680    type Ok = T;
1681    type Err = E;
1682
1683    fn into_result(self) -> Self {
1684        self
1685    }
1686
1687    fn from_ok(ok: T) -> Self {
1688        Ok(ok)
1689    }
1690}
1691
1692// ── Cache key helper ─────────────────────────────────────────────────
1693
1694/// Build a cache key from a function name and its hashable arguments.
1695///
1696/// Used by `#[cached]` macro-generated code. The key is
1697/// `"{fn_name}:{hash_hex}"` where the hash is a 64-bit `DefaultHasher`
1698/// digest of the argument tuple.
1699#[must_use]
1700pub fn make_cache_key<K: Hash>(fn_name: &str, args: &K) -> String {
1701    let mut hasher = DefaultHasher::new();
1702    args.hash(&mut hasher);
1703    format!("{}:{:x}", fn_name, hasher.finish())
1704}
1705
1706#[cfg(test)]
1707mod shared_fence_tests {
1708    //! A fake cross-replica backend: two handles share one store and one epoch.
1709    use super::*;
1710    use std::collections::HashMap;
1711    use std::sync::Mutex;
1712
1713    #[derive(Default)]
1714    struct Shared {
1715        data: HashMap<String, Vec<u8>>,
1716        epochs: HashMap<String, u64>,
1717    }
1718
1719    #[derive(Clone, Default)]
1720    struct Replica {
1721        shared: Arc<Mutex<Shared>>,
1722        epoch_down: bool,
1723        sweep_fails: bool,
1724    }
1725
1726    impl Cache for Replica {
1727        fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
1728            let bytes = self.shared.lock().unwrap().data.get(key).cloned()?;
1729            Some(Arc::new(RawCacheBytes(bytes)))
1730        }
1731        fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {
1732            panic!("a shared-fence backend must not take the unfenced path");
1733        }
1734        fn invalidate(&self, key: &str) {
1735            self.shared.lock().unwrap().data.remove(key);
1736        }
1737        fn clear(&self) {
1738            self.shared.lock().unwrap().data.clear();
1739        }
1740        fn invalidate_namespace(&self, namespace: &str) -> bool {
1741            if self.sweep_fails {
1742                return false;
1743            }
1744            let mut shared = self.shared.lock().unwrap();
1745            *shared.epochs.entry(namespace.to_owned()).or_default() += 1;
1746            let prefix = format!("{namespace}:");
1747            shared.data.retain(|key, _| !key.starts_with(&prefix));
1748            true
1749        }
1750        fn insert_raw_bytes(&self, key: &str, bytes: Vec<u8>, _ttl: Option<Duration>) {
1751            self.shared
1752                .lock()
1753                .unwrap()
1754                .data
1755                .insert(key.to_owned(), bytes);
1756        }
1757        fn shares_fill_epoch(&self) -> bool {
1758            true
1759        }
1760        fn fill_epoch(&self, namespace: &str) -> FillEpoch {
1761            if self.epoch_down {
1762                return FillEpoch::Unavailable;
1763            }
1764            let shared = self.shared.lock().unwrap();
1765            FillEpoch::Sampled(shared.epochs.get(namespace).copied().unwrap_or(0))
1766        }
1767        fn insert_raw_bytes_if_epoch(
1768            &self,
1769            key: &str,
1770            bytes: Vec<u8>,
1771            _ttl: Option<Duration>,
1772            namespace: &str,
1773            sampled: u64,
1774        ) -> bool {
1775            let mut shared = self.shared.lock().unwrap();
1776            if shared.epochs.get(namespace).copied().unwrap_or(0) != sampled {
1777                return false;
1778            }
1779            shared.data.insert(key.to_owned(), bytes);
1780            true
1781        }
1782    }
1783
1784    #[test]
1785    fn a_fill_on_another_replica_cannot_resurrect_an_invalidated_value() {
1786        let a = Replica::default();
1787        let b = Replica {
1788            shared: Arc::clone(&a.shared),
1789            epoch_down: false,
1790            sweep_fails: false,
1791        };
1792        // B misses and samples the shared epoch, then starts computing.
1793        let epoch = b.fill_epoch("ns");
1794        // A commits a write and invalidates the namespace.
1795        assert!(a.invalidate_namespace("ns"));
1796        // B finishes and tries to insert the pre-write value.
1797        let inserted = insert_cached_fenced(&b, "ns:k", "old".to_string(), None, "ns", epoch);
1798        assert!(
1799            !inserted,
1800            "the shared epoch moved, so the fill is fenced out"
1801        );
1802        assert!(get_cached::<String>(&a, "ns:k").is_none());
1803    }
1804
1805    /// Codex review on #3222: during `autumn replay`, the fenced helpers do
1806    /// not reach a raw backend that startup code holds, with no tape.
1807    #[cfg(feature = "reporting")]
1808    #[test]
1809    fn the_replay_block_keeps_the_fenced_helpers_offline() {
1810        let replica = Replica::default();
1811        let live = replica.fill_epoch("ns");
1812        super::TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(true));
1813        let sampled = sample_fill_epoch(&replica, "ns");
1814        let stored = insert_cached_fenced(&replica, "ns:k", "v".to_string(), None, "ns", live);
1815        super::TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(false));
1816        assert_eq!(sampled, FillEpoch::Unsupported, "no epoch read");
1817        assert!(stored, "the replay took the write");
1818        assert!(replica.shared.lock().unwrap().data.is_empty(), "no write");
1819    }
1820
1821    /// Codex review on #3222: a replay keeps the fenced fill's result. A
1822    /// value that cannot be serialized, and an unreadable epoch, are not
1823    /// stored, as in production.
1824    #[cfg(feature = "reporting")]
1825    #[test]
1826    fn a_replay_keeps_the_fenced_fill_result() {
1827        struct Unencodable;
1828        impl serde::Serialize for Unencodable {
1829            fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
1830                Err(serde::ser::Error::custom("cannot encode"))
1831            }
1832        }
1833        impl Clone for Unencodable {
1834            fn clone(&self) -> Self {
1835                Self
1836            }
1837        }
1838        let replica = Replica::default();
1839        super::TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(true));
1840        let unencodable = insert_cached_fenced(
1841            &replica,
1842            "ns:k",
1843            Unencodable,
1844            None,
1845            "ns",
1846            FillEpoch::Sampled(0),
1847        );
1848        let unavailable =
1849            insert_cached_fenced(&replica, "ns:k", 1_u32, None, "ns", FillEpoch::Unavailable);
1850        super::TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(false));
1851        assert!(!unencodable, "the fenced path does not store it");
1852        assert!(!unavailable, "an unreadable epoch stores nothing");
1853        assert!(replica.shared.lock().unwrap().data.is_empty());
1854    }
1855
1856    #[test]
1857    fn a_fill_inserts_when_no_invalidation_landed() {
1858        let a = Replica::default();
1859        let epoch = a.fill_epoch("ns");
1860        assert!(insert_cached_fenced(
1861            &a,
1862            "ns:k",
1863            "new".to_string(),
1864            None,
1865            "ns",
1866            epoch
1867        ));
1868        assert_eq!(get_cached::<String>(&a, "ns:k").as_deref(), Some("new"));
1869    }
1870
1871    #[test]
1872    fn an_unreadable_epoch_skips_the_insert() {
1873        let a = Replica {
1874            epoch_down: true,
1875            ..Replica::default()
1876        };
1877        let epoch = a.fill_epoch("ns");
1878        assert_eq!(epoch, FillEpoch::Unavailable);
1879        assert!(!insert_cached_fenced(
1880            &a,
1881            "ns:k",
1882            "v".to_string(),
1883            None,
1884            "ns",
1885            epoch
1886        ));
1887        assert!(get_cached::<String>(&a, "ns:k").is_none());
1888    }
1889
1890    #[test]
1891    fn a_fragment_render_on_another_replica_cannot_resurrect_an_invalidated_value() {
1892        let a = Replica::default();
1893        let b = Replica {
1894            shared: Arc::clone(&a.shared),
1895            epoch_down: false,
1896            sweep_fails: false,
1897        };
1898        let html = fragment::cache_fragment_in(Some(&b), "ns", "id", "v1", None, || {
1899            // A writes and invalidates while B is still rendering.
1900            assert!(a.invalidate_namespace("ns"));
1901            maud::html! { "old" }
1902        });
1903        assert_eq!(html.0, "old", "the fenced-out caller still gets its markup");
1904        let again = fragment::cache_fragment_in(Some(&b), "ns", "id", "v1", None, || {
1905            maud::html! { "new" }
1906        });
1907        assert_eq!(again.0, "new", "the stale markup must not be cached");
1908    }
1909
1910    /// A caller-owned shared store, registered with `register_namespace_store`,
1911    /// must get its epoch raised by the namespace invalidation. A plain
1912    /// `clear` would sweep it and leave the epoch alone.
1913    #[test]
1914    fn invalidating_a_registered_shared_store_raises_its_epoch() {
1915        let _guard = GLOBAL_CACHE_TEST_LOCK
1916            .lock()
1917            .unwrap_or_else(std::sync::PoisonError::into_inner);
1918        clear_global_cache();
1919        let store = Replica::default();
1920        let handle = Arc::new(store.clone());
1921        let _ = coherence::register_namespace_store("tests::shared_fence_ns", handle);
1922        let before = store.fill_epoch("tests::shared_fence_ns");
1923        assert!(coherence::invalidate_namespace("tests::shared_fence_ns"));
1924        assert_ne!(
1925            store.fill_epoch("tests::shared_fence_ns"),
1926            before,
1927            "an in-flight fill on another replica must be fenced out"
1928        );
1929    }
1930
1931    /// A shared store that fails to bump its epoch makes the result `false`.
1932    /// A store with no shared epoch keeps the old `clear` contract.
1933    #[test]
1934    fn a_failed_registered_shared_store_fails_the_invalidation() {
1935        let _guard = GLOBAL_CACHE_TEST_LOCK
1936            .lock()
1937            .unwrap_or_else(std::sync::PoisonError::into_inner);
1938        clear_global_cache();
1939        let failing = Replica {
1940            sweep_fails: true,
1941            ..Replica::default()
1942        };
1943        let _ = coherence::register_namespace_store("tests::failing_shared_ns", Arc::new(failing));
1944        assert!(!coherence::invalidate_namespace("tests::failing_shared_ns"));
1945    }
1946
1947    #[cfg(feature = "cache-moka")]
1948    #[test]
1949    fn a_registered_store_without_a_shared_epoch_still_counts_as_cleared() {
1950        let _guard = GLOBAL_CACHE_TEST_LOCK
1951            .lock()
1952            .unwrap_or_else(std::sync::PoisonError::into_inner);
1953        clear_global_cache();
1954        let _ = coherence::register_namespace_store(
1955            "tests::plain_ns",
1956            Arc::new(MokaCache::new(4, None)),
1957        );
1958        assert!(coherence::invalidate_namespace("tests::plain_ns"));
1959    }
1960
1961    /// A replay serves cache effects from the tape. It must not read the live
1962    /// epoch, and the fenced write must land on the tape (#1634).
1963    #[cfg(feature = "reporting")]
1964    #[tokio::test]
1965    async fn a_replay_never_touches_the_live_epoch_and_still_writes_the_tape() {
1966        use crate::capsule::{
1967            CapsuleEffects,
1968            effects::{CachedValue, ReplayEffects},
1969        };
1970        let live = Replica {
1971            epoch_down: true,
1972            ..Replica::default()
1973        };
1974        let tape = std::sync::Arc::new(ReplayEffects::new(CapsuleEffects::default()));
1975        let seen = std::sync::Arc::clone(&tape);
1976        crate::capsule::effects::with_effect_tape(tape, async {
1977            let epoch = sample_fill_epoch(&live, "ns");
1978            assert_eq!(
1979                epoch,
1980                FillEpoch::Unsupported,
1981                "replay must skip the backend"
1982            );
1983            assert!(insert_cached_fenced(
1984                &live, "ns:k", 7_u32, None, "ns", epoch
1985            ));
1986        })
1987        .await;
1988        assert!(
1989            live.shared.lock().unwrap().data.is_empty(),
1990            "a replay must not write the live backend"
1991        );
1992        assert!(matches!(seen.cache_get("ns:k"), CachedValue::Hit(_)));
1993    }
1994
1995    #[cfg(feature = "reporting")]
1996    fn capture_scope() -> Arc<crate::capsule::CaptureScope> {
1997        Arc::new(crate::capsule::CaptureScope::new(
1998            "fence-test".to_owned(),
1999            Arc::new(crate::capsule::CaptureSettings::default()),
2000            Arc::new(crate::log::filter::ParameterFilter::default()),
2001        ))
2002    }
2003
2004    /// The capsule seam wraps every installed backend. It must keep the
2005    /// shared fence of the backend.
2006    #[cfg(feature = "reporting")]
2007    #[test]
2008    fn the_capsule_seam_keeps_the_shared_fence() {
2009        let a = Replica::default();
2010        let b = with_capsule_seam(Arc::new(Replica {
2011            shared: Arc::clone(&a.shared),
2012            ..Replica::default()
2013        }));
2014        assert!(b.shares_fill_epoch());
2015        let epoch = sample_fill_epoch(b.as_ref(), "ns");
2016        assert_eq!(epoch, FillEpoch::Sampled(0));
2017        assert!(a.invalidate_namespace("ns"));
2018        assert!(!insert_cached_fenced(
2019            b.as_ref(),
2020            "ns:k",
2021            "old".to_string(),
2022            None,
2023            "ns",
2024            epoch
2025        ));
2026        assert!(a.shared.lock().unwrap().data.is_empty());
2027    }
2028
2029    /// A fenced fill under capture is recorded. A fill the fence stops, or
2030    /// an epoch that cannot be read, is not recorded and replay takes the
2031    /// unfenced path, so the capsule is incomplete.
2032    #[cfg(feature = "reporting")]
2033    #[test]
2034    fn a_fill_the_fence_stops_marks_the_capsule_incomplete() {
2035        use futures::executor::block_on;
2036        let fill = |replica: Replica, invalidate: bool| {
2037            let shared = Arc::clone(&replica.shared);
2038            let cache = with_capsule_seam(Arc::new(replica));
2039            let scope = capture_scope();
2040            block_on(crate::capsule::capture::with_capture_scope(
2041                Arc::clone(&scope),
2042                async {
2043                    let epoch = sample_fill_epoch(cache.as_ref(), "ns");
2044                    if invalidate {
2045                        shared.lock().unwrap().epochs.insert("ns".to_owned(), 9);
2046                    }
2047                    let _ = insert_cached_fenced(cache.as_ref(), "ns:k", 1_u32, None, "ns", epoch);
2048                },
2049            ));
2050            scope
2051        };
2052        let stored = fill(Replica::default(), false);
2053        assert!(!stored.is_truncated(), "a stored fill is replayable");
2054        assert_eq!(stored.effects_snapshot().cache.len(), 1);
2055        assert!(fill(Replica::default(), true).is_truncated());
2056        let down = Replica {
2057            epoch_down: true,
2058            ..Replica::default()
2059        };
2060        assert!(fill(down, false).is_truncated());
2061    }
2062
2063    /// A direct fenced write is like a direct `insert_raw_bytes`.
2064    #[cfg(feature = "reporting")]
2065    #[test]
2066    fn a_direct_fenced_write_marks_the_capsule_incomplete() {
2067        use futures::executor::block_on;
2068        let cache = with_capsule_seam(Arc::new(Replica::default()));
2069        let scope = capture_scope();
2070        block_on(crate::capsule::capture::with_capture_scope(
2071            Arc::clone(&scope),
2072            async {
2073                let _ = cache.insert_raw_bytes_if_epoch("ns:k", b"1".to_vec(), None, "ns", 0);
2074            },
2075        ));
2076        assert!(scope.is_truncated());
2077    }
2078
2079    #[cfg(feature = "cache-moka")]
2080    #[test]
2081    fn a_backend_without_a_shared_epoch_inserts_as_before() {
2082        let moka = MokaCache::new(10, None);
2083        let epoch = moka.fill_epoch("ns");
2084        assert_eq!(epoch, FillEpoch::Unsupported);
2085        assert!(insert_cached_fenced(
2086            &moka,
2087            "ns:k",
2088            "v".to_string(),
2089            None,
2090            "ns",
2091            epoch
2092        ));
2093        assert_eq!(get_cached::<String>(&moka, "ns:k").as_deref(), Some("v"));
2094    }
2095}
2096
2097#[cfg(test)]
2098mod tests {
2099    use super::*;
2100
2101    #[test]
2102    fn cache_key_deterministic() {
2103        let k1 = make_cache_key("get_user", &(42_i64,));
2104        let k2 = make_cache_key("get_user", &(42_i64,));
2105        assert_eq!(k1, k2);
2106    }
2107
2108    #[test]
2109    fn cache_key_differs_by_fn_name() {
2110        let k1 = make_cache_key("get_user", &(42_i64,));
2111        let k2 = make_cache_key("find_user", &(42_i64,));
2112        assert_ne!(k1, k2);
2113    }
2114
2115    #[test]
2116    fn cache_key_differs_by_args() {
2117        let k1 = make_cache_key("get_user", &(1_i64,));
2118        let k2 = make_cache_key("get_user", &(2_i64,));
2119        assert_ne!(k1, k2);
2120    }
2121
2122    #[test]
2123    fn cache_key_no_args() {
2124        let k = make_cache_key("get_config", &());
2125        assert!(k.starts_with("get_config:"));
2126    }
2127
2128    #[cfg(feature = "cache-moka")]
2129    #[test]
2130    fn insert_cached_and_get_cached_round_trip() {
2131        let cache = MokaCache::new(10, None);
2132        insert_cached(&cache, "key", "hello".to_string(), None);
2133        let val: Option<String> = get_cached(&cache, "key");
2134        assert_eq!(val.as_deref(), Some("hello"));
2135    }
2136
2137    #[cfg(feature = "cache-moka")]
2138    #[test]
2139    fn get_cached_raw_bytes_slow_path() {
2140        // Simulate a cross-replica backend: store RawCacheBytes directly, then
2141        // verify get_cached deserializes it back to the concrete type.
2142        let cache = MokaCache::new(10, None);
2143        let bytes = serde_json::to_vec(&42_i32).unwrap();
2144        cache.insert_value("k", Arc::new(RawCacheBytes(bytes)));
2145        let val: Option<i32> = get_cached(&cache, "k");
2146        assert_eq!(val, Some(42));
2147    }
2148
2149    #[cfg(feature = "cache-moka")]
2150    #[test]
2151    fn get_cached_miss_returns_none() {
2152        let cache = MokaCache::new(10, None);
2153        let val: Option<String> = get_cached(&cache, "missing");
2154        assert!(val.is_none());
2155    }
2156
2157    /// A backend that keeps only the required methods, to test the defaults.
2158    struct MinimalBackend {
2159        invalidated: std::sync::Mutex<Vec<String>>,
2160    }
2161
2162    impl Cache for MinimalBackend {
2163        fn get_value(&self, _key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
2164            None
2165        }
2166        fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {}
2167        fn invalidate(&self, key: &str) {
2168            self.invalidated
2169                .lock()
2170                .unwrap_or_else(std::sync::PoisonError::into_inner)
2171                .push(key.to_owned());
2172        }
2173        fn clear(&self) {}
2174    }
2175
2176    #[test]
2177    fn default_try_methods_delegate_and_succeed() {
2178        let backend = MinimalBackend {
2179            invalidated: std::sync::Mutex::new(Vec::new()),
2180        };
2181        assert!(matches!(backend.try_get_value("k"), Ok(None)));
2182        assert!(backend.try_insert_raw_bytes("k", vec![1], None).is_ok());
2183        backend
2184            .try_invalidate("k")
2185            .expect("the default cannot fail");
2186        assert_eq!(
2187            *backend
2188                .invalidated
2189                .lock()
2190                .unwrap_or_else(std::sync::PoisonError::into_inner),
2191            ["k"],
2192            "the default try_invalidate must reach invalidate"
2193        );
2194    }
2195
2196    /// Fails every `try_*` read and write.
2197    struct DownBackend;
2198
2199    impl Cache for DownBackend {
2200        fn get_value(&self, _key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
2201            None
2202        }
2203        fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {}
2204        fn invalidate(&self, _key: &str) {}
2205        fn clear(&self) {}
2206        fn try_get_value(
2207            &self,
2208            _key: &str,
2209        ) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
2210            Err(CacheBackendError::new("down"))
2211        }
2212        fn try_insert_raw_bytes(
2213            &self,
2214            _key: &str,
2215            _bytes: Vec<u8>,
2216            _ttl: Option<Duration>,
2217        ) -> Result<(), CacheBackendError> {
2218            Err(CacheBackendError::new("down"))
2219        }
2220        fn try_invalidate(&self, _key: &str) -> Result<(), InvalidationError> {
2221            Err(InvalidationError::new(1, "down"))
2222        }
2223    }
2224
2225    /// The capsule seam must pass a backend failure through, and must not mark
2226    /// a helper call as a direct one.
2227    #[cfg(feature = "reporting")]
2228    #[test]
2229    fn the_capsule_seam_passes_try_failures_through() {
2230        let cache = with_capsule_seam(Arc::new(DownBackend));
2231        for op in [
2232            (|c: &dyn Cache| assert!(try_insert_cached(c, "k", 1_i64, None).is_err()))
2233                as fn(&dyn Cache),
2234            |c| assert!(try_get_cached::<i64>(c, "k").is_err()),
2235        ] {
2236            // A failed read or write cannot replay, so it marks the capsule.
2237            let scope = capture_scope();
2238            block_on(crate::capsule::capture::with_capture_scope(
2239                Arc::clone(&scope),
2240                async { op(cache.as_ref()) },
2241            ));
2242            assert!(scope.is_truncated(), "a failed cache op is not replayable");
2243        }
2244        // A failed removal is recorded with its error, so replay can match it.
2245        let scope = capture_scope();
2246        block_on(crate::capsule::capture::with_capture_scope(
2247            Arc::clone(&scope),
2248            async { assert!(cache.try_invalidate("k").is_err()) },
2249        ));
2250        let _ = scope.effects_snapshot();
2251        assert!(!scope.is_truncated(), "a recorded removal error replays");
2252
2253        // Control: a direct call is not a helper call, so it marks the capsule.
2254        let direct = capture_scope();
2255        block_on(crate::capsule::capture::with_capture_scope(
2256            Arc::clone(&direct),
2257            async {
2258                let _ = cache.try_get_value("k");
2259            },
2260        ));
2261        assert!(direct.is_truncated(), "a direct read is not recorded");
2262    }
2263
2264    /// A value whose `Serialize` always fails.
2265    #[derive(Clone)]
2266    struct Unserializable;
2267
2268    impl serde::Serialize for Unserializable {
2269        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
2270            Err(serde::ser::Error::custom("no"))
2271        }
2272    }
2273
2274    #[test]
2275    fn try_insert_cached_reports_a_serialization_failure() {
2276        let cache = MokaCache::new(8, None);
2277        let err = try_insert_cached(&cache, "k", Unserializable, None).expect_err("no bytes");
2278        assert!(err.reason().contains("cannot be serialized"));
2279    }
2280
2281    #[test]
2282    fn try_cached_helpers_report_backend_failure() {
2283        let err = try_insert_cached(&DownBackend, "k", 1_i64, None).expect_err("write failed");
2284        assert_eq!(err.reason(), "down");
2285        let err = try_get_cached::<i64>(&DownBackend, "k").expect_err("read failed");
2286        assert_eq!(err.reason(), "down");
2287    }
2288
2289    /// Holds bytes that are not JSON.
2290    struct CorruptBackend;
2291
2292    impl Cache for CorruptBackend {
2293        fn get_value(&self, _key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
2294            None
2295        }
2296        fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {}
2297        fn invalidate(&self, _key: &str) {}
2298        fn clear(&self) {}
2299        fn try_get_value(
2300            &self,
2301            _key: &str,
2302        ) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
2303            Ok(Some(Arc::new(RawCacheBytes(b"not json".to_vec()))))
2304        }
2305    }
2306
2307    #[test]
2308    fn try_get_cached_reports_bytes_it_cannot_decode() {
2309        let err = try_get_cached::<i64>(&CorruptBackend, "k").expect_err("corrupt bytes");
2310        assert!(err.reason().contains("cannot be decoded"), "{err}");
2311    }
2312
2313    /// A decode fault marks the capsule, as a backend fault does.
2314    #[cfg(feature = "reporting")]
2315    #[test]
2316    fn a_decode_fault_marks_the_capsule_incomplete() {
2317        let scope = capture_scope();
2318        block_on(crate::capsule::capture::with_capture_scope(
2319            Arc::clone(&scope),
2320            async {
2321                assert!(try_get_cached::<i64>(&CorruptBackend, "k").is_err());
2322            },
2323        ));
2324        assert!(scope.is_truncated());
2325    }
2326
2327    #[test]
2328    fn try_cached_helpers_round_trip_in_process() {
2329        let cache = MokaCache::new(8, None);
2330        try_insert_cached(&cache, "k", 7_i64, None).expect("stored");
2331        assert_eq!(try_get_cached::<i64>(&cache, "k").expect("read"), Some(7));
2332        assert_eq!(try_get_cached::<i64>(&cache, "gone").expect("read"), None);
2333    }
2334
2335    fn block_on<F: std::future::Future>(future: F) -> F::Output {
2336        tokio::runtime::Builder::new_current_thread()
2337            .build()
2338            .expect("runtime")
2339            .block_on(future)
2340    }
2341
2342    #[test]
2343    fn default_invalidate_async_calls_the_sync_method() {
2344        let backend = MinimalBackend {
2345            invalidated: std::sync::Mutex::new(Vec::new()),
2346        };
2347        block_on(backend.invalidate_async("k")).expect("the default cannot fail");
2348        assert_eq!(
2349            *backend
2350                .invalidated
2351                .lock()
2352                .unwrap_or_else(std::sync::PoisonError::into_inner),
2353            vec!["k".to_owned()]
2354        );
2355    }
2356
2357    #[test]
2358    fn default_invalidate_namespace_async_reports_an_unsupported_backend() {
2359        let backend = MinimalBackend {
2360            invalidated: std::sync::Mutex::new(Vec::new()),
2361        };
2362        let err = block_on(backend.invalidate_namespace_async("ns"))
2363            .expect_err("a backend that cannot sweep must say so");
2364        assert_eq!(err.attempts(), 1);
2365    }
2366
2367    /// Logs every call that reaches the backend.
2368    #[cfg(feature = "reporting")]
2369    #[derive(Default)]
2370    struct SpyBackend {
2371        calls: std::sync::Mutex<Vec<String>>,
2372    }
2373
2374    #[cfg(feature = "reporting")]
2375    impl SpyBackend {
2376        fn log(&self, call: impl Into<String>) {
2377            self.calls
2378                .lock()
2379                .unwrap_or_else(std::sync::PoisonError::into_inner)
2380                .push(call.into());
2381        }
2382        fn calls(&self) -> Vec<String> {
2383            self.calls
2384                .lock()
2385                .unwrap_or_else(std::sync::PoisonError::into_inner)
2386                .clone()
2387        }
2388    }
2389
2390    #[cfg(feature = "reporting")]
2391    impl Cache for SpyBackend {
2392        fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
2393            self.log(format!("get {key}"));
2394            Some(Arc::new(7_u32))
2395        }
2396        fn insert_value(&self, key: &str, _value: Arc<dyn Any + Send + Sync>) {
2397            self.log(format!("insert {key}"));
2398        }
2399        fn invalidate(&self, key: &str) {
2400            self.log(format!("invalidate {key}"));
2401        }
2402        fn clear(&self) {
2403            self.log("clear");
2404        }
2405        fn invalidate_namespace(&self, namespace: &str) -> bool {
2406            self.log(format!("invalidate_namespace {namespace}"));
2407            true
2408        }
2409    }
2410
2411    /// #2351 item 2: a replayed run never reaches the installed backend
2412    /// through the untyped functions or the removal methods.
2413    #[cfg(feature = "reporting")]
2414    #[test]
2415    fn a_replayed_run_never_reaches_the_installed_backend() {
2416        let spy = Arc::new(SpyBackend::default());
2417        let state =
2418            crate::state::AppState::for_test().with_cache(Arc::clone(&spy) as Arc<dyn Cache>);
2419        let tape = Arc::new(crate::capsule::ReplayEffects::new(
2420            crate::capsule::CapsuleEffects::default(),
2421        ));
2422        block_on(crate::capsule::with_effect_tape(Arc::clone(&tape), async {
2423            let cache = state.cache().expect("installed");
2424            cache.invalidate("widgets");
2425            cache.clear();
2426            let _ = cache.invalidate_namespace("widgets");
2427            let _ = cache.invalidate_async("widgets").await;
2428            insert(cache.as_ref(), "widgets", 41_u32);
2429            assert_eq!(get::<u32>(cache.as_ref(), "widgets"), None);
2430        }));
2431        assert!(spy.calls().is_empty(), "{:?}", spy.calls());
2432        assert_eq!(tape.divergences().len(), 6, "{:?}", tape.divergences());
2433    }
2434
2435    /// #2351 item 2: capture records a removal, and the backend still gets it.
2436    #[cfg(feature = "reporting")]
2437    #[test]
2438    fn a_captured_removal_is_recorded_and_still_applied() {
2439        let spy = Arc::new(SpyBackend::default());
2440        let state =
2441            crate::state::AppState::for_test().with_cache(Arc::clone(&spy) as Arc<dyn Cache>);
2442        let scope = Arc::new(crate::capsule::CaptureScope::new(
2443            "cache-test".to_owned(),
2444            Arc::new(crate::capsule::CaptureSettings::default()),
2445            Arc::new(crate::log::filter::ParameterFilter::default()),
2446        ));
2447        block_on(crate::capsule::capture::with_capture_scope(
2448            Arc::clone(&scope),
2449            async {
2450                let cache = state.cache().expect("installed");
2451                cache.invalidate("widgets");
2452                cache.clear();
2453            },
2454        ));
2455        assert_eq!(spy.calls(), vec!["invalidate widgets", "clear"]);
2456        assert_eq!(
2457            scope.effects_snapshot().cache,
2458            vec![
2459                crate::capsule::CacheEffect::Invalidate {
2460                    key: "widgets".to_owned(),
2461                    error: None,
2462                },
2463                crate::capsule::CacheEffect::Clear,
2464            ]
2465        );
2466    }
2467
2468    /// #2351 item 2: an untyped hit cannot be serialized, so the capsule says
2469    /// it is incomplete.
2470    #[cfg(feature = "reporting")]
2471    #[test]
2472    fn a_captured_untyped_hit_or_write_marks_the_capsule_incomplete() {
2473        let spy = Arc::new(SpyBackend::default());
2474        let scope = Arc::new(crate::capsule::CaptureScope::new(
2475            "cache-test".to_owned(),
2476            Arc::new(crate::capsule::CaptureSettings::default()),
2477            Arc::new(crate::log::filter::ParameterFilter::default()),
2478        ));
2479        block_on(crate::capsule::capture::with_capture_scope(
2480            Arc::clone(&scope),
2481            async {
2482                assert_eq!(get::<u32>(spy.as_ref(), "widgets"), Some(7));
2483            },
2484        ));
2485        assert!(scope.is_truncated());
2486        let scope = Arc::new(crate::capsule::CaptureScope::new(
2487            "cache-test".to_owned(),
2488            Arc::new(crate::capsule::CaptureSettings::default()),
2489            Arc::new(crate::log::filter::ParameterFilter::default()),
2490        ));
2491        block_on(crate::capsule::capture::with_capture_scope(
2492            Arc::clone(&scope),
2493            async {
2494                insert(spy.as_ref(), "widgets", 1_u32);
2495            },
2496        ));
2497        assert!(scope.is_truncated());
2498    }
2499
2500    /// Codex review on #3222: a direct `get_value` during a replay is an
2501    /// unrecorded read. It does not consume a read a helper recorded:
2502    /// capture refuses a capsule with a direct read.
2503    #[cfg(feature = "reporting")]
2504    #[test]
2505    fn a_direct_read_on_replay_is_a_divergence_not_a_helper_read() {
2506        let cache = with_capsule_seam(Arc::new(SpyBackend::default()) as Arc<dyn Cache>);
2507        let recorded = crate::capsule::CapsuleEffects {
2508            cache: vec![crate::capsule::CacheEffect::Get {
2509                key: "user:7".to_owned(),
2510                value: None,
2511            }],
2512            ..Default::default()
2513        };
2514        let tape = Arc::new(crate::capsule::ReplayEffects::new(recorded));
2515        block_on(crate::capsule::with_effect_tape(Arc::clone(&tape), async {
2516            assert!(cache.get_value("user:7").is_none());
2517        }));
2518        let finished = tape.finish();
2519        assert!(
2520            finished
2521                .iter()
2522                .any(|divergence| divergence.actual.contains("direct cache read")),
2523            "{finished:?}"
2524        );
2525        assert!(
2526            !finished
2527                .iter()
2528                .any(|divergence| divergence.actual.contains("user:7")),
2529            "the key is withheld: {finished:?}"
2530        );
2531        assert!(
2532            finished
2533                .iter()
2534                .any(|divergence| divergence.kind
2535                    == crate::capsule::EffectDivergenceKind::Unconsumed),
2536            "the helper's recorded read is still owed: {finished:?}"
2537        );
2538    }
2539
2540    /// Review fix: during a replay the wrapper reaches the backend for no
2541    /// read, write or fill lock.
2542    #[cfg(feature = "reporting")]
2543    #[test]
2544    fn the_wrapper_reaches_no_backend_during_a_replay() {
2545        let spy = Arc::new(SpyBackend::default());
2546        let cache = with_capsule_seam(Arc::clone(&spy) as Arc<dyn Cache>);
2547        let tape = Arc::new(crate::capsule::ReplayEffects::new(
2548            crate::capsule::CapsuleEffects::default(),
2549        ));
2550        block_on(crate::capsule::with_effect_tape(Arc::clone(&tape), async {
2551            assert!(cache.get_value("k").is_none());
2552            cache.insert_value("k", Arc::new(1_u32));
2553            cache.insert_raw_bytes("k", b"1".to_vec(), None);
2554            assert_eq!(
2555                cache.try_acquire_fill_lock("k", "t", Duration::from_secs(1)),
2556                FillLockStatus::Unsupported
2557            );
2558            cache.release_fill_lock("k", "t");
2559            assert_eq!(cache.fill_epoch("ns"), FillEpoch::Unsupported);
2560        }));
2561        assert!(spy.calls().is_empty(), "{:?}", spy.calls());
2562        // Codex review on #3222: only the reporting layer enters the replay
2563        // scope. A cache call must not, or the missing-layer warning is lost.
2564        assert!(!tape.scope_entered());
2565    }
2566
2567    /// An async removal takes its tape position when it first runs, the
2568    /// point where replay consumes its entry (Codex review on #3222).
2569    #[cfg(feature = "reporting")]
2570    #[test]
2571    fn an_async_removal_is_recorded_when_it_first_runs() {
2572        let spy = Arc::new(SpyBackend::default());
2573        let cache = with_capsule_seam(Arc::clone(&spy) as Arc<dyn Cache>);
2574        let scope = Arc::new(crate::capsule::CaptureScope::new(
2575            "cache-test".to_owned(),
2576            Arc::new(crate::capsule::CaptureSettings::default()),
2577            Arc::new(crate::log::filter::ParameterFilter::default()),
2578        ));
2579        block_on(crate::capsule::capture::with_capture_scope(
2580            Arc::clone(&scope),
2581            async {
2582                let first = cache.invalidate_async("a");
2583                cache.clear();
2584                first.await.expect("removed");
2585            },
2586        ));
2587        assert_eq!(
2588            scope.effects_snapshot().cache,
2589            vec![
2590                crate::capsule::CacheEffect::Clear,
2591                crate::capsule::CacheEffect::Invalidate {
2592                    key: "a".to_owned(),
2593                    error: None,
2594                },
2595            ]
2596        );
2597    }
2598
2599    /// Codex review on #3222: when production had a global cache, a replay
2600    /// answers a repository invalidation from the tape, so it is consumed.
2601    #[cfg(feature = "reporting")]
2602    #[test]
2603    fn replay_installs_a_seam_cache_when_the_builder_had_one() {
2604        let _guard = GLOBAL_CACHE_TEST_LOCK
2605            .lock()
2606            .unwrap_or_else(std::sync::PoisonError::into_inner);
2607        let recorded = crate::capsule::CapsuleEffects {
2608            cache: vec![crate::capsule::CacheEffect::InvalidateNamespace {
2609                namespace: "posts".to_owned(),
2610                error: None,
2611            }],
2612            state_cache: true,
2613            ..Default::default()
2614        };
2615        let installed = install_replay_cache(true).expect("installed");
2616        assert!(installed.is_capsule_seam());
2617        assert!(Arc::ptr_eq(
2618            &installed,
2619            &global_cache().expect("the global cache")
2620        ));
2621        let tape = Arc::new(crate::capsule::ReplayEffects::new(recorded));
2622        let complete = block_on(crate::capsule::with_effect_tape(
2623            Arc::clone(&tape),
2624            coherence::invalidate_namespace_async("posts"),
2625        ));
2626        clear_global_cache();
2627        assert!(complete);
2628        assert!(tape.finish().is_empty(), "{:?}", tape.finish());
2629
2630        assert!(install_replay_cache(false).is_none());
2631        assert!(global_cache().is_none());
2632    }
2633
2634    /// Codex review on #3222: a cache only a startup hook installed is put
2635    /// in place after the state initializers, as production's initializers
2636    /// did not see it. A cache an initializer installed is kept.
2637    #[cfg(feature = "reporting")]
2638    #[test]
2639    fn a_startup_hook_cache_is_installed_after_the_initializers() {
2640        let _guard = GLOBAL_CACHE_TEST_LOCK
2641            .lock()
2642            .unwrap_or_else(std::sync::PoisonError::into_inner);
2643        let recorded = crate::capsule::CapsuleEffects {
2644            state_cache: true,
2645            ..Default::default()
2646        };
2647        let state = crate::state::AppState::for_test();
2648        install_late_replay_cache(&state, &recorded);
2649        let late = state.cache().expect("installed after the initializers");
2650        assert!(late.is_capsule_seam());
2651
2652        let initializer = crate::state::AppState::for_test();
2653        initializer.set_cache(Arc::new(SpyBackend::default()));
2654        let kept = initializer.cache().expect("the initializer's cache");
2655        install_late_replay_cache(&initializer, &recorded);
2656        assert!(Arc::ptr_eq(
2657            &kept,
2658            &initializer.cache().expect("still there")
2659        ));
2660
2661        let none = crate::state::AppState::for_test();
2662        install_late_replay_cache(&none, &crate::capsule::CapsuleEffects::default());
2663        clear_global_cache();
2664        assert!(none.cache().is_none());
2665    }
2666
2667    /// A backend whose sync removals fail, and whose fill lock is held.
2668    #[cfg(feature = "reporting")]
2669    struct FailingSyncBackend;
2670
2671    #[cfg(feature = "reporting")]
2672    impl Cache for FailingSyncBackend {
2673        fn get_value(&self, _key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
2674            None
2675        }
2676        fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {}
2677        fn invalidate(&self, _key: &str) {
2678            record_invalidation_failure();
2679        }
2680        fn clear(&self) {
2681            record_invalidation_failure();
2682        }
2683        fn try_acquire_fill_lock(
2684            &self,
2685            _key: &str,
2686            _token: &str,
2687            _ttl: Duration,
2688        ) -> FillLockStatus {
2689            FillLockStatus::Held
2690        }
2691    }
2692
2693    #[cfg(feature = "reporting")]
2694    fn capture_scope() -> Arc<crate::capsule::CaptureScope> {
2695        Arc::new(crate::capsule::CaptureScope::new(
2696            "cache-test".to_owned(),
2697            Arc::new(crate::capsule::CaptureSettings::default()),
2698            Arc::new(crate::log::filter::ParameterFilter::default()),
2699        ))
2700    }
2701
2702    /// Codex review on #3222: a sync removal that reports a failure marks
2703    /// the capsule incomplete.
2704    #[cfg(feature = "reporting")]
2705    #[test]
2706    fn a_failed_sync_removal_marks_the_capsule_incomplete() {
2707        for remove in [
2708            (|cache: &dyn Cache| cache.invalidate("k")) as fn(&dyn Cache),
2709            |cache: &dyn Cache| cache.clear(),
2710        ] {
2711            let cache = with_capsule_seam(Arc::new(FailingSyncBackend));
2712            let scope = capture_scope();
2713            block_on(crate::capsule::capture::with_capture_scope(
2714                Arc::clone(&scope),
2715                async { remove(cache.as_ref()) },
2716            ));
2717            assert!(scope.is_truncated());
2718        }
2719    }
2720
2721    /// Codex review on #3222: a fill lock outcome other than `Unsupported`
2722    /// marks the capsule incomplete.
2723    #[cfg(feature = "reporting")]
2724    #[test]
2725    fn a_distributed_fill_lock_marks_the_capsule_incomplete() {
2726        let cache = with_capsule_seam(Arc::new(FailingSyncBackend));
2727        let scope = capture_scope();
2728        let status = block_on(crate::capsule::capture::with_capture_scope(
2729            Arc::clone(&scope),
2730            async { cache.try_acquire_fill_lock("k", "t", Duration::from_secs(1)) },
2731        ));
2732        assert_eq!(status, FillLockStatus::Held);
2733        assert!(scope.is_truncated());
2734
2735        let moka = with_capsule_seam(Arc::new(SpyBackend::default()));
2736        let scope = capture_scope();
2737        block_on(crate::capsule::capture::with_capture_scope(
2738            Arc::clone(&scope),
2739            async {
2740                let _ = moka.try_acquire_fill_lock("k", "t", Duration::from_secs(1));
2741            },
2742        ));
2743        assert!(
2744            !scope.is_truncated(),
2745            "a backend with no lock is replayable"
2746        );
2747    }
2748
2749    /// Codex review on #3222: a cache installed by any route is recorded,
2750    /// a startup hook (`RedisCachePlugin`) included. Replay runs no startup
2751    /// hook, so it must install a cache in its place.
2752    #[cfg(feature = "reporting")]
2753    #[test]
2754    fn a_cache_from_any_install_route_is_recorded() {
2755        let _guard = GLOBAL_CACHE_TEST_LOCK
2756            .lock()
2757            .unwrap_or_else(std::sync::PoisonError::into_inner);
2758        let builder = crate::state::AppState::for_test()
2759            .with_cache(Arc::new(SpyBackend::default()) as Arc<dyn Cache>);
2760        assert!(builder.has_cache());
2761        let startup = crate::state::AppState::for_test();
2762        assert!(!startup.has_cache());
2763        startup.set_cache(Arc::new(SpyBackend::default()));
2764        clear_global_cache();
2765        assert!(startup.has_cache());
2766    }
2767
2768    /// Codex review on #3222: during `autumn replay`, a cache call with no
2769    /// tape (a state initializer) does not reach the backend.
2770    #[cfg(feature = "reporting")]
2771    #[test]
2772    fn the_replay_block_keeps_the_backend_offline_with_no_tape() {
2773        let spy = Arc::new(SpyBackend::default());
2774        let cache = with_capsule_seam(Arc::clone(&spy) as Arc<dyn Cache>);
2775        TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(true));
2776        block_on(async {
2777            assert!(cache.get_value("k").is_none());
2778            cache.insert_value("k", Arc::new(1_u32));
2779            cache.insert_raw_bytes("k", b"1".to_vec(), None);
2780            cache.invalidate("k");
2781            cache.clear();
2782            assert!(cache.invalidate_namespace("ns"));
2783            assert!(cache.invalidate_async("k").await.is_ok());
2784            assert!(cache.invalidate_namespace_async("ns").await.is_ok());
2785            assert_eq!(
2786                cache.try_acquire_fill_lock("k", "t", Duration::from_secs(1)),
2787                FillLockStatus::Unsupported
2788            );
2789        });
2790        TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(false));
2791        assert!(spy.calls().is_empty(), "{:?}", spy.calls());
2792    }
2793
2794    /// Codex review on #3222: during `autumn replay`, the cache helpers do
2795    /// not reach a raw backend that startup code holds, with no tape.
2796    #[cfg(feature = "reporting")]
2797    #[test]
2798    fn the_replay_block_keeps_a_raw_backend_offline_in_the_helpers() {
2799        let spy = SpyBackend::default();
2800        TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(true));
2801        assert_eq!(get::<u32>(&spy, "k"), None);
2802        insert(&spy, "k", 1_u32);
2803        assert_eq!(get_cached::<u32>(&spy, "k"), None);
2804        insert_cached(&spy, "k", 1_u32, None);
2805        TEST_REPLAY_BLOCKED.with(|blocked| blocked.set(false));
2806        assert!(spy.calls().is_empty(), "{:?}", spy.calls());
2807    }
2808
2809    /// A backend whose sync removals panic.
2810    #[cfg(feature = "reporting")]
2811    struct PanickingBackend;
2812
2813    #[cfg(feature = "reporting")]
2814    impl Cache for PanickingBackend {
2815        fn get_value(&self, _key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
2816            None
2817        }
2818        fn insert_value(&self, _key: &str, _value: Arc<dyn Any + Send + Sync>) {}
2819        fn invalidate(&self, _key: &str) {
2820            panic!("backend failed");
2821        }
2822        fn clear(&self) {
2823            panic!("backend failed");
2824        }
2825        fn invalidate_namespace(&self, _namespace: &str) -> bool {
2826            panic!("backend failed");
2827        }
2828    }
2829
2830    /// Codex review on #3222: a sync removal whose backend panics leaves
2831    /// its slot unfilled, so the capsule is incomplete and records no false
2832    /// success.
2833    #[cfg(feature = "reporting")]
2834    #[test]
2835    fn a_panicking_sync_removal_leaves_the_capsule_incomplete() {
2836        for remove in [
2837            (|cache: &dyn Cache| cache.invalidate("k")) as fn(&dyn Cache),
2838            |cache: &dyn Cache| cache.clear(),
2839            |cache: &dyn Cache| {
2840                let _ = cache.invalidate_namespace("ns");
2841            },
2842        ] {
2843            let cache = with_capsule_seam(Arc::new(PanickingBackend));
2844            let scope = capture_scope();
2845            let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2846                block_on(crate::capsule::capture::with_capture_scope(
2847                    Arc::clone(&scope),
2848                    async { remove(cache.as_ref()) },
2849                ));
2850            }));
2851            assert!(caught.is_err(), "the backend panics");
2852            let _ = scope.effects_snapshot();
2853            assert!(
2854                scope.is_truncated(),
2855                "an unfilled slot marks the capsule incomplete"
2856            );
2857        }
2858    }
2859
2860    /// Codex review on #3222: a direct `get_value` call through the seam
2861    /// marks the capsule incomplete during capture, and is a divergence
2862    /// during a replay. A helper read stays recorded.
2863    #[cfg(feature = "reporting")]
2864    #[test]
2865    fn a_direct_get_value_is_not_silently_substituted() {
2866        let cache = with_capsule_seam(Arc::new(SpyBackend::default()));
2867        let scope = capture_scope();
2868        block_on(crate::capsule::capture::with_capture_scope(
2869            Arc::clone(&scope),
2870            async {
2871                let _: Option<u32> = get_cached(cache.as_ref(), "k");
2872            },
2873        ));
2874        assert!(!scope.is_truncated(), "a helper read is recorded");
2875        block_on(crate::capsule::capture::with_capture_scope(
2876            Arc::clone(&scope),
2877            async {
2878                let _ = cache.get_value("k");
2879            },
2880        ));
2881        assert!(scope.is_truncated(), "a direct read cannot be recorded");
2882
2883        let tape = Arc::new(crate::capsule::ReplayEffects::new(
2884            crate::capsule::CapsuleEffects::default(),
2885        ));
2886        let read = block_on(crate::capsule::with_effect_tape(Arc::clone(&tape), async {
2887            cache.get_value("k")
2888        }));
2889        assert!(read.is_none());
2890        assert_eq!(tape.divergences().len(), 1, "{:?}", tape.divergences());
2891    }
2892
2893    /// Codex review on #3222: a direct `insert_value` or `insert_raw_bytes`
2894    /// call through the seam marks the capsule incomplete during capture, and
2895    /// is a divergence during a replay. A helper write stays recorded.
2896    #[cfg(feature = "reporting")]
2897    #[test]
2898    fn a_direct_write_is_not_silently_dropped() {
2899        let cache = with_capsule_seam(Arc::new(SpyBackend::default()));
2900        let scope = capture_scope();
2901        block_on(crate::capsule::capture::with_capture_scope(
2902            Arc::clone(&scope),
2903            async { insert_cached(cache.as_ref(), "k", 1_u32, None) },
2904        ));
2905        assert!(!scope.is_truncated(), "a helper write is recorded");
2906        for write in [
2907            (|cache: &dyn Cache| cache.insert_value("k", Arc::new(1_u32))) as fn(&dyn Cache),
2908            |cache: &dyn Cache| cache.insert_raw_bytes("k", b"1".to_vec(), None),
2909        ] {
2910            let scope = capture_scope();
2911            block_on(crate::capsule::capture::with_capture_scope(
2912                Arc::clone(&scope),
2913                async { write(cache.as_ref()) },
2914            ));
2915            assert!(scope.is_truncated(), "a direct write cannot be recorded");
2916
2917            let tape = Arc::new(crate::capsule::ReplayEffects::new(
2918                crate::capsule::CapsuleEffects::default(),
2919            ));
2920            block_on(crate::capsule::with_effect_tape(Arc::clone(&tape), async {
2921                write(cache.as_ref());
2922            }));
2923            assert_eq!(tape.divergences().len(), 1, "{:?}", tape.divergences());
2924        }
2925    }
2926
2927    /// The seam wraps a backend once, so `Arc` identity stays stable.
2928    #[cfg(feature = "reporting")]
2929    #[test]
2930    fn the_capsule_seam_wraps_a_backend_once() {
2931        let spy: Arc<dyn Cache> = Arc::new(SpyBackend::default());
2932        let once = with_capsule_seam(spy);
2933        let twice = with_capsule_seam(Arc::clone(&once));
2934        assert!(Arc::ptr_eq(&once, &twice));
2935    }
2936
2937    #[test]
2938    fn invalidation_error_reports_attempts_and_reason() {
2939        let err = InvalidationError::new(3, "READONLY");
2940        assert_eq!(err.attempts(), 3);
2941        assert_eq!(err.reason(), "READONLY");
2942        assert_eq!(
2943            err.to_string(),
2944            "cache invalidation failed after 3 attempt(s): READONLY"
2945        );
2946    }
2947
2948    #[test]
2949    fn invalidation_failures_counter_is_monotonic() {
2950        let before = invalidation_failures_total();
2951        record_invalidation_failure();
2952        assert!(invalidation_failures_total() > before);
2953    }
2954
2955    #[test]
2956    fn cacheable_result_ok_round_trips() {
2957        let r: Result<i32, &str> = Result::from_ok(42);
2958        assert_eq!(r, Ok(42));
2959        assert_eq!(r.into_result(), Ok(42));
2960    }
2961
2962    #[test]
2963    fn cacheable_result_err_passes_through() {
2964        let r: Result<i32, &str> = Err("oops");
2965        assert_eq!(r.into_result(), Err("oops"));
2966    }
2967}