Skip to main content

autumn_cache_redis/
lib.rs

1//! Redis-backed shared cache for Autumn applications.
2//!
3//! This crate provides [`RedisCache`], an implementation of the
4//! `autumn_web::cache::Cache` trait backed by Redis, plus
5//! [`RedisCachePlugin`] which wires it into the app via the plugin system.
6//!
7//! # Usage
8//!
9//! ```toml
10//! # autumn.toml
11//! [cache]
12//! backend = "redis"
13//!
14//! [cache.redis]
15//! url = "redis://redis:6379"
16//! key_prefix = "myapp:cache"
17//! ```
18//!
19//! ```rust,ignore
20//! use autumn_cache_redis::RedisCachePlugin;
21//!
22//! autumn_web::app()
23//!     .plugin(RedisCachePlugin::new())
24//!     .routes(routes![...])
25//!     .run()
26//!     .await;
27//! ```
28//!
29//! Values are serialized as JSON so they survive across replicas and restarts.
30//! Invalidation on replica A is immediately visible on replica B — no TTL lag.
31//!
32//! Use [`autumn_web::cache::insert_cached`] / [`autumn_web::cache::get_cached`]
33//! (which the `#[cached]` macro generates) to read and write values that are
34//! `serde::Serialize + serde::Deserialize`. The plain [`autumn_web::cache::insert`]
35//! / [`autumn_web::cache::get`] functions work only for in-process backends.
36//! `CacheResponseLayer` uses Autumn's serde-aware cache path internally, so
37//! HTTP response caching is supported with this Redis backend.
38
39use std::any::Any;
40use std::sync::Arc;
41use std::time::Duration;
42
43use autumn_web::cache::{
44    Cache, CacheBackendError, CacheFuture, FillEpoch, FillLockStatus, InvalidationError,
45    RawCacheBytes, jittered_ttl, record_invalidation_failure,
46};
47use redis::AsyncCommands as _;
48use redis::aio::ConnectionManager;
49use thiserror::Error;
50use tracing::{debug, warn};
51
52/// Lua script for a compare-and-delete fill-lock release: only deletes the
53/// lock key if it still holds the caller's token, so a caller can never
54/// release a lock that another replica has since acquired (e.g. after this
55/// caller's lock expired and was taken over).
56const RELEASE_FILL_LOCK_SCRIPT: &str = r"
57if redis.call('GET', KEYS[1]) == ARGV[1] then
58    return redis.call('DEL', KEYS[1])
59else
60    return 0
61end
62";
63
64/// Lua script for a fenced insert: store the value only if the namespace epoch
65/// still equals the one the fill sampled. One script call, so an `INCR` cannot
66/// land between the check and the store. `KEYS[2]` is the epoch key. `ARGV` is
67/// the sampled epoch, the value, and the TTL in ms (empty for none).
68const FENCED_INSERT_SCRIPT: &str = r"
69local current = redis.call('GET', KEYS[2])
70if not current then current = '0' end
71if current ~= ARGV[1] then return 0 end
72if ARGV[3] == '' then
73    redis.call('SET', KEYS[1], ARGV[2])
74else
75    redis.call('PSETEX', KEYS[1], ARGV[3], ARGV[2])
76end
77return 1
78";
79
80/// Set after the first failed fenced insert has been logged at `warn`.
81static FENCED_INSERT_WARNED: std::sync::atomic::AtomicBool =
82    std::sync::atomic::AtomicBool::new(false);
83
84/// [`FENCED_INSERT_SCRIPT`], hashed once.
85static FENCED_INSERT: std::sync::LazyLock<redis::Script> =
86    std::sync::LazyLock::new(|| redis::Script::new(FENCED_INSERT_SCRIPT));
87
88/// Key segment that marks a shared fill epoch. [`RedisCache::sweep`] never
89/// deletes a key that has it.
90const EPOCH_SEGMENT: &str = "__autumn_epoch__:";
91
92/// Whether an application key uses the reserved epoch segment. A write to such
93/// a key is refused, so it cannot overwrite an epoch.
94fn is_reserved_key(key: &str) -> bool {
95    key.starts_with(EPOCH_SEGMENT)
96}
97
98/// Errors that can occur when constructing or using a [`RedisCache`].
99#[derive(Debug, Error)]
100pub enum RedisCacheError {
101    #[error("Redis connection error: {0}")]
102    Connection(#[from] redis::RedisError),
103    #[error("missing Redis URL in cache config")]
104    MissingUrl,
105}
106
107/// Retry policy for invalidation (`DEL` and the namespace sweep).
108///
109/// A failed attempt sleeps, then tries again. The sleep starts at
110/// `base_backoff`, doubles after each attempt, and stops at `max_backoff`.
111/// Each sleep gets ±50 % jitter, so replicas do not retry at the same time.
112/// If all attempts fail, the async methods return the error. The sync methods
113/// log it with `warn!` and count it in `autumn_cache_invalidation_failures_total`.
114///
115/// Make one with [`InvalidationRetry::new`] or [`Default`]. The type is
116/// `#[non_exhaustive]`, so new fields can come later.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct InvalidationRetry {
120    /// Total attempts, including the first. `0` counts as `1`.
121    pub max_attempts: u32,
122    /// Sleep before the second attempt.
123    pub base_backoff: Duration,
124    /// Upper limit for each sleep.
125    pub max_backoff: Duration,
126}
127
128impl Default for InvalidationRetry {
129    /// 3 attempts, 20 ms base, 200 ms cap. The two sleeps total at most about
130    /// 90 ms.
131    fn default() -> Self {
132        Self::new(3, Duration::from_millis(20), Duration::from_millis(200))
133    }
134}
135
136impl InvalidationRetry {
137    /// A policy with `max_attempts` tries, a first sleep of `base_backoff`,
138    /// and no sleep longer than `max_backoff`.
139    #[must_use]
140    pub const fn new(max_attempts: u32, base_backoff: Duration, max_backoff: Duration) -> Self {
141        Self {
142            max_attempts,
143            base_backoff,
144            max_backoff,
145        }
146    }
147
148    /// Total attempts. Always at least 1.
149    #[must_use]
150    pub const fn attempts(&self) -> u32 {
151        if self.max_attempts == 0 {
152            1
153        } else {
154            self.max_attempts
155        }
156    }
157
158    /// Sleep after failed attempt number `attempt` (0-based).
159    ///
160    /// The result is between half the nominal sleep and `max_backoff`.
161    #[must_use]
162    pub fn backoff(&self, attempt: u32) -> Duration {
163        let nominal = self
164            .base_backoff
165            .saturating_mul(1_u32 << attempt.min(20))
166            .min(self.max_backoff);
167        jittered_ttl(nominal, 0.5).min(self.max_backoff)
168    }
169}
170
171/// A [`Cache`] implementation backed by Redis.
172///
173/// Values are stored as JSON blobs. Multiple replicas share the same Redis
174/// namespace so writes on replica A are immediately visible on replica B, and
175/// invalidations propagate within a single round-trip.
176///
177/// Use [`autumn_web::cache::insert_cached`] and [`autumn_web::cache::get_cached`]
178/// (or equivalently the `#[cached]` macro) to store and retrieve values — both
179/// functions handle JSON serialization transparently. The plain
180/// `autumn_web::cache::insert` / `get` functions perform only in-memory
181/// downcasts and will miss on cross-replica reads. `CacheResponseLayer` is
182/// safe to use because it stores response entries through the serialized path.
183///
184/// # Runtime requirement
185///
186/// The sync [`Cache`] methods bridge to async Redis calls with
187/// [`tokio::task::block_in_place`]. They need a **multi-thread** Tokio
188/// runtime, and panic on a current-thread runtime (for example the default
189/// `#[tokio::test]` flavor).
190///
191/// [`Cache::invalidate_async`] and [`Cache::invalidate_namespace_async`] are
192/// real async calls. They work on any runtime and do not hold a worker.
193/// Framework invalidation (generated repositories,
194/// `coherence::invalidate_namespace_async`) uses them.
195///
196/// # Invalidation errors
197///
198/// Invalidation retries with [`InvalidationRetry`]. The async methods return
199/// the final error. The sync `invalidate` and `clear` cannot return it, so
200/// they log it with `warn!` and count it in
201/// `autumn_cache_invalidation_failures_total`.
202#[derive(Clone)]
203pub struct RedisCache {
204    manager: ConnectionManager,
205    key_prefix: String,
206    invalidation_retry: InvalidationRetry,
207}
208
209fn ttl_millis_for_redis(ttl: std::time::Duration) -> u64 {
210    let millis = ttl.as_millis().max(1);
211    u64::try_from(millis).unwrap_or(u64::MAX)
212}
213
214/// Escape Redis GLOB pattern metacharacters (`\`, `*`, `?`, `[`) in `s` so it
215/// matches only literally when used inside a `SCAN`/`KEYS` `MATCH` pattern.
216///
217/// `key_prefix` is an operator-supplied config value (`cache.redis.key_prefix`
218/// in `autumn.toml`), not attacker-controlled request input, but an operator
219/// prefix that happens to contain one of these characters (e.g. `tenant:*`)
220/// would otherwise be interpreted as a glob by [`RedisCache::clear`]'s `SCAN
221/// MATCH {prefix}:*`, letting `clear()` match — and delete — keys outside the
222/// namespace the prefix was meant to scope it to.
223fn escape_redis_glob(s: &str) -> String {
224    let mut escaped = String::with_capacity(s.len());
225    for ch in s.chars() {
226        if matches!(ch, '\\' | '*' | '?' | '[') {
227            escaped.push('\\');
228        }
229        escaped.push(ch);
230    }
231    escaped
232}
233
234impl RedisCache {
235    /// Connect using an explicit URL and key prefix.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`RedisCacheError::Connection`] if the initial connection fails.
240    pub async fn connect(
241        url: &str,
242        key_prefix: impl Into<String>,
243    ) -> Result<Self, RedisCacheError> {
244        // `open_client`, not `redis::Client::open`: it installs the
245        // process-wide rustls `CryptoProvider` that `redis`'s
246        // `tokio-rustls-comp` needs before a `rediss://` URL can be dialled,
247        // and it is the same guard every Redis client inside `autumn-web`
248        // goes through. Scoped to TLS schemes there, so a plain `redis://`
249        // connection never claims the process-wide default. See #2172.
250        let client = autumn_web::redis_tls::open_client(url)?;
251        let manager = ConnectionManager::new(client).await?;
252        Ok(Self {
253            manager,
254            key_prefix: key_prefix.into(),
255            invalidation_retry: InvalidationRetry::default(),
256        })
257    }
258
259    /// Build a `RedisCache` from the `[cache]` section of `autumn.toml`.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`RedisCacheError::MissingUrl`] if `cache.redis.url` is absent.
264    /// Returns [`RedisCacheError::Connection`] on connection failure.
265    pub async fn from_config(
266        config: &autumn_web::config::CacheRedisConfig,
267    ) -> Result<Self, RedisCacheError> {
268        let url = config.url.as_deref().ok_or(RedisCacheError::MissingUrl)?;
269        Self::connect(url, &config.key_prefix).await
270    }
271
272    /// Set the retry policy for invalidation. See [`InvalidationRetry`].
273    #[must_use]
274    pub const fn with_invalidation_retry(mut self, retry: InvalidationRetry) -> Self {
275        self.invalidation_retry = retry;
276        self
277    }
278
279    fn prefixed(&self, key: &str) -> String {
280        format!("{}:{}", self.key_prefix, key)
281    }
282
283    /// Physical key for `key`'s distributed fill lock.
284    ///
285    /// Deliberately **not** nested under `key_prefix:` (i.e. not
286    /// `prefixed(format!("{key}:fill-lock"))`): `prefixed()` maps every
287    /// possible `key` onto `"{key_prefix}:{key}"`, so any suffix appended
288    /// under that same namespace is reachable by *some* ordinary cache key
289    /// (e.g. an app key literally named `"session:fill-lock"`) and could
290    /// silently collide with a lock entry. Using a distinct top-level sentinel
291    /// here means a collision would require the operator's own `key_prefix`
292    /// to equal `"__autumn_fill_lock__"`, not merely an unlucky choice of
293    /// cache key.
294    fn fill_lock_key(&self, key: &str) -> String {
295        format!("__autumn_fill_lock__:{}:{}", self.key_prefix, key)
296    }
297
298    /// Key of a namespace's shared fill epoch.
299    ///
300    /// Inside `key_prefix:`, so a Redis ACL scoped to the prefix still works.
301    /// [`Cache::clear`] sweeps `{key_prefix}:*`, so [`Self::sweep`] skips these
302    /// keys. If `clear` removed an epoch, the counter would restart at 0, and a
303    /// stale fill that sampled 0 would pass.
304    fn epoch_key(&self, namespace: &str) -> String {
305        self.prefixed(&format!("{EPOCH_SEGMENT}{namespace}"))
306    }
307
308    /// Raise the namespace's shared epoch, with retries.
309    async fn bump_epoch(&self, namespace: &str) -> Result<(), InvalidationError> {
310        let key = self.epoch_key(namespace);
311        self.retry_invalidation(&key, || {
312            let mut conn = self.manager.clone();
313            let key = key.clone();
314            async move { conn.incr::<_, _, i64>(key, 1).await.map(|_| ()) }
315        })
316        .await
317    }
318
319    /// Drop a namespace for all replicas.
320    ///
321    /// The epoch rises first. Then a fill that sampled the old epoch is fenced
322    /// out, and the sweep removes anything stored before the bump. The sweep
323    /// runs even if the bump failed. The first error is returned.
324    async fn invalidate_namespace_fenced(&self, namespace: &str) -> Result<(), InvalidationError> {
325        let bumped = self.bump_epoch(namespace).await;
326        let swept = self
327            .sweep_with_retry(&self.namespace_pattern(namespace))
328            .await;
329        bumped.and(swept)
330    }
331
332    fn redis_get(&self, key: &str) -> Option<Vec<u8>> {
333        self.try_redis_get(key).ok().flatten()
334    }
335
336    /// `GET`, with the Redis error kept.
337    fn try_redis_get(&self, key: &str) -> Result<Option<Vec<u8>>, redis::RedisError> {
338        let prefixed = self.prefixed(key);
339        let mut conn = self.manager.clone();
340        tokio::task::block_in_place(|| {
341            tokio::runtime::Handle::current()
342                .block_on(async move { conn.get::<_, Option<Vec<u8>>>(&prefixed).await })
343        })
344    }
345
346    fn redis_set(&self, key: &str, bytes: Vec<u8>, ttl: Option<std::time::Duration>) {
347        // The error is dropped: `Cache::insert_raw_bytes` cannot return it.
348        // `Cache::try_insert_raw_bytes` can.
349        let _ = self.try_redis_set(key, bytes, ttl);
350    }
351
352    /// `SET` or `PSETEX`, with the failure kept. A reserved key is refused.
353    fn try_redis_set(
354        &self,
355        key: &str,
356        bytes: Vec<u8>,
357        ttl: Option<std::time::Duration>,
358    ) -> Result<(), CacheBackendError> {
359        if is_reserved_key(key) {
360            warn!(key, "RedisCache: refused a write to a reserved epoch key");
361            return Err(CacheBackendError::new("the key is reserved"));
362        }
363        let prefixed = self.prefixed(key);
364        let mut conn = self.manager.clone();
365        tokio::task::block_in_place(|| {
366            tokio::runtime::Handle::current().block_on(async move {
367                if let Some(ttl) = ttl {
368                    let millis = ttl_millis_for_redis(ttl);
369                    redis::cmd("PSETEX")
370                        .arg(&prefixed)
371                        .arg(millis)
372                        .arg(bytes)
373                        .query_async::<()>(&mut conn)
374                        .await
375                } else {
376                    conn.set::<_, _, ()>(&prefixed, bytes).await
377                }
378            })
379        })
380        .map_err(|error| CacheBackendError::new(error.to_string()))
381    }
382
383    /// Delete every key matching `pattern`, walking the keyspace with `SCAN`
384    /// rather than `KEYS` so a large keyspace never blocks the server.
385    ///
386    /// Shared by [`Cache::clear`] and [`Cache::invalidate_namespace`], which
387    /// differ only in how many segments of the key they pin.
388    ///
389    /// The first `SCAN` or `DEL` error stops the walk and is returned. A
390    /// partial sweep is not a complete one. The sweep is idempotent, so a
391    /// retry starts again from cursor 0.
392    async fn sweep(&self, pattern: &str) -> Result<(), redis::RedisError> {
393        let mut conn = self.manager.clone();
394        let mut cursor: u64 = 0;
395        loop {
396            let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
397                .arg(cursor)
398                .arg("MATCH")
399                .arg(pattern)
400                .arg("COUNT")
401                .arg(100u32)
402                .query_async(&mut conn)
403                .await?;
404            let epoch_prefix = self.prefixed(EPOCH_SEGMENT);
405            let keys: Vec<String> = keys
406                .into_iter()
407                .filter(|key| !key.starts_with(&epoch_prefix))
408                .collect();
409            if !keys.is_empty() {
410                conn.del::<_, ()>(keys).await?;
411            }
412            cursor = next_cursor;
413            if cursor == 0 {
414                return Ok(());
415            }
416        }
417    }
418
419    /// Run `op` until it succeeds or the retry budget is spent.
420    ///
421    /// Each failed attempt is logged at `debug`. The caller decides what to do
422    /// with the final error: return it, or log it and count it.
423    async fn retry_invalidation<F, Fut>(
424        &self,
425        target: &str,
426        mut op: F,
427    ) -> Result<(), InvalidationError>
428    where
429        F: FnMut() -> Fut + Send,
430        Fut: std::future::Future<Output = Result<(), redis::RedisError>> + Send,
431    {
432        let attempts = self.invalidation_retry.attempts();
433        let mut last_error = String::new();
434        for attempt in 0..attempts {
435            if attempt > 0 {
436                tokio::time::sleep(self.invalidation_retry.backoff(attempt - 1)).await;
437            }
438            match op().await {
439                Ok(()) => return Ok(()),
440                Err(error) => {
441                    debug!(
442                        target,
443                        attempt = attempt + 1,
444                        attempts,
445                        error = %error,
446                        "RedisCache: invalidation attempt failed"
447                    );
448                    last_error = error.to_string();
449                }
450            }
451        }
452        Err(InvalidationError::new(attempts, last_error))
453    }
454
455    /// `DEL` one key, with retries.
456    async fn delete_key(&self, key: &str) -> Result<(), InvalidationError> {
457        if is_reserved_key(key) {
458            warn!(key, "RedisCache: refused to delete a reserved epoch key");
459            return Ok(());
460        }
461        let prefixed = self.prefixed(key);
462        self.retry_invalidation(key, || {
463            let mut conn = self.manager.clone();
464            let prefixed = prefixed.clone();
465            async move { conn.del::<_, ()>(prefixed).await }
466        })
467        .await
468    }
469
470    /// Sweep every key that matches `pattern`, with retries.
471    async fn sweep_with_retry(&self, pattern: &str) -> Result<(), InvalidationError> {
472        self.retry_invalidation(pattern, || self.sweep(pattern))
473            .await
474    }
475
476    /// The `SCAN MATCH` pattern for one namespace.
477    ///
478    /// Cache keys are `{key_prefix}:{namespace}:{hash}`, so
479    /// `{key_prefix}:{namespace}:*` drops exactly one cached read's entries.
480    /// Both parts are escaped: an unescaped `*` or `[` would widen the sweep.
481    fn namespace_pattern(&self, namespace: &str) -> String {
482        format!(
483            "{}:{}:*",
484            escape_redis_glob(&self.key_prefix),
485            escape_redis_glob(namespace)
486        )
487    }
488}
489
490/// Run an async invalidation from a sync [`Cache`] method. Needs a
491/// multi-thread runtime, like every sync method here.
492fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
493    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
494}
495
496/// Log and count an invalidation error that a sync method cannot return.
497fn report_dropped_invalidation_error(target: &str, error: &InvalidationError) {
498    warn!(
499        target,
500        error = %error,
501        "RedisCache: invalidation failed; stale data can be served until its TTL"
502    );
503    record_invalidation_failure();
504}
505
506impl Cache for RedisCache {
507    /// Retrieves a value from Redis and returns it as [`RawCacheBytes`].
508    ///
509    /// Callers using [`autumn_web::cache::get_cached`] (which the `#[cached]`
510    /// macro generates) will have the bytes automatically deserialized into the
511    /// concrete return type `V` via `serde_json`. Direct callers that need the
512    /// concrete type should also use `get_cached` rather than `get_value`.
513    fn get_value(&self, key: &str) -> Option<Arc<dyn Any + Send + Sync>> {
514        self.redis_get(key)
515            .map(|bytes| Arc::new(RawCacheBytes(bytes)) as Arc<dyn Any + Send + Sync>)
516    }
517
518    /// Stores a value in Redis by serializing the most common primitive types.
519    ///
520    /// For arbitrary serde types — including structs and collections — use
521    /// [`insert_raw_bytes`] (called automatically by
522    /// [`autumn_web::cache::insert_cached`]) instead. Unknown types are
523    /// silently skipped here because [`autumn_web::cache::insert_cached`] will have already
524    /// written the serialized form via [`insert_raw_bytes`].
525    ///
526    /// [`insert_raw_bytes`]: Cache::insert_raw_bytes
527    fn insert_value(&self, key: &str, value: Arc<dyn Any + Send + Sync>) {
528        // Only handle RawCacheBytes round-trips and the primitive types used by
529        // direct `cache::insert` callers. Serde types arrive via insert_raw_bytes.
530        let bytes: Option<Vec<u8>> = value
531            .downcast_ref::<RawCacheBytes>()
532            .map(|raw| raw.0.clone())
533            .or_else(|| {
534                value
535                    .downcast_ref::<String>()
536                    .and_then(|s| serde_json::to_vec(s).ok())
537            })
538            .or_else(|| {
539                value
540                    .downcast_ref::<i64>()
541                    .and_then(|n| serde_json::to_vec(n).ok())
542            })
543            .or_else(|| {
544                value
545                    .downcast_ref::<i32>()
546                    .and_then(|n| serde_json::to_vec(n).ok())
547            });
548
549        if let Some(bytes) = bytes {
550            // insert_value has no TTL context; use no-expiry SET for these callers.
551            self.redis_set(key, bytes, None);
552            debug!(key, "RedisCache: inserted via insert_value");
553        }
554    }
555
556    /// Stores pre-serialized JSON bytes in Redis, applying the TTL when provided.
557    ///
558    /// This is the primary write path for `#[cached]`-annotated functions (via
559    /// [`autumn_web::cache::insert_cached`]). It handles all `serde::Serialize`
560    /// types, including structs and collections that `insert_value` cannot
561    /// serialize from an erased `Arc<dyn Any>`.
562    ///
563    /// When `ttl` is `Some`, the entry is stored with millisecond precision so
564    /// Redis expires it automatically, matching the TTL declared on `#[cached]`.
565    fn insert_raw_bytes(&self, key: &str, bytes: Vec<u8>, ttl: Option<std::time::Duration>) {
566        self.redis_set(key, bytes, ttl);
567        debug!(key, "RedisCache: inserted via insert_raw_bytes");
568    }
569
570    /// As [`Cache::get_value`], but a Redis error is an error, not a miss.
571    fn try_get_value(
572        &self,
573        key: &str,
574    ) -> Result<Option<Arc<dyn Any + Send + Sync>>, CacheBackendError> {
575        self.try_redis_get(key)
576            .map(|found| {
577                found.map(|bytes| Arc::new(RawCacheBytes(bytes)) as Arc<dyn Any + Send + Sync>)
578            })
579            .map_err(|error| CacheBackendError::new(error.to_string()))
580    }
581
582    /// As [`Cache::insert_raw_bytes`], but a failed write is an error.
583    fn try_insert_raw_bytes(
584        &self,
585        key: &str,
586        bytes: Vec<u8>,
587        ttl: Option<std::time::Duration>,
588    ) -> Result<(), CacheBackendError> {
589        self.try_redis_set(key, bytes, ttl)
590    }
591
592    /// As [`Cache::invalidate`], but the final error is returned. It is not
593    /// added to the invalidation failure counter. A reserved key is refused.
594    /// Like `invalidate`, this needs a multi-thread runtime.
595    fn try_invalidate(&self, key: &str) -> Result<(), InvalidationError> {
596        if is_reserved_key(key) {
597            return Err(InvalidationError::new(0, "the key is reserved"));
598        }
599        block_on(self.delete_key(key))
600    }
601
602    /// `RedisCache` keeps a shared fill epoch per namespace.
603    fn shares_fill_epoch(&self) -> bool {
604        true
605    }
606
607    /// Reads the namespace's shared epoch (one `GET`). A missing key is 0. A
608    /// Redis error is [`FillEpoch::Unavailable`], so the fill skips its insert.
609    fn fill_epoch(&self, namespace: &str) -> FillEpoch {
610        let key = self.epoch_key(namespace);
611        let mut conn = self.manager.clone();
612        let read: Result<Option<u64>, _> = block_on(async move { conn.get(&key).await });
613        match read {
614            Ok(epoch) => FillEpoch::Sampled(epoch.unwrap_or(0)),
615            Err(error) => {
616                debug!(namespace, error = %error, "RedisCache: epoch read failed");
617                FillEpoch::Unavailable
618            }
619        }
620    }
621
622    /// Stores the bytes through one Lua call that compares the epoch first. An
623    /// error counts as not stored: the cost is one extra miss.
624    fn insert_raw_bytes_if_epoch(
625        &self,
626        key: &str,
627        bytes: Vec<u8>,
628        ttl: Option<std::time::Duration>,
629        namespace: &str,
630        sampled: u64,
631    ) -> bool {
632        if is_reserved_key(key) {
633            warn!(key, "RedisCache: refused a write to a reserved epoch key");
634            return false;
635        }
636        let prefixed = self.prefixed(key);
637        let epoch_key = self.epoch_key(namespace);
638        let ttl_ms = ttl.map_or_else(String::new, |ttl| ttl_millis_for_redis(ttl).to_string());
639        let mut conn = self.manager.clone();
640        let result: Result<i64, _> = block_on(async move {
641            FENCED_INSERT
642                .key(&prefixed)
643                .key(&epoch_key)
644                .arg(sampled.to_string())
645                .arg(bytes)
646                .arg(ttl_ms)
647                .invoke_async(&mut conn)
648                .await
649        });
650        match result {
651            Ok(stored) => stored == 1,
652            Err(error) => {
653                // Most likely a Redis ACL that denies scripting. Every miss
654                // then recomputes, so say so once.
655                if !FENCED_INSERT_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
656                    warn!(
657                        key,
658                        error = %error,
659                        "RedisCache: the fenced insert failed, so cached values are not stored. \
660                         The Redis user needs +eval, +evalsha and +script|load"
661                    );
662                }
663                debug!(key, error = %error, "RedisCache: fenced insert failed");
664                false
665            }
666        }
667    }
668
669    /// Sync `DEL` with retries. The final error is logged with `warn!` and
670    /// counted, because this method cannot return it. Prefer
671    /// [`Cache::invalidate_async`].
672    fn invalidate(&self, key: &str) {
673        match block_on(self.delete_key(key)) {
674            Ok(()) => debug!(key, "RedisCache: invalidated"),
675            Err(error) => report_dropped_invalidation_error(key, &error),
676        }
677    }
678
679    /// Sync namespace sweep. Returns `false` when the sweep failed after all
680    /// retries. The caller owns that `false`, so it is not counted here.
681    fn invalidate_namespace(&self, namespace: &str) -> bool {
682        // Issue #1716: a declared invalidation edge must be complete on a
683        // shared, cross-replica backend. The `bool` tells the caller whether
684        // stale data can still be served.
685        match block_on(self.invalidate_namespace_fenced(namespace)) {
686            Ok(()) => true,
687            Err(error) => {
688                debug!(namespace, error = %error, "RedisCache: namespace sweep failed");
689                false
690            }
691        }
692    }
693
694    fn clear(&self) {
695        // SCAN, not KEYS, so a large keyspace never blocks the server.
696        let pattern = format!("{}:*", escape_redis_glob(&self.key_prefix));
697        if let Err(error) = block_on(self.sweep_with_retry(&pattern)) {
698            report_dropped_invalidation_error(&pattern, &error);
699        }
700    }
701
702    /// Async `DEL` with retries. No `block_in_place`, so it works on a
703    /// current-thread runtime. Returns the final error.
704    fn invalidate_async<'a>(
705        &'a self,
706        key: &'a str,
707    ) -> CacheFuture<'a, Result<(), InvalidationError>> {
708        Box::pin(self.delete_key(key))
709    }
710
711    /// Async namespace sweep with retries. Returns the final error.
712    fn invalidate_namespace_async<'a>(
713        &'a self,
714        namespace: &'a str,
715    ) -> CacheFuture<'a, Result<(), InvalidationError>> {
716        Box::pin(self.invalidate_namespace_fenced(namespace))
717    }
718
719    /// Acquires a cross-replica fill lock via `SET NX PX`. Redis errors are
720    /// treated as [`FillLockStatus::Held`] (fail closed toward "someone else
721    /// may be filling") — the caller's `lock_wait_timeout` bounds how long it
722    /// waits before falling back to filling locally.
723    fn try_acquire_fill_lock(&self, key: &str, token: &str, ttl: Duration) -> FillLockStatus {
724        let lock_key = self.fill_lock_key(key);
725        let millis = ttl_millis_for_redis(ttl);
726        let mut conn = self.manager.clone();
727        let token = token.to_owned();
728        let acquired = tokio::task::block_in_place(|| {
729            tokio::runtime::Handle::current().block_on(async move {
730                redis::cmd("SET")
731                    .arg(&lock_key)
732                    .arg(&token)
733                    .arg("NX")
734                    .arg("PX")
735                    .arg(millis)
736                    .query_async::<Option<String>>(&mut conn)
737                    .await
738            })
739        });
740        match acquired {
741            Ok(Some(_)) => FillLockStatus::Acquired,
742            Ok(None) => FillLockStatus::Held,
743            Err(e) => {
744                debug!(key, error = %e, "RedisCache: fill lock acquire failed, treating as held");
745                FillLockStatus::Held
746            }
747        }
748    }
749
750    /// Releases the fill lock only if `token` still owns it (compare-and-delete
751    /// via a Lua script), so a caller can never release a lock another replica
752    /// has since taken over after this caller's lock expired.
753    fn release_fill_lock(&self, key: &str, token: &str) {
754        let lock_key = self.fill_lock_key(key);
755        let mut conn = self.manager.clone();
756        let token = token.to_owned();
757        let result: Result<i64, _> = tokio::task::block_in_place(|| {
758            tokio::runtime::Handle::current().block_on(async move {
759                let script = redis::Script::new(RELEASE_FILL_LOCK_SCRIPT);
760                script
761                    .key(&lock_key)
762                    .arg(&token)
763                    .invoke_async(&mut conn)
764                    .await
765            })
766        });
767        match result {
768            Ok(_) => debug!(key, "RedisCache: released fill lock"),
769            Err(e) => {
770                debug!(key, error = %e, "RedisCache: fill lock release failed");
771            }
772        }
773    }
774}
775
776// ── Plugin ────────────────────────────────────────────────────────────────────
777
778/// Autumn plugin that wires `RedisCache` as the global application cache.
779///
780/// Reads `[cache.redis]` from the active `AutumnConfig` and installs a
781/// `RedisCache` into `AppState` via `with_cache_backend`.
782///
783/// When `cache.backend != "redis"` the plugin is a no-op and the default
784/// per-function Moka caches continue to work.
785pub struct RedisCachePlugin;
786
787impl RedisCachePlugin {
788    /// Create the plugin.
789    #[must_use]
790    pub const fn new() -> Self {
791        Self
792    }
793}
794
795impl Default for RedisCachePlugin {
796    fn default() -> Self {
797        Self::new()
798    }
799}
800
801impl autumn_web::plugin::Plugin for RedisCachePlugin {
802    /// This plugin ships in lockstep with `autumn-web` — see
803    /// [`lockstep_contract`](autumn_web::plugin_contract::lockstep_contract).
804    fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
805        Some(autumn_web::plugin_contract::lockstep_contract(
806            env!("CARGO_PKG_NAME"),
807            env!("CARGO_PKG_VERSION"),
808        ))
809    }
810
811    fn build(self, app: autumn_web::app::AppBuilder) -> autumn_web::app::AppBuilder {
812        app.on_startup(|state| async move {
813            // Read the config the framework already stored as an extension.
814            let config = state
815                .extension::<autumn_web::config::AutumnConfig>()
816                .expect("AutumnConfig must be registered before RedisCachePlugin startup");
817
818            if !config.cache.is_redis() {
819                return Ok(());
820            }
821
822            let redis_cfg = &config.cache.redis;
823            let cache = RedisCache::from_config(redis_cfg).await.map_err(|e| {
824                autumn_web::AutumnError::service_unavailable_msg(format!(
825                    "Failed to connect RedisCache: {e}"
826                ))
827            })?;
828
829            state.set_cache(Arc::new(cache));
830            tracing::info!("RedisCache registered as global application cache");
831            register_cache_health_indicator(&state, &config);
832            Ok(())
833        })
834    }
835}
836
837/// Register the `redis:cache` `PING` indicator (issue #3059), with the
838/// `[health]` rules of the built-in Redis indicators. The framework does not
839/// register it, because only this plugin installs the Redis cache.
840fn register_cache_health_indicator(
841    state: &autumn_web::AppState,
842    config: &autumn_web::config::AutumnConfig,
843) {
844    let Some(url) = config.cache.redis.url.as_deref() else {
845        return;
846    };
847    // Share the app's connection to this URL with the built-in indicators.
848    match autumn_web::redis_health::RedisHealthIndicator::shared(state, url) {
849        Ok(indicator) => {
850            let indicator = indicator.configured(&config.health);
851            let group = autumn_web::actuator::HealthIndicator::group(&indicator);
852            if let Err(error) = state.health_indicator_registry().register(
853                "redis:cache",
854                group,
855                Arc::new(indicator),
856            ) {
857                tracing::warn!("{error}");
858            }
859        }
860        Err(error) => tracing::warn!(error = %error, "redis:cache health indicator not registered"),
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn cache_health_indicator_follows_the_health_config() {
870        let state = autumn_web::AppState::for_test();
871        let mut config = autumn_web::config::AutumnConfig::default();
872        config.cache.redis.url = Some("redis://127.0.0.1:1".to_owned());
873        config.health.redis_readiness = true;
874
875        register_cache_health_indicator(&state, &config);
876
877        assert!(state.health_indicator_registry().contains("redis:cache"));
878    }
879
880    #[test]
881    fn no_cache_health_indicator_without_a_url() {
882        let state = autumn_web::AppState::for_test();
883        let config = autumn_web::config::AutumnConfig::default();
884
885        register_cache_health_indicator(&state, &config);
886
887        assert!(!state.health_indicator_registry().contains("redis:cache"));
888    }
889    use autumn_web::cache::{FillEpoch, get_cached, insert_cached, insert_cached_fenced};
890    use std::sync::atomic::{AtomicUsize, Ordering};
891    use testcontainers::runners::AsyncRunner;
892    use testcontainers_modules::redis::Redis as RedisImage;
893
894    #[test]
895    fn contract_declares_lockstep_with_own_crate() {
896        use autumn_web::plugin::Plugin;
897
898        let contract = RedisCachePlugin::new().contract().expect("a contract");
899        assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
900        assert_eq!(
901            contract.plugin_version.as_deref(),
902            Some(env!("CARGO_PKG_VERSION"))
903        );
904        assert_eq!(
905            contract.autumn_web.as_deref(),
906            Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
907        );
908        assert!(contract.experimental_surfaces.is_empty());
909    }
910
911    #[test]
912    fn redis_ttl_millis_preserves_subsecond_precision() {
913        assert_eq!(
914            ttl_millis_for_redis(std::time::Duration::from_millis(100)),
915            100
916        );
917        assert_eq!(
918            ttl_millis_for_redis(std::time::Duration::from_millis(1_500)),
919            1_500
920        );
921    }
922
923    #[test]
924    fn redis_ttl_millis_never_uses_zero() {
925        assert_eq!(ttl_millis_for_redis(std::time::Duration::ZERO), 1);
926    }
927
928    /// This crate must not build its own Redis client: `RedisCache::connect`
929    /// goes through `autumn_web::redis_tls::open_client`, which installs the
930    /// process-level rustls `CryptoProvider` a `rediss://` URL needs before
931    /// rustls is asked to resolve one (#2172). A second, hand-rolled install
932    /// here is exactly how one copy of this logic drifts from the other.
933    ///
934    /// Walks `src/` rather than scanning `lib.rs` alone: the crate is a
935    /// single file today, and a scan that silently stops covering the crate
936    /// the day a second module appears is the drift it exists to prevent.
937    #[test]
938    fn the_cache_never_builds_a_redis_client_outside_the_shared_tls_guard() {
939        // Split so the needles do not match this declaration.
940        let needles = [concat!("Client::", "open("), concat!("build_with", "_tls(")];
941        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
942        let mut files = Vec::new();
943        collect_rust_sources(&src, &mut files);
944        assert!(!files.is_empty(), "no sources under {}", src.display());
945        files.sort();
946
947        let mut offenders = Vec::new();
948        for file in files {
949            let source = std::fs::read_to_string(&file).expect("read source");
950            for (index, line) in source.lines().enumerate() {
951                let trimmed = line.trim_start();
952                if trimmed.starts_with("//") || trimmed.starts_with('*') {
953                    continue;
954                }
955                if needles.iter().any(|needle| line.contains(needle)) {
956                    offenders.push(format!(
957                        "{}:{}: {}",
958                        file.strip_prefix(&src).unwrap_or(&file).display(),
959                        index + 1,
960                        line.trim()
961                    ));
962                }
963            }
964        }
965        assert!(
966            offenders.is_empty(),
967            "build Redis clients through `autumn_web::redis_tls::open_client`, \
968             not directly — a `rediss://` URL otherwise reaches rustls with no \
969             process-level CryptoProvider installed and panics (#2172):\n{}",
970            offenders.join("\n")
971        );
972    }
973
974    /// Recursively collect every `.rs` file under `dir`.
975    fn collect_rust_sources(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
976        for entry in std::fs::read_dir(dir).expect("read_dir") {
977            let path = entry.expect("dir entry").path();
978            if path.is_dir() {
979                collect_rust_sources(&path, out);
980            } else if path.extension().is_some_and(|ext| ext == "rs") {
981                out.push(path);
982            }
983        }
984    }
985
986    /// The TLS classification itself is `autumn_web::redis_tls`'s contract;
987    /// this only pins that this crate is wired to the same one.
988    #[test]
989    fn the_shared_guard_classifies_the_tls_schemes_this_cache_is_deployed_on() {
990        assert!(autumn_web::redis_tls::url_needs_tls_crypto_provider(
991            "rediss://cache.redis.cache.windows.net:6380/"
992        ));
993        assert!(!autumn_web::redis_tls::url_needs_tls_crypto_provider(
994            "redis://127.0.0.1:6379/"
995        ));
996    }
997
998    #[test]
999    fn escape_redis_glob_leaves_plain_prefixes_unchanged() {
1000        assert_eq!(escape_redis_glob("myapp:cache"), "myapp:cache");
1001    }
1002
1003    #[test]
1004    fn escape_redis_glob_escapes_scan_match_metacharacters() {
1005        // Regression: `clear()` builds a SCAN MATCH pattern as
1006        // "{key_prefix}:*". An unescaped operator-supplied prefix containing
1007        // a glob metacharacter (e.g. "tenant:*") would let SCAN MATCH match
1008        // — and clear() then delete — keys well outside that prefix's
1009        // intended namespace.
1010        assert_eq!(escape_redis_glob("tenant:*"), r"tenant:\*");
1011        assert_eq!(escape_redis_glob("a?b"), r"a\?b");
1012        assert_eq!(escape_redis_glob("[ab]"), r"\[ab]");
1013        assert_eq!(escape_redis_glob(r"back\slash"), r"back\\slash");
1014    }
1015
1016    #[tokio::test(flavor = "multi_thread")]
1017    async fn rediss_scheme_is_usable_without_a_tls_cargo_feature_error() {
1018        // The azure-container-apps release target's generated Redis Cache
1019        // disables the non-TLS port (main.tf: non_ssl_port_enabled = false),
1020        // so it only ever hands the app a `rediss://` URL. Without a TLS
1021        // Cargo feature compiled in, `redis::Client::open` rejects that
1022        // scheme immediately at URL-parse time (before any network I/O) with
1023        // "can't connect with TLS, the feature is not enabled" — invisible
1024        // until a real Azure deploy, since no local test exercised it. This
1025        // needs no Docker/testcontainer TLS-capable Redis: with the
1026        // tls-rustls feature compiled in (workspace Cargo.toml), parsing
1027        // succeeds and the failure that follows (a closed loopback port) is
1028        // a genuine network error instead.
1029        let result = RedisCache::connect("rediss://127.0.0.1:1/", "test").await;
1030        let Err(err) = result else {
1031            panic!("connecting to a closed port must fail");
1032        };
1033        let message = err.to_string();
1034        assert!(
1035            !message.contains("feature is not enabled") && !message.contains("without the tls"),
1036            "the `redis` crate must be built with a TLS feature (tokio-rustls-comp) \
1037             so `rediss://` URLs actually connect: {message}"
1038        );
1039    }
1040
1041    // ── #3056: invalidation errors are retried, then surfaced ──────────
1042
1043    /// A plain connection for test set-up and fault injection.
1044    async fn admin_conn(url: &str) -> redis::aio::MultiplexedConnection {
1045        autumn_web::redis_tls::open_client(url)
1046            .expect("client")
1047            .get_multiplexed_async_connection()
1048            .await
1049            .expect("admin connection")
1050    }
1051
1052    /// Make the server a replica of a master that does not exist. A replica
1053    /// is read-only, so each `DEL` gets a `READONLY` error. The data stays.
1054    async fn reject_writes(admin: &mut redis::aio::MultiplexedConnection) {
1055        redis::cmd("REPLICAOF")
1056            .arg("127.0.0.1")
1057            .arg(1)
1058            .query_async::<()>(admin)
1059            .await
1060            .expect("REPLICAOF");
1061    }
1062
1063    /// Make the server a master again, so writes succeed.
1064    async fn accept_writes(admin: &mut redis::aio::MultiplexedConnection) {
1065        redis::cmd("REPLICAOF")
1066            .arg("NO")
1067            .arg("ONE")
1068            .query_async::<()>(admin)
1069            .await
1070            .expect("REPLICAOF NO ONE");
1071    }
1072
1073    async fn key_exists(admin: &mut redis::aio::MultiplexedConnection, key: &str) -> bool {
1074        admin.exists::<_, bool>(key).await.expect("EXISTS")
1075    }
1076
1077    const FAST_RETRY: InvalidationRetry = InvalidationRetry {
1078        max_attempts: 3,
1079        base_backoff: Duration::from_millis(1),
1080        max_backoff: Duration::from_millis(5),
1081    };
1082
1083    #[test]
1084    fn application_keys_cannot_use_the_reserved_epoch_segment() {
1085        assert!(is_reserved_key("__autumn_epoch__:orders"));
1086        assert!(!is_reserved_key("orders:abc"));
1087        assert!(!is_reserved_key("x:__autumn_epoch__:orders"));
1088    }
1089
1090    #[test]
1091    fn default_retry_policy_is_bounded() {
1092        let retry = InvalidationRetry::default();
1093        assert_eq!(retry.max_attempts, 3);
1094        assert!(retry.base_backoff <= retry.max_backoff);
1095        assert!(retry.max_backoff <= Duration::from_secs(1));
1096    }
1097
1098    #[test]
1099    fn retry_backoff_grows_and_never_passes_the_cap() {
1100        let retry = InvalidationRetry {
1101            max_attempts: 10,
1102            base_backoff: Duration::from_millis(10),
1103            max_backoff: Duration::from_millis(80),
1104        };
1105        for _ in 0..200 {
1106            for attempt in 0..10 {
1107                let nominal = retry
1108                    .base_backoff
1109                    .saturating_mul(1 << attempt.min(20))
1110                    .min(retry.max_backoff);
1111                let delay = retry.backoff(attempt);
1112                assert!(delay <= retry.max_backoff, "{delay:?} passes the cap");
1113                assert!(delay >= nominal / 2, "{delay:?} below half of {nominal:?}");
1114            }
1115        }
1116    }
1117
1118    #[test]
1119    fn a_zero_attempt_policy_still_tries_once() {
1120        let retry = InvalidationRetry {
1121            max_attempts: 0,
1122            ..InvalidationRetry::default()
1123        };
1124        assert_eq!(retry.attempts(), 1);
1125    }
1126
1127    /// The async path must not use `block_in_place`, so this test uses the
1128    /// default current-thread runtime. `block_in_place` panics there.
1129    #[tokio::test]
1130    #[ignore = "requires Docker (testcontainers)"]
1131    async fn redis_invalidate_async_surfaces_readonly_del_failure() {
1132        let container = RedisImage::default().start().await.unwrap();
1133        let port = container.get_host_port_ipv4(6379).await.unwrap();
1134        let url = format!("redis://127.0.0.1:{port}");
1135        let mut admin = admin_conn(&url).await;
1136        let cache = RedisCache::connect(&url, "inv-fail")
1137            .await
1138            .unwrap()
1139            .with_invalidation_retry(FAST_RETRY);
1140
1141        admin.set::<_, _, ()>("inv-fail:k", "1").await.unwrap();
1142        reject_writes(&mut admin).await;
1143
1144        let err = cache
1145            .invalidate_async("k")
1146            .await
1147            .expect_err("a rejected DEL must surface as an error");
1148        assert_eq!(err.attempts(), 3, "all attempts must run: {err}");
1149        assert!(
1150            err.reason().to_ascii_lowercase().contains("read only"),
1151            "the reason must carry the Redis error: {err}"
1152        );
1153        assert!(
1154            key_exists(&mut admin, "inv-fail:k").await,
1155            "the key must still exist, so the error is true"
1156        );
1157
1158        accept_writes(&mut admin).await;
1159        cache
1160            .invalidate_async("k")
1161            .await
1162            .expect("DEL succeeds once Redis accepts writes");
1163        assert!(!key_exists(&mut admin, "inv-fail:k").await);
1164    }
1165
1166    #[tokio::test]
1167    #[ignore = "requires Docker (testcontainers)"]
1168    async fn redis_invalidate_retries_until_del_succeeds() {
1169        let container = RedisImage::default().start().await.unwrap();
1170        let port = container.get_host_port_ipv4(6379).await.unwrap();
1171        let url = format!("redis://127.0.0.1:{port}");
1172        let mut admin = admin_conn(&url).await;
1173        // At least 49 sleeps of 5 ms or more: the fault clears long before
1174        // the budget ends.
1175        let cache = RedisCache::connect(&url, "inv-retry")
1176            .await
1177            .unwrap()
1178            .with_invalidation_retry(InvalidationRetry {
1179                max_attempts: 50,
1180                base_backoff: Duration::from_millis(10),
1181                max_backoff: Duration::from_millis(10),
1182            });
1183
1184        admin.set::<_, _, ()>("inv-retry:k", "1").await.unwrap();
1185        reject_writes(&mut admin).await;
1186
1187        let mut healer = admin.clone();
1188        let heal = tokio::spawn(async move {
1189            tokio::time::sleep(Duration::from_millis(30)).await;
1190            accept_writes(&mut healer).await;
1191        });
1192
1193        cache
1194            .invalidate_async("k")
1195            .await
1196            .expect("a transient DEL failure must be retried, not dropped");
1197        heal.await.unwrap();
1198        assert!(!key_exists(&mut admin, "inv-retry:k").await);
1199    }
1200
1201    #[tokio::test]
1202    #[ignore = "requires Docker (testcontainers)"]
1203    async fn redis_invalidate_namespace_async_surfaces_failure() {
1204        let container = RedisImage::default().start().await.unwrap();
1205        let port = container.get_host_port_ipv4(6379).await.unwrap();
1206        let url = format!("redis://127.0.0.1:{port}");
1207        let mut admin = admin_conn(&url).await;
1208        let cache = RedisCache::connect(&url, "inv-ns")
1209            .await
1210            .unwrap()
1211            .with_invalidation_retry(FAST_RETRY);
1212
1213        admin.set::<_, _, ()>("inv-ns:reads:1", "1").await.unwrap();
1214        reject_writes(&mut admin).await;
1215
1216        let err = cache
1217            .invalidate_namespace_async("reads")
1218            .await
1219            .expect_err("a rejected sweep must surface as an error");
1220        assert_eq!(err.attempts(), 3, "{err}");
1221        assert!(key_exists(&mut admin, "inv-ns:reads:1").await);
1222
1223        accept_writes(&mut admin).await;
1224        cache
1225            .invalidate_namespace_async("reads")
1226            .await
1227            .expect("the sweep succeeds once Redis accepts writes");
1228        assert!(!key_exists(&mut admin, "inv-ns:reads:1").await);
1229    }
1230
1231    // ── #2356: the fill fence is shared across replicas ─────────────────
1232
1233    #[tokio::test(flavor = "multi_thread")]
1234    #[ignore = "requires Docker (testcontainers)"]
1235    async fn redis_fill_on_another_replica_cannot_resurrect_an_invalidated_value() {
1236        let container = RedisImage::default().start().await.unwrap();
1237        let port = container.get_host_port_ipv4(6379).await.unwrap();
1238        let url = format!("redis://127.0.0.1:{port}");
1239        let mut admin = admin_conn(&url).await;
1240        let replica_a = RedisCache::connect(&url, "fence").await.unwrap();
1241        let replica_b = RedisCache::connect(&url, "fence").await.unwrap();
1242
1243        // B misses, samples the shared epoch, and starts computing.
1244        let epoch = replica_b.fill_epoch("reads");
1245        assert_eq!(epoch, FillEpoch::Sampled(0));
1246
1247        // A writes, invalidates, and is told it is complete.
1248        replica_a
1249            .invalidate_namespace_async("reads")
1250            .await
1251            .expect("invalidation");
1252
1253        // B finishes. Its insert must be fenced out.
1254        let stored = insert_cached_fenced(
1255            &replica_b,
1256            "reads:k",
1257            "old".to_string(),
1258            None,
1259            "reads",
1260            epoch,
1261        );
1262        assert!(!stored, "the shared epoch moved");
1263        assert!(!key_exists(&mut admin, "fence:reads:k").await);
1264
1265        // Without a TTL the entry has no expiry.
1266        let epoch = replica_b.fill_epoch("reads");
1267        assert!(insert_cached_fenced(
1268            &replica_b,
1269            "reads:forever",
1270            1_i64,
1271            None,
1272            "reads",
1273            epoch
1274        ));
1275        let left: i64 = redis::cmd("PTTL")
1276            .arg("fence:reads:forever")
1277            .query_async(&mut admin)
1278            .await
1279            .unwrap();
1280        assert_eq!(left, -1, "no TTL means no expiry");
1281
1282        // A fill that starts after the invalidation is stored, with its TTL.
1283        let epoch = replica_b.fill_epoch("reads");
1284        assert_eq!(epoch, FillEpoch::Sampled(1));
1285        let ttl = Some(Duration::from_secs(60));
1286        assert!(insert_cached_fenced(
1287            &replica_b,
1288            "reads:k",
1289            "new".to_string(),
1290            ttl,
1291            "reads",
1292            epoch
1293        ));
1294        assert_eq!(
1295            get_cached::<String>(&replica_a, "reads:k").as_deref(),
1296            Some("new")
1297        );
1298        let left: i64 = redis::cmd("PTTL")
1299            .arg("fence:reads:k")
1300            .query_async(&mut admin)
1301            .await
1302            .unwrap();
1303        assert!(left > 0, "the TTL must survive the Lua insert: {left}");
1304    }
1305
1306    #[tokio::test(flavor = "multi_thread")]
1307    #[ignore = "requires Docker (testcontainers)"]
1308    async fn redis_epoch_is_per_namespace_and_survives_clear() {
1309        let container = RedisImage::default().start().await.unwrap();
1310        let port = container.get_host_port_ipv4(6379).await.unwrap();
1311        let url = format!("redis://127.0.0.1:{port}");
1312        let cache = RedisCache::connect(&url, "fence-ns").await.unwrap();
1313
1314        cache.invalidate_namespace_async("a").await.unwrap();
1315        assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
1316        assert_eq!(cache.fill_epoch("b"), FillEpoch::Sampled(0));
1317
1318        // `clear` sweeps the prefix. If it took the epoch, a sampled 0 would
1319        // match again after a bump.
1320        cache.clear();
1321        assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
1322
1323        // A key-level delete cannot remove an epoch either.
1324        cache
1325            .invalidate_async("__autumn_epoch__:a")
1326            .await
1327            .expect("a refused delete is not an error");
1328        cache.invalidate("__autumn_epoch__:a");
1329        assert_eq!(cache.fill_epoch("a"), FillEpoch::Sampled(1));
1330
1331        // The key sits inside the prefix, so a prefix-scoped ACL still works.
1332        let mut admin = admin_conn(&url).await;
1333        assert!(key_exists(&mut admin, "fence-ns:__autumn_epoch__:a").await);
1334    }
1335
1336    #[tokio::test(flavor = "multi_thread")]
1337    #[ignore = "requires Docker (testcontainers)"]
1338    async fn redis_failed_epoch_bump_fails_the_invalidation() {
1339        let container = RedisImage::default().start().await.unwrap();
1340        let port = container.get_host_port_ipv4(6379).await.unwrap();
1341        let url = format!("redis://127.0.0.1:{port}");
1342        let mut admin = admin_conn(&url).await;
1343        let cache = RedisCache::connect(&url, "fence-fail")
1344            .await
1345            .unwrap()
1346            .with_invalidation_retry(FAST_RETRY);
1347
1348        // No key matches, so the sweep alone would succeed.
1349        reject_writes(&mut admin).await;
1350        assert!(
1351            cache.invalidate_namespace_async("reads").await.is_err(),
1352            "a `true` must not be reported when the shared fence did not move"
1353        );
1354        assert!(!cache.invalidate_namespace("reads"));
1355        accept_writes(&mut admin).await;
1356        assert!(cache.invalidate_namespace_async("reads").await.is_ok());
1357    }
1358
1359    /// Serializes tests that read the process-global failure counter or set
1360    /// the global cache.
1361    static GLOBAL_STATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1362
1363    /// The path a generated repository write takes: the framework sweep with a
1364    /// `RedisCache` as the global backend, on a current-thread runtime.
1365    #[tokio::test]
1366    #[ignore = "requires Docker (testcontainers)"]
1367    async fn coherence_async_invalidation_reaches_redis_and_reports_failure() {
1368        let _guard = GLOBAL_STATE.lock().await;
1369        let container = RedisImage::default().start().await.unwrap();
1370        let port = container.get_host_port_ipv4(6379).await.unwrap();
1371        let url = format!("redis://127.0.0.1:{port}");
1372        let mut admin = admin_conn(&url).await;
1373        let cache = RedisCache::connect(&url, "e2e")
1374            .await
1375            .unwrap()
1376            .with_invalidation_retry(FAST_RETRY);
1377        autumn_web::cache::set_global_cache(Arc::new(cache));
1378        let namespace = "e2e::reads::count";
1379
1380        admin
1381            .set::<_, _, ()>(format!("e2e:{namespace}:a"), "1")
1382            .await
1383            .unwrap();
1384        let complete = autumn_web::cache::coherence::invalidate_namespace_async(namespace).await;
1385        let gone = !key_exists(&mut admin, &format!("e2e:{namespace}:a")).await;
1386
1387        admin
1388            .set::<_, _, ()>(format!("e2e:{namespace}:b"), "1")
1389            .await
1390            .unwrap();
1391        reject_writes(&mut admin).await;
1392        let before = autumn_web::cache::invalidation_failures_total();
1393        let refused = autumn_web::cache::coherence::invalidate_namespace_async(namespace).await;
1394        let after = autumn_web::cache::invalidation_failures_total();
1395        let kept = key_exists(&mut admin, &format!("e2e:{namespace}:b")).await;
1396        accept_writes(&mut admin).await;
1397        autumn_web::cache::clear_global_cache();
1398
1399        assert!(complete && gone, "a healthy Redis drops the namespace");
1400        assert!(!refused, "a rejected sweep must report incomplete");
1401        assert_eq!(after, before + 1, "and count one failure");
1402        assert!(kept, "the key is still there, so the report is true");
1403    }
1404
1405    /// The sync `invalidate` returns `()`, so it cannot surface the error. It
1406    /// must log it and count it instead of dropping it.
1407    #[tokio::test(flavor = "multi_thread")]
1408    #[ignore = "requires Docker (testcontainers)"]
1409    async fn redis_sync_invalidate_counts_final_failure() {
1410        let _guard = GLOBAL_STATE.lock().await;
1411        let container = RedisImage::default().start().await.unwrap();
1412        let port = container.get_host_port_ipv4(6379).await.unwrap();
1413        let url = format!("redis://127.0.0.1:{port}");
1414        let mut admin = admin_conn(&url).await;
1415        let cache = RedisCache::connect(&url, "inv-sync")
1416            .await
1417            .unwrap()
1418            .with_invalidation_retry(FAST_RETRY);
1419
1420        admin.set::<_, _, ()>("inv-sync:k", "1").await.unwrap();
1421        reject_writes(&mut admin).await;
1422
1423        let before = autumn_web::cache::invalidation_failures_total();
1424        autumn_web::cache::Cache::invalidate(&cache, "k");
1425        let after = autumn_web::cache::invalidation_failures_total();
1426        assert_eq!(
1427            after,
1428            before + 1,
1429            "a final DEL failure must increment autumn_cache_invalidation_failures_total once"
1430        );
1431        assert!(key_exists(&mut admin, "inv-sync:k").await);
1432
1433        accept_writes(&mut admin).await;
1434    }
1435
1436    #[tokio::test(flavor = "multi_thread")]
1437    #[ignore = "requires Docker (testcontainers)"]
1438    async fn redis_cache_insert_get_invalidate() {
1439        let container = RedisImage::default().start().await.unwrap();
1440        let port = container.get_host_port_ipv4(6379).await.unwrap();
1441        let url = format!("redis://127.0.0.1:{port}");
1442
1443        let cache = RedisCache::connect(&url, "test").await.unwrap();
1444
1445        // insert_cached serializes via serde_json and stores via insert_raw_bytes
1446        insert_cached(&cache, "hello", "world".to_string(), None);
1447
1448        // get_value returns RawCacheBytes wrapping the JSON
1449        let raw = cache.get_value("hello").expect("should be present");
1450        let raw_bytes = raw.downcast_ref::<RawCacheBytes>().expect("RawCacheBytes");
1451        let v: serde_json::Value = serde_json::from_slice(&raw_bytes.0).unwrap();
1452        assert_eq!(v, serde_json::json!("world"));
1453
1454        // get_cached deserializes back to the concrete type
1455        let s: Option<String> = get_cached(&cache, "hello");
1456        assert_eq!(s.as_deref(), Some("world"));
1457
1458        // Invalidate
1459        autumn_web::cache::Cache::invalidate(&cache, "hello");
1460        assert!(cache.get_value("hello").is_none());
1461    }
1462
1463    #[tokio::test(flavor = "multi_thread")]
1464    #[ignore = "requires Docker (testcontainers)"]
1465    async fn redis_try_methods_round_trip_and_report_outage() {
1466        let container = RedisImage::default().start().await.unwrap();
1467        let port = container.get_host_port_ipv4(6379).await.unwrap();
1468        let url = format!("redis://127.0.0.1:{port}");
1469        let cache = RedisCache::connect(&url, "test").await.unwrap();
1470
1471        cache
1472            .try_insert_raw_bytes("k", b"1".to_vec(), None)
1473            .unwrap();
1474        assert!(cache.try_get_value("k").unwrap().is_some());
1475        assert!(cache.try_get_value("absent").unwrap().is_none());
1476        cache.try_invalidate("k").unwrap();
1477        assert!(cache.try_get_value("k").unwrap().is_none());
1478
1479        // An outage is an error, not a miss.
1480        container.stop().await.unwrap();
1481        assert!(cache.try_get_value("k").is_err());
1482        assert!(
1483            cache
1484                .try_insert_raw_bytes("k", b"1".to_vec(), None)
1485                .is_err()
1486        );
1487    }
1488
1489    #[tokio::test(flavor = "multi_thread")]
1490    #[ignore = "requires Docker (testcontainers)"]
1491    async fn redis_cache_cross_replica_invalidation() {
1492        let container = RedisImage::default().start().await.unwrap();
1493        let port = container.get_host_port_ipv4(6379).await.unwrap();
1494        let url = format!("redis://127.0.0.1:{port}");
1495
1496        // Two "replicas" sharing the same Redis
1497        let replica_a = RedisCache::connect(&url, "xreplica").await.unwrap();
1498        let replica_b = RedisCache::connect(&url, "xreplica").await.unwrap();
1499
1500        // A writes via insert_cached (the path used by #[cached])
1501        let start = std::time::Instant::now();
1502        insert_cached(&replica_a, "key", "value".to_string(), None);
1503
1504        // B can read it via get_cached and gets the correctly-typed value
1505        let seen: Option<String> = get_cached(&replica_b, "key");
1506        let elapsed = start.elapsed();
1507
1508        assert_eq!(
1509            seen.as_deref(),
1510            Some("value"),
1511            "replica B must read the value written by replica A"
1512        );
1513
1514        // A invalidates
1515        autumn_web::cache::Cache::invalidate(&replica_a, "key");
1516
1517        // B no longer sees it (within one round-trip = < 50 ms p99)
1518        let gone: Option<String> = get_cached(&replica_b, "key");
1519        assert!(gone.is_none());
1520        assert!(
1521            elapsed.as_millis() < 50,
1522            "cross-replica lag {elapsed:?} exceeded 50 ms"
1523        );
1524    }
1525
1526    #[tokio::test(flavor = "multi_thread")]
1527    #[ignore = "requires Docker (testcontainers)"]
1528    async fn redis_cache_serde_struct_round_trip() {
1529        #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1530        struct Item {
1531            id: i32,
1532            name: String,
1533        }
1534
1535        let container = RedisImage::default().start().await.unwrap();
1536        let port = container.get_host_port_ipv4(6379).await.unwrap();
1537        let url = format!("redis://127.0.0.1:{port}");
1538
1539        let cache_a = RedisCache::connect(&url, "structs").await.unwrap();
1540        let cache_b = RedisCache::connect(&url, "structs").await.unwrap();
1541
1542        let item = Item {
1543            id: 1,
1544            name: "widget".into(),
1545        };
1546        insert_cached(&cache_a, "item:1", item.clone(), None);
1547
1548        // Same replica
1549        let retrieved: Option<Item> = get_cached(&cache_a, "item:1");
1550        assert_eq!(retrieved, Some(item.clone()));
1551
1552        // Cross-replica
1553        let from_b: Option<Item> = get_cached(&cache_b, "item:1");
1554        assert_eq!(from_b, Some(item));
1555    }
1556
1557    #[tokio::test(flavor = "multi_thread")]
1558    #[ignore = "requires Docker (testcontainers)"]
1559    async fn redis_cache_response_layer_caches_http_gets() {
1560        use std::convert::Infallible;
1561
1562        use autumn_web::cache::CacheResponseLayer;
1563        use autumn_web::reexports::axum::body::{Body, to_bytes};
1564        use autumn_web::reexports::http::{Request, StatusCode};
1565        use tower::{Service, ServiceBuilder, ServiceExt};
1566
1567        let container = RedisImage::default().start().await.unwrap();
1568        let port = container.get_host_port_ipv4(6379).await.unwrap();
1569        let url = format!("redis://127.0.0.1:{port}");
1570
1571        let cache: Arc<dyn autumn_web::cache::Cache> =
1572            Arc::new(RedisCache::connect(&url, "http-response").await.unwrap());
1573        let counter = Arc::new(AtomicUsize::new(0));
1574
1575        let inner = {
1576            let counter = counter.clone();
1577            tower::service_fn(move |_req: Request<Body>| {
1578                let counter = counter.clone();
1579                async move {
1580                    counter.fetch_add(1, Ordering::SeqCst);
1581                    Ok::<_, Infallible>(
1582                        autumn_web::reexports::axum::response::Response::builder()
1583                            .status(StatusCode::OK)
1584                            .header("x-cache-test", "redis")
1585                            .body(Body::from("redis-body"))
1586                            .expect("infallible response builder"),
1587                    )
1588                }
1589            })
1590        };
1591
1592        let mut svc = ServiceBuilder::new()
1593            .layer(CacheResponseLayer::from_shared(cache.clone()))
1594            .service(inner);
1595
1596        let req = Request::get("/redis-backed")
1597            .body(Body::empty())
1598            .expect("infallible response builder");
1599        let resp = svc
1600            .ready()
1601            .await
1602            .expect("service ready")
1603            .call(req)
1604            .await
1605            .expect("infallible service");
1606        assert_eq!(resp.status(), StatusCode::OK);
1607
1608        let req = Request::get("/redis-backed")
1609            .body(Body::empty())
1610            .expect("infallible response builder");
1611        let resp = svc
1612            .ready()
1613            .await
1614            .expect("service ready")
1615            .call(req)
1616            .await
1617            .expect("infallible service");
1618
1619        assert_eq!(resp.status(), StatusCode::OK);
1620        assert_eq!(
1621            resp.headers()
1622                .get("x-cache-test")
1623                .and_then(|v| v.to_str().ok()),
1624            Some("redis")
1625        );
1626        let body = to_bytes(resp.into_body(), usize::MAX)
1627            .await
1628            .expect("body collection");
1629        assert_eq!(body.as_ref(), b"redis-body");
1630        assert_eq!(counter.load(Ordering::SeqCst), 1);
1631
1632        cache.invalidate("http:/redis-backed");
1633    }
1634
1635    #[tokio::test(flavor = "multi_thread")]
1636    #[ignore = "requires Docker (testcontainers)"]
1637    async fn redis_fill_lock_acquire_conflict_release() {
1638        let container = RedisImage::default().start().await.unwrap();
1639        let port = container.get_host_port_ipv4(6379).await.unwrap();
1640        let url = format!("redis://127.0.0.1:{port}");
1641        let cache = RedisCache::connect(&url, "fill-lock-test").await.unwrap();
1642
1643        assert_eq!(
1644            cache.try_acquire_fill_lock("k", "token-a", Duration::from_secs(10)),
1645            FillLockStatus::Acquired
1646        );
1647        assert_eq!(
1648            cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
1649            FillLockStatus::Held,
1650            "a second replica must not acquire a lock already held"
1651        );
1652
1653        // Releasing with the wrong token must not free the lock.
1654        cache.release_fill_lock("k", "token-b");
1655        assert_eq!(
1656            cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
1657            FillLockStatus::Held,
1658            "releasing with a stale/foreign token must be a no-op"
1659        );
1660
1661        // Releasing with the owning token frees it for the next acquirer.
1662        cache.release_fill_lock("k", "token-a");
1663        assert_eq!(
1664            cache.try_acquire_fill_lock("k", "token-b", Duration::from_secs(10)),
1665            FillLockStatus::Acquired
1666        );
1667    }
1668
1669    #[tokio::test(flavor = "multi_thread")]
1670    #[ignore = "requires Docker (testcontainers)"]
1671    async fn redis_fill_lock_expires_after_ttl() {
1672        let container = RedisImage::default().start().await.unwrap();
1673        let port = container.get_host_port_ipv4(6379).await.unwrap();
1674        let url = format!("redis://127.0.0.1:{port}");
1675        let cache = RedisCache::connect(&url, "fill-lock-ttl-test")
1676            .await
1677            .unwrap();
1678
1679        assert_eq!(
1680            cache.try_acquire_fill_lock("k", "crashed-filler", Duration::from_millis(200)),
1681            FillLockStatus::Acquired
1682        );
1683        // Never release — simulate a filler that crashed while holding the lock.
1684        tokio::time::sleep(Duration::from_millis(400)).await;
1685
1686        assert_eq!(
1687            cache.try_acquire_fill_lock("k", "new-filler", Duration::from_secs(10)),
1688            FillLockStatus::Acquired,
1689            "the lock TTL must bound damage from a crashed filler"
1690        );
1691    }
1692
1693    #[tokio::test(flavor = "multi_thread")]
1694    #[ignore = "requires Docker (testcontainers)"]
1695    async fn redis_lock_wait_timeout_falls_back_to_self_fill() {
1696        use autumn_web::cache::GetOrComputeOptions;
1697
1698        let container = RedisImage::default().start().await.unwrap();
1699        let port = container.get_host_port_ipv4(6379).await.unwrap();
1700        let url = format!("redis://127.0.0.1:{port}");
1701        let cache: Arc<dyn Cache> = Arc::new(
1702            RedisCache::connect(&url, "lock-timeout-test")
1703                .await
1704                .unwrap(),
1705        );
1706
1707        // Simulate a stuck holder that never releases and outlives the test.
1708        let key = "stuck-key";
1709        assert_eq!(
1710            cache.try_acquire_fill_lock(key, "stuck-holder", Duration::from_secs(60)),
1711            FillLockStatus::Acquired
1712        );
1713
1714        let fill_count = Arc::new(AtomicUsize::new(0));
1715        let fc = fill_count.clone();
1716        let opts = GetOrComputeOptions::new()
1717            .distributed_fill_lock(true)
1718            .lock_wait_timeout(Duration::from_millis(300))
1719            .lock_poll_interval(Duration::from_millis(50));
1720
1721        let value: i32 =
1722            autumn_web::cache::get_or_compute_with(&cache, key, opts, move || async move {
1723                fc.fetch_add(1, Ordering::SeqCst);
1724                Ok::<i32, String>(123)
1725            })
1726            .await
1727            .unwrap();
1728
1729        assert_eq!(value, 123);
1730        assert_eq!(
1731            fill_count.load(Ordering::SeqCst),
1732            1,
1733            "after the wait timeout the caller must fall back to filling locally"
1734        );
1735    }
1736
1737    #[tokio::test(flavor = "multi_thread")]
1738    #[ignore = "requires Docker (testcontainers)"]
1739    async fn redis_lock_wait_timeout_not_overshot_by_poll_interval() {
1740        use autumn_web::cache::GetOrComputeOptions;
1741
1742        let container = RedisImage::default().start().await.unwrap();
1743        let port = container.get_host_port_ipv4(6379).await.unwrap();
1744        let url = format!("redis://127.0.0.1:{port}");
1745        let cache: Arc<dyn Cache> = Arc::new(
1746            RedisCache::connect(&url, "lock-timeout-overshoot-test")
1747                .await
1748                .unwrap(),
1749        );
1750
1751        // Simulate a stuck holder that never releases and outlives the test.
1752        let key = "stuck-key-overshoot";
1753        assert_eq!(
1754            cache.try_acquire_fill_lock(key, "stuck-holder", Duration::from_secs(60)),
1755            FillLockStatus::Acquired
1756        );
1757
1758        // A poll interval much larger than the wait timeout must not make the
1759        // caller sleep past the timeout before falling back: lock_wait_timeout
1760        // bounds the total wait regardless of the poll cadence.
1761        let opts = GetOrComputeOptions::new()
1762            .distributed_fill_lock(true)
1763            .lock_wait_timeout(Duration::from_millis(100))
1764            .lock_poll_interval(Duration::from_secs(5));
1765
1766        let start = std::time::Instant::now();
1767        let value: i32 =
1768            autumn_web::cache::get_or_compute_with(&cache, key, opts, move || async move {
1769                Ok::<i32, String>(456)
1770            })
1771            .await
1772            .unwrap();
1773        let elapsed = start.elapsed();
1774
1775        assert_eq!(value, 456);
1776        assert!(
1777            elapsed < Duration::from_millis(500),
1778            "lock_wait_timeout must bound the wait even when lock_poll_interval is much larger; took {elapsed:?}"
1779        );
1780    }
1781
1782    #[tokio::test(flavor = "multi_thread")]
1783    #[ignore = "requires Docker (testcontainers)"]
1784    async fn redis_get_or_compute_round_trip() {
1785        let container = RedisImage::default().start().await.unwrap();
1786        let port = container.get_host_port_ipv4(6379).await.unwrap();
1787        let url = format!("redis://127.0.0.1:{port}");
1788        let cache: Arc<dyn Cache> = Arc::new(
1789            RedisCache::connect(&url, "read-through-test")
1790                .await
1791                .unwrap(),
1792        );
1793
1794        let fill_count = Arc::new(AtomicUsize::new(0));
1795        let fc = fill_count.clone();
1796        let v: String = autumn_web::cache::get_or_compute(
1797            &cache,
1798            "rt-key",
1799            Some(Duration::from_secs(60)),
1800            move || async move {
1801                fc.fetch_add(1, Ordering::SeqCst);
1802                Ok::<String, String>("value-from-redis".to_string())
1803            },
1804        )
1805        .await
1806        .unwrap();
1807        assert_eq!(v, "value-from-redis");
1808
1809        let fc = fill_count.clone();
1810        let v: String = autumn_web::cache::get_or_compute(
1811            &cache,
1812            "rt-key",
1813            Some(Duration::from_secs(60)),
1814            move || async move {
1815                fc.fetch_add(1, Ordering::SeqCst);
1816                Ok::<String, String>("should-not-run".to_string())
1817            },
1818        )
1819        .await
1820        .unwrap();
1821        assert_eq!(
1822            v, "value-from-redis",
1823            "second call must hit via RawCacheBytes deserialization"
1824        );
1825        assert_eq!(fill_count.load(Ordering::SeqCst), 1);
1826    }
1827
1828    #[tokio::test(flavor = "multi_thread")]
1829    #[ignore = "requires Docker (testcontainers)"]
1830    async fn redis_cross_replica_single_fill() {
1831        use autumn_web::cache::GetOrComputeOptions;
1832
1833        let container = RedisImage::default().start().await.unwrap();
1834        let port = container.get_host_port_ipv4(6379).await.unwrap();
1835        let url = format!("redis://127.0.0.1:{port}");
1836
1837        // Two independent connections sharing the same key prefix, modeling
1838        // two application replicas.
1839        let replica_a: Arc<dyn Cache> =
1840            Arc::new(RedisCache::connect(&url, "xrepl-fill").await.unwrap());
1841        let replica_b: Arc<dyn Cache> =
1842            Arc::new(RedisCache::connect(&url, "xrepl-fill").await.unwrap());
1843
1844        let fill_count = Arc::new(AtomicUsize::new(0));
1845        let key = "hot-key";
1846
1847        let mut handles = Vec::new();
1848        for replica in [replica_a, replica_b] {
1849            for _ in 0..4 {
1850                let replica = replica.clone();
1851                let fc = fill_count.clone();
1852                let opts = GetOrComputeOptions::new()
1853                    .distributed_fill_lock(true)
1854                    .lock_poll_interval(Duration::from_millis(30))
1855                    .lock_wait_timeout(Duration::from_secs(5));
1856                handles.push(tokio::spawn(async move {
1857                    autumn_web::cache::get_or_compute_with::<String, String, _, _>(
1858                        &replica,
1859                        key,
1860                        opts,
1861                        move || async move {
1862                            fc.fetch_add(1, Ordering::SeqCst);
1863                            tokio::time::sleep(Duration::from_millis(300)).await;
1864                            Ok("cross-replica-value".to_string())
1865                        },
1866                    )
1867                    .await
1868                }));
1869            }
1870        }
1871
1872        for h in handles {
1873            let v = h.await.unwrap().unwrap();
1874            assert_eq!(v, "cross-replica-value");
1875        }
1876
1877        assert_eq!(
1878            fill_count.load(Ordering::SeqCst),
1879            1,
1880            "the distributed fill lock must limit total fills to 1 across both replicas"
1881        );
1882    }
1883}