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}