openzeppelin_relayer/queues/redis/
refreshing_connection.rs

1//! A `ConnectionLike` wrapper that gives Redis connections a bounded lifetime,
2//! so they are periodically dropped and reopened.
3//!
4//! # Why this exists
5//!
6//! A long-lived Redis connection resolves the endpoint's DNS once, at connect
7//! time, and then keeps talking to whatever address it first resolved. If the
8//! DNS behind the endpoint later changes — due to failover, scaling, node
9//! replacement, blue/green cutover, or maintenance — the connection stays
10//! pinned to the OLD, now-stale node instead of following the endpoint to its
11//! new target (e.g. after an ElastiCache failover repoints the endpoint to a
12//! new node). A stale connection pinned to a demoted node keeps failing writes
13//! as one symptom, but the underlying problem is the stale DNS resolution, not
14//! any particular error. `ConnectionManager` only re-resolves DNS when it
15//! reconnects, and it reconnects on a broken socket — NOT on a valid error
16//! reply over a healthy socket, so it never notices the endpoint moved.
17//!
18//! `RefreshingConnection` fixes this by giving each connection a bounded
19//! lifetime: once a connection exceeds a jittered max age it is dropped and
20//! reopened before the next command, and a fresh connect re-resolves the
21//! endpoint's DNS onto whatever node it currently points to. Recovery from any
22//! DNS change is therefore bounded by the configured max age.
23//!
24//! # Reactive rebuild (accelerator)
25//!
26//! Age-based recycling is the guaranteed backstop, but recovery is only as fast
27//! as the configured max age. On top of it, connections are ALSO rebuilt
28//! reactively when a command reply indicates the connection is pinned to a
29//! read-only node — the tell-tale symptom of talking to a stale/demoted node
30//! after an endpoint change. When such a reply is observed the wrapper rebuilds
31//! the connection immediately (rate-limited by [`MIN_RECONNECT_GAP`]), so the
32//! very next command re-resolves DNS onto the current node instead of waiting
33//! out the age window. This detection intentionally covers BOTH shapes Redis
34//! uses for a write-on-replica failure: a top-level `Err(ReadOnly)` (direct
35//! write commands) AND an inline `Value::ServerError` embedded in an otherwise
36//! `Ok(..)` reply (apalis runs queue ops as Lua `EVALSHA` scripts, and the
37//! read-only failure is returned inside the script reply, not as a top-level
38//! error). Reactive rebuild is an accelerator only; it does not replace the
39//! age-based backstop, and it is not keyed on any specific error string beyond
40//! the read-only marker.
41//!
42//! # Shared healing state
43//!
44//! `RedisStorage` is cloned into every worker AND cloned per enqueue on the
45//! producer path. All clones of a given queue's connection therefore share ONE
46//! healing connection and ONE age clock via `Arc<Mutex<ConnState>>`. A rebuild
47//! performed by any clone is immediately visible to all other clones. This is
48//! what makes the fix correct in steady state: without shared state, the
49//! template living in the `Queue` struct would have a `created_at` fixed at
50//! setup that never advances, so every producer clone would be born
51//! already-expired once uptime exceeds `max_age`, opening a brand-new connection
52//! per enqueue forever.
53//!
54//! ## Locking discipline
55//!
56//! The `std::sync::Mutex` guard is NEVER held across an `.await`; it guards only
57//! tiny synchronous critical sections (read/clone the current conn, swap in a
58//! rebuilt conn). The actual command is delegated with no lock held, and the
59//! inner `ConnectionManager` is a multiplexed connection, so concurrent
60//! commands from different clones stay concurrent. Cloning `C` is cheap and
61//! shares the underlying multiplexed pipe.
62
63use std::future::Future;
64use std::pin::Pin;
65use std::sync::{Arc, Mutex};
66use std::time::{Duration, Instant};
67
68use redis::aio::{ConnectionLike, ConnectionManager, ConnectionManagerConfig};
69use redis::{Client, Cmd, Pipeline, RedisFuture, RedisResult, Value};
70use tracing::warn;
71
72/// Per-connection jitter fraction applied to the base max age (±20%).
73///
74/// Spreads reconnects across connections and instances so they do not reconnect
75/// in lockstep, which would otherwise cause synchronized load spikes on Redis.
76const JITTER_FRACTION: f64 = 0.20;
77
78/// Minimum gap between two reactive (read-only-triggered) rebuilds.
79///
80/// Rate-limits the reactive path so a burst of read-only replies (which all
81/// arrive before a single rebuild can take effect) triggers at most one
82/// rebuild per window instead of a reconnect storm.
83const MIN_RECONNECT_GAP: Duration = Duration::from_secs(3);
84
85/// Boxed future returned by the connection builder closure.
86type BuildFuture<C> = Pin<Box<dyn Future<Output = RedisResult<C>> + Send>>;
87
88/// Builds a fresh inner connection on demand. Cloneable so the wrapper can
89/// rebuild repeatedly and so the whole wrapper can be `Clone`.
90type Builder<C> = Arc<dyn Fn() -> BuildFuture<C> + Send + Sync>;
91
92/// Shared, interior-mutable healing state for a `RefreshingConnection`.
93///
94/// One instance is shared (behind `Arc<Mutex<..>>`) by all clones of a given
95/// queue's connection.
96struct ConnState<C: ConnectionLike + Clone + Send> {
97    conn: C,
98    created_at: Instant,
99    /// Un-jittered base; `None` disables age-based recycling.
100    base_max_age: Option<Duration>,
101    /// `base_max_age` ± jitter, re-drawn on each rebuild.
102    max_age: Option<Duration>,
103    /// Timestamp of the last reactive rebuild; gates the rate limit.
104    last_reconnect: Option<Instant>,
105}
106
107impl<C: ConnectionLike + Clone + Send> ConnState<C> {
108    /// Whether the current connection has exceeded its (jittered) max age.
109    fn is_expired(&self) -> bool {
110        matches!(self.max_age, Some(age) if self.created_at.elapsed() >= age)
111    }
112
113    /// Whether a reactive rebuild is allowed right now under the rate limit:
114    /// true if none has happened yet or at least `min_gap` has elapsed since
115    /// the last one.
116    fn reconnect_allowed(&self, min_gap: Duration) -> bool {
117        match self.last_reconnect {
118            None => true,
119            Some(at) => at.elapsed() >= min_gap,
120        }
121    }
122
123    /// Installs a freshly built connection: swaps it in, resets the age clock,
124    /// and re-draws the jittered max age around the fixed base.
125    fn install(&mut self, new_conn: C) {
126        self.conn = new_conn;
127        self.created_at = Instant::now();
128        self.max_age = self.base_max_age.map(jittered_max_age);
129    }
130
131    /// Claims the age-rebuild window: pushes the age clock back so the
132    /// connection only re-expires after `gap`. Concurrent clones then see a
133    /// non-expired connection (one build in flight, no thundering herd), and a
134    /// failed build cannot retry faster than the gap. A successful build
135    /// resets the clock fully via `install`.
136    ///
137    /// If the configured max age is shorter than `gap`, the effective retry
138    /// gap for failed builds is the max age itself. `checked_sub` guards
139    /// against `Instant` underflow on hosts whose monotonic clock started
140    /// recently; the fallback defers retry by one full age window instead.
141    fn defer_expiry(&mut self, gap: Duration) {
142        if let Some(age) = self.max_age {
143            let now = Instant::now();
144            self.created_at = now.checked_sub(age.saturating_sub(gap)).unwrap_or(now);
145        }
146    }
147}
148
149/// A `ConnectionLike` wrapper that periodically rebuilds its inner connection
150/// (bounded lifetime), sharing healing state across all clones.
151///
152/// Generic over the inner connection type so it can be unit-tested against a
153/// fake `ConnectionLike`; in production `C = ConnectionManager`.
154#[derive(Clone)]
155pub struct RefreshingConnection<C: ConnectionLike + Clone + Send> {
156    /// Builds a fresh inner connection (re-resolves DNS in production). Shared.
157    builder: Builder<C>,
158    /// Shared interior-mutable healing state. Cloning the wrapper clones this
159    /// `Arc`, so all clones share one connection and one age clock.
160    state: Arc<Mutex<ConnState<C>>>,
161    min_reconnect_gap: Duration,
162}
163
164/// Returns a deterministic, per-instance jitter multiplier in
165/// `[1 - JITTER_FRACTION, 1 + JITTER_FRACTION]`.
166///
167/// Each rebuild draws a fresh value so connections/instances do not reconnect
168/// in lockstep.
169fn jitter_multiplier() -> f64 {
170    let r: f64 = rand::random::<f64>(); // [0, 1)
171    1.0 - JITTER_FRACTION + r * (2.0 * JITTER_FRACTION)
172}
173
174/// Applies the jitter multiplier to a base max age.
175fn jittered_max_age(base: Duration) -> Duration {
176    base.mul_f64(jitter_multiplier())
177}
178
179impl<C: ConnectionLike + Clone + Send> RefreshingConnection<C> {
180    /// Creates a wrapper from an already-built inner connection and a builder
181    /// used to rebuild it later.
182    ///
183    /// `max_age_ms == 0` disables the age-based rebuild entirely. A non-zero
184    /// value is jittered per rebuild.
185    fn from_parts(builder: Builder<C>, conn: C, max_age_ms: u64) -> Self {
186        let base_max_age = if max_age_ms == 0 {
187            None
188        } else {
189            Some(Duration::from_millis(max_age_ms))
190        };
191        let max_age = base_max_age.map(jittered_max_age);
192        let state = ConnState {
193            conn,
194            created_at: Instant::now(),
195            base_max_age,
196            max_age,
197            last_reconnect: None,
198        };
199        Self {
200            builder,
201            state: Arc::new(Mutex::new(state)),
202            min_reconnect_gap: MIN_RECONNECT_GAP,
203        }
204    }
205
206    /// Locks the state guard, recovering from poisoning (a panic while healing
207    /// must not wedge the connection forever).
208    fn lock(&self) -> std::sync::MutexGuard<'_, ConnState<C>> {
209        self.state.lock().unwrap_or_else(|p| p.into_inner())
210    }
211
212    /// Resolves the connection to delegate to, performing a proactive age
213    /// rebuild first if needed.
214    ///
215    /// The rebuild window is claimed under the first lock BEFORE building
216    /// (via `defer_expiry`) — success or failure — so only one clone builds at
217    /// a time and a failed build retries no faster than `min_reconnect_gap`.
218    /// The `builder().await` runs with NO lock held.
219    async fn conn_for_command(&self) -> C {
220        let stale_conn = {
221            let mut guard = self.lock();
222            if !guard.is_expired() {
223                return guard.conn.clone();
224            }
225            guard.defer_expiry(self.min_reconnect_gap);
226            guard.conn.clone()
227        };
228
229        match (self.builder)().await {
230            Ok(new_conn) => {
231                let mut guard = self.lock();
232                guard.install(new_conn);
233                guard.conn.clone()
234            }
235            Err(e) => {
236                warn!(error = %e, "failed to rebuild Redis connection (age); keeping existing connection");
237                stale_conn
238            }
239        }
240    }
241
242    /// Reactively rebuilds the inner connection in response to a read-only
243    /// reply, rate-limited by `min_reconnect_gap`.
244    ///
245    /// The rate-limit window is claimed under the first lock BEFORE building —
246    /// success or failure — so concurrent clones short-circuit at the gate
247    /// (one build in flight, no thundering herd) and a failed rebuild retries
248    /// no faster than the gap. The `builder().await` runs with NO lock held.
249    async fn handle_readonly(&self) {
250        {
251            let mut guard = self.lock();
252            if !guard.reconnect_allowed(self.min_reconnect_gap) {
253                return;
254            }
255            guard.last_reconnect = Some(Instant::now());
256        }
257
258        match (self.builder)().await {
259            Ok(new_conn) => self.lock().install(new_conn),
260            Err(e) => {
261                warn!(error = %e, "failed to rebuild Redis connection (read-only); keeping existing connection");
262            }
263        }
264    }
265}
266
267/// Returns true if a reply indicates the connection is pinned to a read-only
268/// node, recursing into aggregate replies so nested and pipeline replies
269/// (e.g. Lua `EVALSHA` script results) are covered.
270///
271/// Redis surfaces a write-on-replica failure as an inline `Value::ServerError`
272/// carrying the `READONLY` code, which can appear at the top level or nested
273/// inside an aggregate — redis-rs only lifts it to a top-level `Err` one layer
274/// above this wrapper, so we must inspect the `Value` shape directly.
275fn is_readonly_value(v: &Value) -> bool {
276    match v {
277        Value::ServerError(e) => e.code() == "READONLY",
278        Value::Array(items) | Value::Set(items) => items.iter().any(is_readonly_value),
279        Value::Map(pairs) => pairs
280            .iter()
281            .any(|(k, val)| is_readonly_value(k) || is_readonly_value(val)),
282        Value::Push { data, .. } => data.iter().any(is_readonly_value),
283        Value::Attribute { data, .. } => is_readonly_value(data),
284        _ => false,
285    }
286}
287
288impl RefreshingConnection<ConnectionManager> {
289    /// Creates a `RefreshingConnection` wrapping a `ConnectionManager`.
290    ///
291    /// The builder recreates a `ConnectionManager` from `client` and
292    /// `cm_config`; a fresh `ConnectionManager` re-resolves the host via DNS,
293    /// which is how recreating a connection follows the CNAME onto the new
294    /// writer after failover.
295    pub fn new(
296        client: Client,
297        cm_config: ConnectionManagerConfig,
298        inner: ConnectionManager,
299        max_age_ms: u64,
300    ) -> Self {
301        let builder: Builder<ConnectionManager> = Arc::new(move || {
302            let client = client.clone();
303            let cm_config = cm_config.clone();
304            Box::pin(async move { ConnectionManager::new_with_config(client, cm_config).await })
305        });
306        Self::from_parts(builder, inner, max_age_ms)
307    }
308}
309
310impl<C: ConnectionLike + Clone + Send> ConnectionLike for RefreshingConnection<C> {
311    fn req_packed_command<'a>(&'a mut self, cmd: &'a Cmd) -> RedisFuture<'a, Value> {
312        Box::pin(async move {
313            let mut conn = self.conn_for_command().await;
314            let result = conn.req_packed_command(cmd).await;
315            // Reactive rebuild on a read-only reply, in either shape: a
316            // top-level `Err(ReadOnly)` (direct writes) or an inline
317            // `Value::ServerError` embedded in an `Ok(..)` reply (Lua scripts).
318            if matches!(&result, Err(e) if e.kind() == redis::ErrorKind::ReadOnly)
319                || matches!(&result, Ok(v) if is_readonly_value(v))
320            {
321                self.handle_readonly().await;
322            }
323            result
324        })
325    }
326
327    fn req_packed_commands<'a>(
328        &'a mut self,
329        cmd: &'a Pipeline,
330        offset: usize,
331        count: usize,
332    ) -> RedisFuture<'a, Vec<Value>> {
333        Box::pin(async move {
334            let mut conn = self.conn_for_command().await;
335            let result = conn.req_packed_commands(cmd, offset, count).await;
336            // Reactive rebuild on a read-only reply, in either shape: a
337            // top-level `Err(ReadOnly)` or an inline `Value::ServerError`
338            // embedded in any element of the `Ok(..)` pipeline reply.
339            if matches!(&result, Err(e) if e.kind() == redis::ErrorKind::ReadOnly)
340                || matches!(&result, Ok(vs) if vs.iter().any(is_readonly_value))
341            {
342                self.handle_readonly().await;
343            }
344            result
345        })
346    }
347
348    fn get_db(&self) -> i64 {
349        self.lock().conn.get_db()
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
357
358    /// How a `FakeConn` should reply to commands, so tests can reproduce the
359    /// exact production shapes of a read-only failure.
360    #[derive(Clone, Copy)]
361    enum Reply {
362        /// Normal success (`Value::Okay` / `[Value::Okay]`).
363        Ok,
364        /// Inline read-only `ServerError` inside an otherwise-`Ok(..)` reply —
365        /// the apalis Lua-script shape that a naive top-level `Err` guard
366        /// missed. THE regression case.
367        InlineReadonly,
368        /// Inline read-only nested one aggregate level deeper
369        /// (`Ok(Array[Array[ServerError]])`) — exercises the recursion arm.
370        NestedInlineReadonly,
371        /// Top-level `Err(ReadOnly)` — the shape direct write commands take.
372        ErrReadonly,
373    }
374
375    /// Parses a RESP `-READONLY ...` error line into the `Value::ServerError`
376    /// that redis-rs produces in production, WITHOUT naming redis's private
377    /// `ServerError`/`ServerErrorKind` types. This is exactly the inline error
378    /// value that appears embedded in an otherwise-`Ok(..)` script reply.
379    fn readonly_value() -> Value {
380        redis::parse_redis_value(b"-READONLY You can't write against a read only replica.\r\n")
381            .expect("parse READONLY server error")
382    }
383
384    /// A top-level `Err` whose kind is `ReadOnly`, as a direct write would
385    /// surface it one layer above this wrapper.
386    fn readonly_err() -> redis::RedisError {
387        redis::RedisError::from((redis::ErrorKind::ReadOnly, "read only replica"))
388    }
389
390    /// A fake `ConnectionLike` we fully control: it replies according to its
391    /// `reply` mode and records how many commands this instance served.
392    #[derive(Clone)]
393    struct FakeConn {
394        /// Unique id so tests can tell rebuilt connections apart.
395        id: u64,
396        /// Count of commands served by THIS connection instance.
397        calls: Arc<AtomicUsize>,
398        /// How this connection replies to commands.
399        reply: Reply,
400    }
401
402    impl FakeConn {
403        fn new(id: u64) -> Self {
404            Self::with_reply(id, Reply::Ok)
405        }
406
407        fn with_reply(id: u64, reply: Reply) -> Self {
408            Self {
409                id,
410                calls: Arc::new(AtomicUsize::new(0)),
411                reply,
412            }
413        }
414    }
415
416    impl ConnectionLike for FakeConn {
417        fn req_packed_command<'a>(&'a mut self, _cmd: &'a Cmd) -> RedisFuture<'a, Value> {
418            Box::pin(async move {
419                self.calls.fetch_add(1, Ordering::SeqCst);
420                match self.reply {
421                    Reply::Ok => Ok(Value::Okay),
422                    Reply::InlineReadonly => Ok(readonly_value()),
423                    Reply::NestedInlineReadonly => {
424                        Ok(Value::Array(vec![Value::Array(vec![readonly_value()])]))
425                    }
426                    Reply::ErrReadonly => Err(readonly_err()),
427                }
428            })
429        }
430
431        fn req_packed_commands<'a>(
432            &'a mut self,
433            _cmd: &'a Pipeline,
434            _offset: usize,
435            _count: usize,
436        ) -> RedisFuture<'a, Vec<Value>> {
437            Box::pin(async move {
438                self.calls.fetch_add(1, Ordering::SeqCst);
439                match self.reply {
440                    Reply::Ok => Ok(vec![Value::Okay]),
441                    Reply::InlineReadonly => Ok(vec![readonly_value()]),
442                    Reply::NestedInlineReadonly => {
443                        Ok(vec![Value::Array(vec![Value::Array(vec![
444                            readonly_value(),
445                        ])])])
446                    }
447                    Reply::ErrReadonly => Err(readonly_err()),
448                }
449            })
450        }
451
452        fn get_db(&self) -> i64 {
453            self.id as i64
454        }
455    }
456
457    /// Builds a `RefreshingConnection<FakeConn>` whose builder hands out a new
458    /// (healthy, `Reply::Ok`) `FakeConn` with incrementing id on each rebuild,
459    /// and counts rebuilds. The initial connection is also healthy.
460    fn make_conn(max_age_ms: u64) -> (RefreshingConnection<FakeConn>, Arc<AtomicU64>) {
461        make_conn_with_initial_reply(max_age_ms, Reply::Ok)
462    }
463
464    /// Like `make_conn`, but the INITIAL connection replies with `initial_reply`
465    /// (e.g. a read-only shape). Rebuilt connections are always healthy so that
466    /// a reactive rebuild resolves the condition after exactly one rebuild.
467    fn make_conn_with_initial_reply(
468        max_age_ms: u64,
469        initial_reply: Reply,
470    ) -> (RefreshingConnection<FakeConn>, Arc<AtomicU64>) {
471        let rebuild_count = Arc::new(AtomicU64::new(0));
472        let next_id = Arc::new(AtomicU64::new(1));
473        let rc = rebuild_count.clone();
474        let ni = next_id.clone();
475
476        let builder: Builder<FakeConn> = Arc::new(move || {
477            let rc = rc.clone();
478            let ni = ni.clone();
479            Box::pin(async move {
480                rc.fetch_add(1, Ordering::SeqCst);
481                let id = ni.fetch_add(1, Ordering::SeqCst);
482                Ok(FakeConn::new(id))
483            })
484        });
485
486        let initial = FakeConn::with_reply(next_id.fetch_add(1, Ordering::SeqCst), initial_reply);
487        let conn = RefreshingConnection::from_parts(builder, initial, max_age_ms);
488        (conn, rebuild_count)
489    }
490
491    /// An `Instant` far enough in the past to trip any age/gap check.
492    /// `checked_sub` with fallbacks: a bare `Instant - Duration` panics on
493    /// hosts whose monotonic clock started less than an hour ago (fresh
494    /// VMs/containers), which would make these tests flake.
495    fn distant_past() -> Instant {
496        let now = Instant::now();
497        [3600u64, 600, 60, 5]
498            .iter()
499            .find_map(|&secs| now.checked_sub(Duration::from_secs(secs)))
500            .unwrap_or(now)
501    }
502
503    /// Force the shared state to look expired regardless of jitter.
504    fn force_expire(conn: &RefreshingConnection<FakeConn>) {
505        let mut guard = conn.lock();
506        guard.created_at = distant_past();
507    }
508
509    /// Push `last_reconnect` far enough into the past that the reactive rate
510    /// limit no longer blocks another rebuild.
511    fn elapse_reconnect_gap(conn: &RefreshingConnection<FakeConn>) {
512        let mut guard = conn.lock();
513        guard.last_reconnect = Some(distant_past());
514    }
515
516    #[tokio::test]
517    async fn test_age_triggered_rebuild() {
518        // Tiny max age so the connection is immediately expired.
519        let (mut conn, rebuilds) = make_conn(1);
520        force_expire(&conn);
521        assert!(conn.lock().is_expired(), "connection should be expired");
522
523        let _ = conn.req_packed_command(&Cmd::new()).await;
524
525        assert_eq!(
526            rebuilds.load(Ordering::SeqCst),
527            1,
528            "aged connection should rebuild once before delegating"
529        );
530    }
531
532    #[tokio::test]
533    async fn test_age_rebuild_disabled_when_zero() {
534        // max_age_ms == 0 => AGE-based rebuild disabled. The reactive path is
535        // independent and stays active (see test_reactive_rebuild_*).
536        let (mut conn, rebuilds) = make_conn(0);
537        assert!(
538            conn.lock().max_age.is_none(),
539            "max_age should be None when disabled"
540        );
541        // Even a very old created_at must not trigger a rebuild.
542        force_expire(&conn);
543        assert!(!conn.lock().is_expired());
544
545        // A healthy reply must not trigger the reactive path either.
546        let _ = conn.req_packed_command(&Cmd::new()).await;
547
548        assert_eq!(
549            rebuilds.load(Ordering::SeqCst),
550            0,
551            "age-based rebuild must be disabled when max_age_ms == 0"
552        );
553    }
554
555    /// THE regression guard: an inline read-only `ServerError` embedded in an
556    /// otherwise-`Ok(..)` single-command reply (the apalis Lua-script shape a
557    /// naive top-level `Err` guard missed) must trigger exactly one reactive
558    /// rebuild. Age is disabled here so only the reactive path can fire.
559    #[tokio::test]
560    async fn test_reactive_rebuild_on_inline_readonly_command() {
561        let (mut conn, rebuilds) = make_conn_with_initial_reply(0, Reply::InlineReadonly);
562
563        let _ = conn.req_packed_command(&Cmd::new()).await;
564
565        assert_eq!(
566            rebuilds.load(Ordering::SeqCst),
567            1,
568            "inline Ok(Value::ServerError(READONLY)) must trigger exactly one reactive rebuild"
569        );
570    }
571
572    /// Same regression guard for the pipeline path: an inline read-only
573    /// `ServerError` nested in an `Ok(vec![..])` reply must trigger exactly one
574    /// reactive rebuild.
575    #[tokio::test]
576    async fn test_reactive_rebuild_on_inline_readonly_commands() {
577        let (mut conn, rebuilds) = make_conn_with_initial_reply(0, Reply::InlineReadonly);
578
579        let _ = conn.req_packed_commands(&Pipeline::new(), 0, 1).await;
580
581        assert_eq!(
582            rebuilds.load(Ordering::SeqCst),
583            1,
584            "inline Ok(vec![Value::ServerError(READONLY)]) must trigger exactly one reactive rebuild"
585        );
586    }
587
588    /// Direct-write shape: a top-level `Err(ReadOnly)` must also trigger a
589    /// reactive rebuild.
590    #[tokio::test]
591    async fn test_reactive_rebuild_on_err_readonly() {
592        let (mut conn, rebuilds) = make_conn_with_initial_reply(0, Reply::ErrReadonly);
593
594        let _ = conn.req_packed_command(&Cmd::new()).await;
595
596        assert_eq!(
597            rebuilds.load(Ordering::SeqCst),
598            1,
599            "top-level Err(ReadOnly) must trigger exactly one reactive rebuild"
600        );
601    }
602
603    /// The rate limit: repeated read-only replies within `min_reconnect_gap`
604    /// cause a single rebuild; once the gap has elapsed, another is allowed.
605    ///
606    /// Here the builder produces conns that STILL reply read-only, so the
607    /// condition never clears and every command re-enters the reactive path —
608    /// isolating the rate limit as the only thing that gates rebuild count.
609    #[tokio::test]
610    async fn test_reactive_rebuild_rate_limited() {
611        let rebuild_count = Arc::new(AtomicU64::new(0));
612        let next_id = Arc::new(AtomicU64::new(1));
613        let rc = rebuild_count.clone();
614        let ni = next_id.clone();
615
616        // Every built connection also replies read-only, so the reactive path
617        // fires on every command; only the rate limit caps the rebuild count.
618        let builder: Builder<FakeConn> = Arc::new(move || {
619            let rc = rc.clone();
620            let ni = ni.clone();
621            Box::pin(async move {
622                rc.fetch_add(1, Ordering::SeqCst);
623                let id = ni.fetch_add(1, Ordering::SeqCst);
624                Ok(FakeConn::with_reply(id, Reply::InlineReadonly))
625            })
626        });
627        let initial = FakeConn::with_reply(
628            next_id.fetch_add(1, Ordering::SeqCst),
629            Reply::InlineReadonly,
630        );
631        // Age disabled so only the reactive path can rebuild.
632        let mut conn = RefreshingConnection::from_parts(builder, initial, 0);
633
634        // First read-only reply: allowed (last_reconnect is None) -> 1 rebuild.
635        let _ = conn.req_packed_command(&Cmd::new()).await;
636        // Second read-only reply immediately after: within the gap -> no rebuild.
637        let _ = conn.req_packed_command(&Cmd::new()).await;
638
639        assert_eq!(
640            rebuild_count.load(Ordering::SeqCst),
641            1,
642            "repeated read-only within the gap must rebuild at most once"
643        );
644
645        // Simulate the gap elapsing: the next read-only reply is allowed again.
646        elapse_reconnect_gap(&conn);
647        let _ = conn.req_packed_command(&Cmd::new()).await;
648
649        assert_eq!(
650            rebuild_count.load(Ordering::SeqCst),
651            2,
652            "after the gap elapses, another reactive rebuild is allowed"
653        );
654    }
655
656    #[tokio::test]
657    async fn test_get_db_delegates_to_inner() {
658        let (conn, _) = make_conn(0);
659        // The initial FakeConn is assigned id 1 (before any builder rebuild).
660        assert_eq!(
661            conn.get_db(),
662            1,
663            "get_db must delegate to the inner connection"
664        );
665    }
666
667    /// Regression guard for the producer-path fix: cloning an already-expired
668    /// wrapper and driving several clones must NOT rebuild once per clone.
669    /// Because all clones share one `ConnState`, the proactive age rebuild
670    /// happens at most once across all of them.
671    #[tokio::test]
672    async fn test_shared_state_rebuilds_at_most_once_across_clones() {
673        let (base, rebuilds) = make_conn(60_000);
674        force_expire(&base);
675        assert!(base.lock().is_expired(), "base must start expired");
676
677        // Simulate the producer path: clone the template per enqueue.
678        let mut clone_a = base.clone();
679        let mut clone_b = base.clone();
680
681        let _ = clone_a.req_packed_command(&Cmd::new()).await;
682        let _ = clone_b.req_packed_command(&Cmd::new()).await;
683
684        // Shared state: the first command rebuilt and advanced the age clock;
685        // the second clone sees a fresh, non-expired connection and does NOT
686        // rebuild again.
687        assert_eq!(
688            rebuilds.load(Ordering::SeqCst),
689            1,
690            "shared state must rebuild at most once across clones, not once per clone"
691        );
692        assert!(
693            !base.lock().is_expired(),
694            "the age clock must have advanced (visible through all clones)"
695        );
696    }
697
698    /// Builds a wrapper whose builder ALWAYS fails, counting attempts — for
699    /// asserting that failed rebuilds are rate-limited instead of storming.
700    fn make_conn_with_failing_builder(
701        max_age_ms: u64,
702        initial_reply: Reply,
703    ) -> (RefreshingConnection<FakeConn>, Arc<AtomicU64>) {
704        let attempts = Arc::new(AtomicU64::new(0));
705        let at = attempts.clone();
706        let builder: Builder<FakeConn> = Arc::new(move || {
707            let at = at.clone();
708            Box::pin(async move {
709                at.fetch_add(1, Ordering::SeqCst);
710                Err(redis::RedisError::from((
711                    redis::ErrorKind::IoError,
712                    "connect failed",
713                )))
714            })
715        });
716        let initial = FakeConn::with_reply(1, initial_reply);
717        let conn = RefreshingConnection::from_parts(builder, initial, max_age_ms);
718        (conn, attempts)
719    }
720
721    /// A FAILED reactive rebuild must still consume the rate-limit window;
722    /// otherwise every read-only reply during an outage would hammer the
723    /// builder (reconnect storm).
724    #[tokio::test]
725    async fn test_reactive_failed_rebuild_is_rate_limited() {
726        let (mut conn, attempts) = make_conn_with_failing_builder(0, Reply::InlineReadonly);
727
728        // First read-only reply: one (failed) build attempt claims the window.
729        // Subsequent read-only replies within the gap must not retry.
730        let _ = conn.req_packed_command(&Cmd::new()).await;
731        let _ = conn.req_packed_command(&Cmd::new()).await;
732        let _ = conn.req_packed_command(&Cmd::new()).await;
733        assert_eq!(
734            attempts.load(Ordering::SeqCst),
735            1,
736            "failed reactive rebuilds within the gap must not retry the builder"
737        );
738        assert_eq!(conn.get_db(), 1, "existing connection must be preserved");
739
740        // Once the gap elapses, one more attempt is allowed.
741        elapse_reconnect_gap(&conn);
742        let _ = conn.req_packed_command(&Cmd::new()).await;
743        assert_eq!(
744            attempts.load(Ordering::SeqCst),
745            2,
746            "after the gap elapses, another rebuild attempt is allowed"
747        );
748    }
749
750    /// A FAILED age rebuild must defer the next attempt (via the claimed
751    /// window) instead of retrying the builder on every command.
752    #[tokio::test]
753    async fn test_age_failed_rebuild_is_rate_limited() {
754        let (mut conn, attempts) = make_conn_with_failing_builder(60_000, Reply::Ok);
755        force_expire(&conn);
756
757        // First command: one (failed) build attempt claims the window.
758        // Further commands within the gap reuse the stale connection.
759        let _ = conn.req_packed_command(&Cmd::new()).await;
760        let _ = conn.req_packed_command(&Cmd::new()).await;
761        let _ = conn.req_packed_command(&Cmd::new()).await;
762        assert_eq!(
763            attempts.load(Ordering::SeqCst),
764            1,
765            "a failed age rebuild must not retry the builder on every command"
766        );
767        assert_eq!(conn.get_db(), 1, "existing connection must be preserved");
768
769        // Once the claim expires, a retry is allowed.
770        force_expire(&conn);
771        let _ = conn.req_packed_command(&Cmd::new()).await;
772        assert_eq!(
773            attempts.load(Ordering::SeqCst),
774            2,
775            "after the claim expires, another rebuild attempt is allowed"
776        );
777    }
778
779    /// The aggregate recursion: a read-only `ServerError` nested one level
780    /// deeper (array-in-array, as a script reply can be shaped) must still
781    /// trigger the reactive rebuild.
782    #[tokio::test]
783    async fn test_reactive_rebuild_on_nested_readonly() {
784        let (mut conn, rebuilds) = make_conn_with_initial_reply(0, Reply::NestedInlineReadonly);
785
786        let _ = conn.req_packed_command(&Cmd::new()).await;
787
788        assert_eq!(
789            rebuilds.load(Ordering::SeqCst),
790            1,
791            "a READONLY nested inside aggregates must trigger a reactive rebuild"
792        );
793    }
794}