Skip to main content

cachekit/
reliability.rs

1//! Reliability tier: backpressure (bounded backend concurrency), retry with
2//! exponential backoff + jitter, and a closed/open/half-open circuit breaker
3//! around backend operations.
4//!
5//! All three are composed by a private `ReliableBackend` decorator around any
6//! [`crate::backend::Backend`], applied by the builder when a [`ReliabilityConfig`] is set
7//! (see [`crate::CacheKitBuilder::reliability`]). The intent presets
8//! `production`, `secure`, and `io` enable it by default; `minimal` does
9//! not — mirroring the TypeScript SDK's preset posture.
10//!
11//! Composition order is `backpressure(breaker(retry(op)))`:
12//!
13//! - The retry loop is *inside* the breaker (matching the TypeScript SDK's
14//!   `ReliabilityExecutor`), so one exhausted retry sequence counts as a
15//!   single breaker failure, and a fast-failing open breaker never spends
16//!   time retrying.
17//! - The concurrency limiter is *outermost*: one permit per logical cache
18//!   operation, held across the entire breaker/retry sequence. That bounds
19//!   in-flight work including retry amplification (K callers mid-backoff are
20//!   still K permits — new work queues behind them instead of piling onto a
21//!   struggling backend), and a shed call never touches breaker counters or
22//!   half-open probe slots, so the breaker keeps measuring backend health,
23//!   not caller-side overload. A permit holder never re-enters the limiter
24//!   (backend ops don't nest), so holding permits across retry backoff
25//!   cannot deadlock.
26//!
27//! Unlike the TypeScript breaker (which counts every error), only errors
28//! classified retryable by [`crate::error::BackendErrorKind::is_retryable`] (`Transient`,
29//! `Timeout`) count toward opening the circuit: they are the backend-health
30//! signals. `Permanent` / `Authentication` errors are request-specific — five
31//! malformed requests must not cut off healthy traffic.
32//!
33//! Requires a tokio runtime for backoff timers (`redis` and `cachekitio`
34//! backends already do). Not available on wasm32 targets.
35
36use std::future::Future;
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::sync::{Arc, Mutex, PoisonError};
39use std::time::{Duration, Instant};
40
41use async_trait::async_trait;
42
43use crate::backend::{Backend, HealthStatus, LockableBackend};
44use crate::client::SharedBackend;
45use crate::error::BackendError;
46use crate::random_unit;
47
48// ── Configuration ────────────────────────────────────────────────────────────
49
50/// Retry policy configuration (truncated exponential backoff with jitter).
51#[derive(Debug, Clone, PartialEq)]
52pub struct RetryConfig {
53    /// Total attempts, including the first (default: 3). `0` behaves as `1`.
54    pub max_attempts: u32,
55    /// Backoff base delay; attempt *n* waits `base_delay * 2^n` (default: 100 ms).
56    pub base_delay: Duration,
57    /// Backoff ceiling (default: 5 s).
58    pub max_delay: Duration,
59    /// Multiply each delay by a random factor in `[0.5, 1.5)` (default: true).
60    pub jitter: bool,
61}
62
63impl Default for RetryConfig {
64    fn default() -> Self {
65        Self {
66            max_attempts: 3,
67            base_delay: Duration::from_millis(100),
68            max_delay: Duration::from_secs(5),
69            jitter: true,
70        }
71    }
72}
73
74/// Circuit breaker configuration.
75///
76/// Defaults mirror the TypeScript SDK's production preset
77/// (`PRODUCTION_RELIABILITY` in `cachekit-ts/src/intents.ts`).
78#[derive(Debug, Clone, PartialEq)]
79pub struct CircuitBreakerConfig {
80    /// Retryable failures within [`Self::rolling_window`] before the circuit
81    /// opens (default: 5).
82    pub failure_threshold: u32,
83    /// Successes in half-open state required to close the circuit (default: 3).
84    pub success_threshold: u32,
85    /// How long the circuit stays open before allowing half-open probes
86    /// (default: 5 s).
87    pub open_timeout: Duration,
88    /// Maximum concurrent probe calls in half-open state (default: 3).
89    pub half_open_max_calls: u32,
90    /// Rolling window for failure counting (default: 60 s).
91    pub rolling_window: Duration,
92}
93
94impl Default for CircuitBreakerConfig {
95    fn default() -> Self {
96        Self {
97            failure_threshold: 5,
98            success_threshold: 3,
99            open_timeout: Duration::from_secs(5),
100            half_open_max_calls: 3,
101            rolling_window: Duration::from_secs(60),
102        }
103    }
104}
105
106/// Backpressure configuration: bound how many backend data operations may be
107/// in flight at once, so a slow or failing backend cannot exhaust the
108/// caller's connection pool or memory.
109///
110/// Defaults mirror the Python SDK's `BackpressureConfig`
111/// (`max_concurrent_requests: 100`, `queue_size: 1000`, `timeout: 0.1s`).
112///
113/// Over-limit calls first join a bounded waiting queue; a caller that finds
114/// the queue full, or that waits longer than [`Self::acquire_timeout`]
115/// without a permit freeing up, is shed with a
116/// [`crate::error::BackendErrorKind::Backpressure`] error — never queued
117/// unboundedly. Shed calls do not reach the backend and do not count toward
118/// opening the circuit breaker.
119///
120/// On the `#[cachekit]` macro's plain path a shed is outage-class — exactly
121/// like `CircuitOpen`, the wrapped function runs uncached (fail-open);
122/// `secure` paths fail closed on a shed like on every other backend error.
123#[derive(Debug, Clone, PartialEq)]
124pub struct BackpressureConfig {
125    /// Maximum backend data operations in flight at once (default: 100).
126    /// `0` behaves as `1`; values above tokio's `Semaphore::MAX_PERMITS`
127    /// (`usize::MAX >> 3`) are clamped to it, so `usize::MAX` reads as
128    /// "effectively unbounded" rather than panicking the builder.
129    pub max_concurrent: usize,
130    /// Maximum callers waiting for a permit before further calls are shed
131    /// immediately (default: 1000). `0` disables waiting entirely: a call
132    /// that cannot take a permit on the spot is shed.
133    pub max_queue: usize,
134    /// How long a queued caller waits for a permit before it is shed
135    /// (default: 100 ms).
136    pub acquire_timeout: Duration,
137}
138
139impl Default for BackpressureConfig {
140    fn default() -> Self {
141        Self {
142            max_concurrent: 100,
143            max_queue: 1000,
144            acquire_timeout: Duration::from_millis(100),
145        }
146    }
147}
148
149/// Reliability stack configuration: which layers to apply around backend ops.
150///
151/// The `Default` enables all layers with production defaults. Disable a
152/// layer by setting its field to `None`:
153///
154/// ```
155/// use cachekit::reliability::ReliabilityConfig;
156///
157/// let retry_only = ReliabilityConfig {
158///     circuit_breaker: None,
159///     backpressure: None,
160///     ..ReliabilityConfig::default()
161/// };
162/// assert!(retry_only.retry.is_some());
163/// ```
164#[derive(Debug, Clone, PartialEq)]
165pub struct ReliabilityConfig {
166    /// Retry policy, or `None` to propagate every error on first failure.
167    pub retry: Option<RetryConfig>,
168    /// Circuit breaker, or `None` to never fail fast.
169    pub circuit_breaker: Option<CircuitBreakerConfig>,
170    /// Concurrency limiter, or `None` for unbounded backend concurrency.
171    pub backpressure: Option<BackpressureConfig>,
172}
173
174impl Default for ReliabilityConfig {
175    fn default() -> Self {
176        Self {
177            retry: Some(RetryConfig::default()),
178            circuit_breaker: Some(CircuitBreakerConfig::default()),
179            backpressure: Some(BackpressureConfig::default()),
180        }
181    }
182}
183
184impl ReliabilityConfig {
185    /// A config with every layer off — the documented preset opt-out.
186    ///
187    /// Prefer this over spelling out a struct literal with all-`None`
188    /// fields: a literal breaks downstream code every time the stack gains
189    /// a layer (it has, twice).
190    ///
191    /// ```
192    /// use cachekit::reliability::ReliabilityConfig;
193    ///
194    /// assert!(ReliabilityConfig::disabled().is_disabled());
195    /// assert!(!ReliabilityConfig::default().is_disabled());
196    /// ```
197    #[must_use]
198    pub fn disabled() -> Self {
199        Self {
200            retry: None,
201            circuit_breaker: None,
202            backpressure: None,
203        }
204    }
205
206    /// `true` when no layer is enabled — the builder skips the (no-op)
207    /// `ReliableBackend` decorator entirely. Lives here, next to the fields,
208    /// so adding a layer cannot silently miss the builder gate again.
209    #[must_use]
210    pub fn is_disabled(&self) -> bool {
211        self.retry.is_none() && self.circuit_breaker.is_none() && self.backpressure.is_none()
212    }
213}
214
215// ── RetryPolicy ──────────────────────────────────────────────────────────────
216
217/// Retries an operation on errors where [`crate::error::BackendErrorKind::is_retryable`] is
218/// true, sleeping a truncated exponential backoff (with jitter) between
219/// attempts. `Permanent` and `Authentication` errors propagate immediately.
220#[derive(Debug)]
221pub(crate) struct RetryPolicy {
222    config: RetryConfig,
223}
224
225impl RetryPolicy {
226    pub(crate) fn new(config: RetryConfig) -> Self {
227        Self { config }
228    }
229
230    fn delay(&self, attempt: u32) -> Duration {
231        let exp = self
232            .config
233            .base_delay
234            .saturating_mul(2u32.saturating_pow(attempt));
235        let capped = exp.min(self.config.max_delay);
236        if self.config.jitter {
237            capped.mul_f64(0.5 + random_unit())
238        } else {
239            capped
240        }
241    }
242
243    pub(crate) async fn execute<T, F, Fut>(&self, f: F) -> Result<T, BackendError>
244    where
245        F: Fn() -> Fut,
246        Fut: Future<Output = Result<T, BackendError>>,
247    {
248        let mut attempt: u32 = 0;
249        loop {
250            match f().await {
251                Ok(v) => return Ok(v),
252                Err(e) if e.kind.is_retryable() && attempt + 1 < self.config.max_attempts => {
253                    tokio::time::sleep(self.delay(attempt)).await;
254                    attempt += 1;
255                }
256                Err(e) => return Err(e),
257            }
258        }
259    }
260}
261
262// ── CircuitBreaker ───────────────────────────────────────────────────────────
263
264/// Circuit breaker state, as reported by
265/// [`CacheKit::circuit_state`](crate::CacheKit::circuit_state) and carried by
266/// the `tracing` transition events (`from` / `to` fields).
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum CircuitState {
269    /// Normal operation; calls pass through.
270    Closed,
271    /// Failing fast; calls return a [`crate::error::BackendErrorKind::CircuitOpen`] error
272    /// without reaching the backend.
273    Open,
274    /// Probing recovery with a bounded number of trial calls.
275    HalfOpen,
276}
277
278#[derive(Debug)]
279enum State {
280    Closed,
281    Open { since: Instant },
282    HalfOpen,
283}
284
285impl From<&State> for CircuitState {
286    fn from(state: &State) -> Self {
287        match state {
288            State::Closed => Self::Closed,
289            State::Open { .. } => Self::Open,
290            State::HalfOpen => Self::HalfOpen,
291        }
292    }
293}
294
295#[derive(Debug)]
296struct BreakerInner {
297    state: State,
298    /// Timestamps of counted failures inside the rolling window.
299    failures: Vec<Instant>,
300    half_open_successes: u32,
301    half_open_calls: u32,
302    /// Number of state transitions so far; stamps each [`Transition`] with
303    /// its `seq` under the lock.
304    transitions: u64,
305}
306
307/// How a completed call is reported back to the breaker.
308enum Outcome {
309    Success,
310    /// A retryable-kind failure — a backend-health signal.
311    Failure,
312    /// A non-retryable failure (permanent/auth) — request-specific, does not
313    /// count toward opening the circuit but must release its half-open slot,
314    /// or a burst of permanent errors would wedge the breaker half-open.
315    Neutral,
316}
317
318/// State machine: closed → (failures ≥ threshold in window) → open →
319/// (open_timeout elapsed) → half-open → (successes ≥ threshold) → closed,
320/// or (any counted failure) → open.
321#[derive(Debug)]
322pub(crate) struct CircuitBreaker {
323    /// Process-unique id, stable for the breaker's lifetime; the `breaker`
324    /// field on transition events. Several clients under one subscriber each
325    /// restart `seq` at 1, so `(breaker, seq)` is the ordering key.
326    id: u64,
327    config: CircuitBreakerConfig,
328    inner: Mutex<BreakerInner>,
329}
330
331/// Source of [`CircuitBreaker::id`]s. Per process: fleet-wide correlation
332/// combines the field with the host/process context the subscriber adds.
333static BREAKER_IDS: AtomicU64 = AtomicU64::new(0);
334
335impl CircuitBreaker {
336    pub(crate) fn new(config: CircuitBreakerConfig) -> Self {
337        Self {
338            id: BREAKER_IDS.fetch_add(1, Ordering::Relaxed) + 1,
339            config,
340            inner: Mutex::new(BreakerInner {
341                state: State::Closed,
342                failures: Vec::new(),
343                half_open_successes: 0,
344                half_open_calls: 0,
345                transitions: 0,
346            }),
347        }
348    }
349
350    fn lock(&self) -> std::sync::MutexGuard<'_, BreakerInner> {
351        // A poisoned lock means a panic mid-update; breaker state is advisory,
352        // so recovering the guard is strictly better than propagating panics.
353        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
354    }
355
356    /// Current state (transitions open → half-open lazily on inspection).
357    pub(crate) fn state(&self) -> CircuitState {
358        let mut inner = self.lock();
359        let change = self.maybe_half_open(&mut inner);
360        let state = CircuitState::from(&inner.state);
361        drop(inner);
362        emit(change);
363        state
364    }
365
366    fn maybe_half_open(&self, inner: &mut BreakerInner) -> Option<Transition> {
367        let State::Open { since } = inner.state else {
368            return None;
369        };
370        if since.elapsed() < self.config.open_timeout {
371            return None;
372        }
373        let change = transition(self.id, inner, State::HalfOpen);
374        inner.half_open_successes = 0;
375        inner.half_open_calls = 0;
376        Some(change)
377    }
378
379    /// Admit a call, or fail fast with a circuit-open error.
380    ///
381    /// Returns an RAII [`ProbePermit`]: if the guarded future is cancelled
382    /// (caller timeout/`select!`) or panics before an outcome is recorded,
383    /// the permit's `Drop` releases any half-open probe slot it took —
384    /// otherwise `half_open_max_calls` cancelled probes would wedge the
385    /// breaker half-open forever, fast-failing every call even against a
386    /// recovered backend.
387    fn try_acquire(&self) -> Result<ProbePermit<'_>, BackendError> {
388        let mut inner = self.lock();
389        let change = self.maybe_half_open(&mut inner);
390        let admitted = match inner.state {
391            State::Closed => Ok(ProbePermit {
392                breaker: self,
393                took_slot: false,
394            }),
395            State::Open { .. } => Err(BackendError::circuit_open(
396                "circuit breaker is open: backend calls are failing fast",
397            )),
398            State::HalfOpen => {
399                if inner.half_open_calls >= self.config.half_open_max_calls {
400                    Err(BackendError::circuit_open(
401                        "circuit breaker is half-open and the probe limit is reached",
402                    ))
403                } else {
404                    inner.half_open_calls += 1;
405                    Ok(ProbePermit {
406                        breaker: self,
407                        took_slot: true,
408                    })
409                }
410            }
411        };
412        drop(inner);
413        emit(change);
414        admitted
415    }
416
417    fn record(&self, outcome: &Outcome) {
418        let mut inner = self.lock();
419        let mut change = None;
420        match outcome {
421            Outcome::Success => {
422                if matches!(inner.state, State::HalfOpen) {
423                    inner.half_open_successes += 1;
424                    if inner.half_open_successes >= self.config.success_threshold {
425                        change = Some(transition(self.id, &mut inner, State::Closed));
426                        inner.failures.clear();
427                        inner.half_open_successes = 0;
428                        inner.half_open_calls = 0;
429                    } else {
430                        // Release this probe's slot. `half_open_calls` caps the
431                        // number of *in-flight* probes, so a success that does
432                        // not yet close the breaker must free its slot (exactly
433                        // as `Neutral` does). Without this, a config with
434                        // success_threshold > half_open_max_calls wedges the
435                        // breaker half-open forever: the slots fill, successes
436                        // stall below the threshold, and every subsequent call
437                        // fails fast with CircuitOpen even against a healthy
438                        // backend.
439                        inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
440                    }
441                }
442            }
443            Outcome::Failure => match inner.state {
444                State::HalfOpen => {
445                    let now = Instant::now();
446                    change = Some(transition(self.id, &mut inner, State::Open { since: now }));
447                    inner.half_open_successes = 0;
448                    inner.half_open_calls = 0;
449                }
450                State::Closed => {
451                    let now = Instant::now();
452                    inner.failures.push(now);
453                    let window = self.config.rolling_window;
454                    inner.failures.retain(|t| now.duration_since(*t) <= window);
455                    if inner.failures.len() >= self.config.failure_threshold as usize {
456                        change = Some(transition(self.id, &mut inner, State::Open { since: now }));
457                        inner.failures.clear();
458                    }
459                }
460                // Open without an admitted call cannot report a failure;
461                // ignore rather than extend the open window.
462                State::Open { .. } => {}
463            },
464            Outcome::Neutral => {
465                if matches!(inner.state, State::HalfOpen) {
466                    inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
467                }
468            }
469        }
470        drop(inner);
471        emit(change);
472    }
473}
474
475/// A breaker state change handed back by [`transition`] so the caller can
476/// report it once the breaker lock is released.
477///
478/// `(breaker, seq)` is the ordering key. `seq` is assigned under the lock and
479/// counts this breaker's transitions; `breaker` says which breaker, since
480/// several clients under one subscriber each restart `seq` at 1. Emission
481/// happens after unlock, so two transitions can reach a subscriber in the
482/// wrong wall-clock order (`Closed → Open` committed, unlock, a zero
483/// `open_timeout` lets another caller commit and emit `Open → HalfOpen`
484/// first); a consumer that groups by `breaker` and orders by `seq`
485/// reconstructs each true timeline.
486#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
487#[derive(Debug, Clone, Copy)]
488struct Transition {
489    breaker: u64,
490    seq: u64,
491    from: CircuitState,
492    to: CircuitState,
493}
494
495/// Apply a breaker state change under the lock and return it for [`emit`].
496fn transition(breaker: u64, inner: &mut BreakerInner, to: State) -> Transition {
497    inner.transitions += 1;
498    let change = Transition {
499        breaker,
500        seq: inner.transitions,
501        from: CircuitState::from(&inner.state),
502        to: CircuitState::from(&to),
503    };
504    inner.state = to;
505    change
506}
507
508/// Report a transition on the `tracing` target (feature `tracing`): `warn`
509/// when the circuit opens — traffic is now failing fast — `info` for half-open
510/// and closed. Fields: `breaker`, `seq`, `from`, `to`.
511///
512/// Must be called with the breaker lock **released**: a subscriber may react
513/// to the event by calling
514/// [`CacheKit::circuit_state`](crate::CacheKit::circuit_state), which takes
515/// the same lock, and a synchronous writer must not stall every other
516/// worker's `try_acquire` for the duration of the write. The price is that
517/// emission order is not transition order under contention — see
518/// [`Transition`]; `(breaker, seq)` is what a consumer orders by.
519fn emit(change: Option<Transition>) {
520    #[cfg(feature = "tracing")]
521    if let Some(Transition {
522        breaker,
523        seq,
524        from,
525        to,
526    }) = change
527    {
528        if to == CircuitState::Open {
529            tracing::warn!(breaker, seq, from = ?from, to = ?to, "circuit breaker opened");
530        } else {
531            tracing::info!(breaker, seq, from = ?from, to = ?to, "circuit breaker transitioned");
532        }
533    }
534    #[cfg(not(feature = "tracing"))]
535    let _ = change;
536}
537
538// ── ProbePermit ──────────────────────────────────────────────────────────────
539
540/// RAII token for a breaker-admitted call.
541///
542/// Slot accounting lives in exactly one of two places: [`Self::complete`]
543/// (normal return — the outcome arms of `record` own the bookkeeping from
544/// there) or `Drop` (cancel/panic — release the slot like `Neutral`, no
545/// transition). Manual increment/decrement pairs leaked twice before this
546/// guard existed; do not reintroduce them.
547#[derive(Debug)]
548struct ProbePermit<'a> {
549    breaker: &'a CircuitBreaker,
550    /// Whether this admission consumed a half-open probe slot.
551    took_slot: bool,
552}
553
554impl ProbePermit<'_> {
555    /// Report the call's outcome and disarm the drop-release.
556    fn complete(mut self, outcome: &Outcome) {
557        self.took_slot = false;
558        self.breaker.record(outcome);
559    }
560}
561
562impl Drop for ProbePermit<'_> {
563    fn drop(&mut self) {
564        if !self.took_slot {
565            return;
566        }
567        // No outcome was recorded: the guarded future was cancelled mid-await
568        // or panicked. Free the probe slot so the half-open window can keep
569        // probing; if the breaker transitioned meanwhile (counters reset),
570        // the saturating decrement is a no-op.
571        let mut inner = self.breaker.lock();
572        if matches!(inner.state, State::HalfOpen) {
573            inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
574        }
575    }
576}
577
578// ── ConcurrencyLimiter ───────────────────────────────────────────────────────
579
580/// Bounds concurrent backend data operations with a semaphore and a bounded
581/// waiting queue (two-phase, like the Python SDK's `BackpressureController`):
582/// a saturated limiter admits up to `max_queue` waiters for at most
583/// `acquire_timeout` each; everyone else is shed with a
584/// [`crate::error::BackendErrorKind::Backpressure`] error.
585#[derive(Debug)]
586pub(crate) struct ConcurrencyLimiter {
587    semaphore: tokio::sync::Semaphore,
588    /// Callers currently waiting for a permit (phase-2 queue depth).
589    waiting: std::sync::atomic::AtomicUsize,
590    config: BackpressureConfig,
591}
592
593/// RAII guard for a slot in the waiting queue: decrements `waiting` on every
594/// exit path, including cancellation mid-`acquire` (same lesson as
595/// [`ProbePermit`] — manual increment/decrement pairs leak on cancel).
596struct QueueSlot<'a> {
597    waiting: &'a std::sync::atomic::AtomicUsize,
598}
599
600impl Drop for QueueSlot<'_> {
601    fn drop(&mut self) {
602        self.waiting
603            .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
604    }
605}
606
607impl ConcurrencyLimiter {
608    pub(crate) fn new(config: BackpressureConfig) -> Self {
609        Self {
610            // `Semaphore::new(0)` would shed every call after acquire_timeout
611            // with nothing ever admitted — clamp like RetryConfig's "0
612            // behaves as 1". The upper clamp matters too: `Semaphore::new`
613            // PANICS above `MAX_PERMITS` (usize::MAX >> 3), and usize::MAX
614            // is the natural "effectively unbounded" sentinel a caller will
615            // reach for — a config value must never panic the builder.
616            semaphore: tokio::sync::Semaphore::new(
617                config
618                    .max_concurrent
619                    .clamp(1, tokio::sync::Semaphore::MAX_PERMITS),
620            ),
621            waiting: std::sync::atomic::AtomicUsize::new(0),
622            config,
623        }
624    }
625
626    /// Take a permit, or shed the call.
627    ///
628    /// Phase 1: a free permit is taken on the spot — no queue accounting.
629    /// Phase 2 (saturated): join the bounded waiting queue and wait up to
630    /// `acquire_timeout` for a permit; queue-full and wait-timeout both shed
631    /// with a `Backpressure` error. The returned permit releases on drop, so
632    /// a cancelled or panicking caller can never leak capacity.
633    async fn acquire(&self) -> Result<tokio::sync::SemaphorePermit<'_>, BackendError> {
634        use std::sync::atomic::Ordering;
635
636        if let Ok(permit) = self.semaphore.try_acquire() {
637            return Ok(permit);
638        }
639        if self.waiting.fetch_add(1, Ordering::AcqRel) >= self.config.max_queue {
640            self.waiting.fetch_sub(1, Ordering::AcqRel);
641            return Err(BackendError::backpressure(format!(
642                "backpressure: waiting queue is full (max_queue={}), call shed without reaching the backend",
643                self.config.max_queue
644            )));
645        }
646        let _slot = QueueSlot {
647            waiting: &self.waiting,
648        };
649        match tokio::time::timeout(self.config.acquire_timeout, self.semaphore.acquire()).await {
650            Ok(Ok(permit)) => Ok(permit),
651            // The semaphore is never closed; treat a close defensively as shed.
652            Ok(Err(_closed)) => Err(BackendError::backpressure(
653                "backpressure: limiter unavailable, call shed without reaching the backend",
654            )),
655            Err(_elapsed) => Err(BackendError::backpressure(format!(
656                "backpressure: timed out waiting for a permit after {:?}, call shed without reaching the backend",
657                self.config.acquire_timeout
658            ))),
659        }
660    }
661}
662
663// ── ReliableBackend ──────────────────────────────────────────────────────────
664
665/// Decorator that applies the reliability stack to every cache operation of
666/// an inner [`Backend`]: `backpressure(breaker(retry(op)))`.
667///
668/// - `get`/`set`/`delete`/`exists` take a concurrency-limiter permit, are
669///   retried on retryable errors, and gated by the circuit breaker.
670/// - `health` passes through unguarded — it is a diagnostic and must keep
671///   reporting truthfully while the breaker fails data calls fast (or the
672///   limiter sheds them).
673/// - [`Backend::as_lockable`] forwards to the inner backend so distributed
674///   fill locks bypass the stack (locks are best-effort advisory).
675pub(crate) struct ReliableBackend {
676    inner: SharedBackend,
677    retry: Option<RetryPolicy>,
678    /// Shared with the owning `CacheKit` so
679    /// [`CacheKit::circuit_state`](crate::CacheKit::circuit_state) can read
680    /// it through the type-erased backend.
681    breaker: Option<Arc<CircuitBreaker>>,
682    limiter: Option<ConcurrencyLimiter>,
683}
684
685impl ReliableBackend {
686    pub(crate) fn new(inner: SharedBackend, config: ReliabilityConfig) -> Self {
687        Self {
688            inner,
689            retry: config.retry.map(RetryPolicy::new),
690            breaker: config
691                .circuit_breaker
692                .map(|config| Arc::new(CircuitBreaker::new(config))),
693            limiter: config.backpressure.map(ConcurrencyLimiter::new),
694        }
695    }
696
697    async fn guarded<T, F, Fut>(&self, f: F) -> Result<T, BackendError>
698    where
699        F: Fn() -> Fut,
700        Fut: Future<Output = Result<T, BackendError>>,
701    {
702        // Outermost layer: one permit per logical operation, held across the
703        // whole breaker/retry sequence (see the module docs for why). A shed
704        // call returns here — before touching breaker state.
705        let _permit = match &self.limiter {
706            Some(limiter) => Some(limiter.acquire().await?),
707            None => None,
708        };
709        let permit = match &self.breaker {
710            Some(cb) => Some(cb.try_acquire()?),
711            None => None,
712        };
713        let result = match &self.retry {
714            Some(retry) => retry.execute(f).await,
715            None => f().await,
716        };
717        if let Some(permit) = permit {
718            let outcome = match &result {
719                Ok(_) => Outcome::Success,
720                Err(e) if e.kind.is_retryable() => Outcome::Failure,
721                Err(_) => Outcome::Neutral,
722            };
723            permit.complete(&outcome);
724        }
725        result
726    }
727}
728
729#[cfg_attr(not(feature = "unsync"), async_trait)]
730#[cfg_attr(feature = "unsync", async_trait(?Send))]
731impl Backend for ReliableBackend {
732    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
733        self.guarded(|| self.inner.get(key)).await
734    }
735
736    async fn set(
737        &self,
738        key: &str,
739        value: Vec<u8>,
740        ttl: Option<Duration>,
741    ) -> Result<(), BackendError> {
742        // Clone per attempt: the inner call consumes the buffer.
743        self.guarded(|| self.inner.set(key, value.clone(), ttl))
744            .await
745    }
746
747    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
748        self.guarded(|| self.inner.delete(key)).await
749    }
750
751    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
752        self.guarded(|| self.inner.exists(key)).await
753    }
754
755    async fn health(&self) -> Result<HealthStatus, BackendError> {
756        self.inner.health().await
757    }
758
759    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
760        self.inner.as_lockable()
761    }
762}
763
764/// Wrap `inner` in a [`ReliableBackend`] and re-share it, handing back the
765/// breaker (if configured) for the client's state accessor.
766pub(crate) fn wrap_reliable(
767    inner: SharedBackend,
768    config: ReliabilityConfig,
769) -> (SharedBackend, Option<Arc<CircuitBreaker>>) {
770    let reliable = ReliableBackend::new(inner, config);
771    let breaker = reliable.breaker.clone();
772    #[cfg(not(feature = "unsync"))]
773    let shared: SharedBackend = std::sync::Arc::new(reliable);
774    #[cfg(feature = "unsync")]
775    let shared: SharedBackend = std::rc::Rc::new(reliable);
776    (shared, breaker)
777}
778
779// ── Unit tests ───────────────────────────────────────────────────────────────
780
781#[cfg(test)]
782#[allow(clippy::expect_used)] // test-only: failed acquire/probe should panic loudly
783mod tests {
784    use super::*;
785    use crate::error::BackendErrorKind;
786
787    fn breaker(failure_threshold: u32, open_timeout: Duration) -> CircuitBreaker {
788        CircuitBreaker::new(CircuitBreakerConfig {
789            failure_threshold,
790            success_threshold: 2,
791            open_timeout,
792            half_open_max_calls: 2,
793            rolling_window: Duration::from_secs(60),
794        })
795    }
796
797    /// Admit a call and immediately report its outcome.
798    fn admit_and(cb: &CircuitBreaker, outcome: &Outcome) {
799        let permit = cb.try_acquire().expect("breaker admits the call");
800        permit.complete(outcome);
801    }
802
803    #[test]
804    fn breaker_opens_after_threshold_and_fails_fast() {
805        let cb = breaker(3, Duration::from_secs(60));
806        for _ in 0..3 {
807            admit_and(&cb, &Outcome::Failure);
808        }
809        assert_eq!(cb.state(), CircuitState::Open);
810        let err = cb.try_acquire().expect_err("open breaker fails fast");
811        assert_eq!(err.kind, BackendErrorKind::CircuitOpen);
812        assert!(!err.kind.is_retryable());
813    }
814
815    #[test]
816    fn breaker_ignores_permanent_errors() {
817        let cb = breaker(2, Duration::from_secs(60));
818        for _ in 0..10 {
819            admit_and(&cb, &Outcome::Neutral);
820        }
821        assert_eq!(cb.state(), CircuitState::Closed);
822    }
823
824    #[test]
825    fn breaker_half_open_recovers_on_successes() {
826        let cb = breaker(1, Duration::from_millis(0));
827        admit_and(&cb, &Outcome::Failure);
828        // open_timeout of zero → immediately half-open on next inspection
829        assert_eq!(cb.state(), CircuitState::HalfOpen);
830        for _ in 0..2 {
831            admit_and(&cb, &Outcome::Success);
832        }
833        assert_eq!(cb.state(), CircuitState::Closed);
834    }
835
836    #[test]
837    fn breaker_half_open_reopens_on_failure() {
838        let cb = breaker(1, Duration::from_millis(0));
839        admit_and(&cb, &Outcome::Failure);
840        assert_eq!(cb.state(), CircuitState::HalfOpen);
841        admit_and(&cb, &Outcome::Failure);
842        // Freshly re-opened with a zero timeout flips half-open again on
843        // inspection, so assert via the internal state before inspecting.
844        assert!(matches!(cb.lock().state, State::Open { .. }));
845    }
846
847    #[test]
848    fn breaker_half_open_slot_released_by_neutral_outcome() {
849        let cb = breaker(1, Duration::from_millis(0));
850        admit_and(&cb, &Outcome::Failure);
851        assert_eq!(cb.state(), CircuitState::HalfOpen);
852        // Exhaust both probe slots with permanent errors...
853        admit_and(&cb, &Outcome::Neutral);
854        admit_and(&cb, &Outcome::Neutral);
855        // ...and the breaker still admits probes instead of wedging.
856        let permit = cb
857            .try_acquire()
858            .expect("neutral outcomes release their probe slots");
859        permit.complete(&Outcome::Neutral);
860    }
861
862    #[test]
863    fn breaker_half_open_closes_when_success_threshold_exceeds_probe_cap() {
864        // success_threshold (3) deliberately exceeds half_open_max_calls (1):
865        // with a single in-flight probe slot, the breaker can only ever reach
866        // three successes if each non-closing success RELEASES its slot. Before
867        // the fix this wedged half-open forever — the slot filled after the
868        // first success (which stalled at 1 < 3), so no further probe was
869        // admitted and the breaker never re-closed.
870        let cb = CircuitBreaker::new(CircuitBreakerConfig {
871            failure_threshold: 1,
872            success_threshold: 3,
873            open_timeout: Duration::from_millis(0),
874            half_open_max_calls: 1,
875            rolling_window: Duration::from_secs(60),
876        });
877        admit_and(&cb, &Outcome::Failure);
878        assert_eq!(cb.state(), CircuitState::HalfOpen);
879        for _ in 0..3 {
880            let permit = cb
881                .try_acquire()
882                .expect("a non-closing success must release its probe slot");
883            permit.complete(&Outcome::Success);
884        }
885        assert_eq!(cb.state(), CircuitState::Closed);
886    }
887
888    #[test]
889    fn breaker_dropped_permit_releases_probe_slot() {
890        // A probe future cancelled (caller timeout / select!) or panicked
891        // before recording an outcome must not consume its slot forever:
892        // exhaust every half-open slot with plain drops and the breaker must
893        // still admit probes instead of wedging half-open until restart.
894        let cb = breaker(1, Duration::from_millis(0));
895        admit_and(&cb, &Outcome::Failure);
896        assert_eq!(cb.state(), CircuitState::HalfOpen);
897        for _ in 0..2 {
898            let permit = cb.try_acquire().expect("half-open admits a probe");
899            drop(permit); // cancelled before any outcome
900        }
901        let permit = cb
902            .try_acquire()
903            .expect("dropped permits release their probe slots");
904        permit.complete(&Outcome::Success);
905    }
906
907    #[test]
908    fn breaker_closed_permit_drop_does_not_touch_half_open_accounting() {
909        // A call admitted while CLOSED holds no probe slot; cancelling it
910        // must not free (or corrupt) slots in a half-open window that opened
911        // after its admission.
912        let cb = breaker(1, Duration::from_millis(0));
913        let closed_permit = cb.try_acquire().expect("closed breaker admits calls");
914        // Another call's failure opens the breaker, then zero timeout flips
915        // it half-open with a fresh probe window.
916        admit_and(&cb, &Outcome::Failure);
917        assert_eq!(cb.state(), CircuitState::HalfOpen);
918        let p1 = cb.try_acquire().expect("probe slot 1");
919        let p2 = cb.try_acquire().expect("probe slot 2");
920        drop(closed_permit); // must be a no-op: it never took a slot
921        assert!(
922            cb.try_acquire().is_err(),
923            "probe cap must still be enforced after a closed-state permit drops"
924        );
925        p1.complete(&Outcome::Success);
926        p2.complete(&Outcome::Success);
927        assert_eq!(cb.state(), CircuitState::Closed);
928    }
929
930    #[test]
931    fn reliability_default_enables_backpressure_with_python_parity_defaults() {
932        let config = ReliabilityConfig::default();
933        let bp = config.backpressure.expect("backpressure is on by default");
934        assert_eq!(bp.max_concurrent, 100);
935        assert_eq!(bp.max_queue, 1000);
936        assert_eq!(bp.acquire_timeout, Duration::from_millis(100));
937    }
938
939    #[tokio::test]
940    async fn limiter_clamps_zero_max_concurrent_to_one() {
941        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
942            max_concurrent: 0,
943            max_queue: 0,
944            acquire_timeout: Duration::from_millis(10),
945        });
946        let permit = limiter
947            .acquire()
948            .await
949            .expect("0 behaves as 1 — one permit exists");
950        drop(permit);
951    }
952
953    #[tokio::test]
954    async fn limiter_clamps_huge_max_concurrent_instead_of_panicking() {
955        // usize::MAX is the natural "unbounded" sentinel; Semaphore::new
956        // panics above MAX_PERMITS, so the constructor must clamp.
957        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
958            max_concurrent: usize::MAX,
959            max_queue: 0,
960            acquire_timeout: Duration::from_millis(10),
961        });
962        let permit = limiter.acquire().await.expect("clamped limiter admits");
963        drop(permit);
964    }
965
966    #[tokio::test]
967    async fn limiter_sheds_immediately_when_queue_disabled() {
968        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
969            max_concurrent: 1,
970            max_queue: 0,
971            acquire_timeout: Duration::from_secs(5),
972        });
973        let _held = limiter.acquire().await.expect("first permit");
974        let start = Instant::now();
975        let err = limiter
976            .acquire()
977            .await
978            .expect_err("saturated with no waiting queue");
979        assert_eq!(err.kind, BackendErrorKind::Backpressure);
980        assert!(!err.kind.is_retryable());
981        assert!(
982            start.elapsed() < Duration::from_millis(500),
983            "queue-full sheds immediately, not after acquire_timeout"
984        );
985    }
986
987    #[tokio::test]
988    async fn limiter_waiting_slot_released_on_cancelled_wait() {
989        // A waiter cancelled mid-acquire (caller timeout / select!) must free
990        // its queue slot via the QueueSlot drop guard. With max_queue: 1, a
991        // leaked slot would shed the next waiter instantly as queue-full;
992        // joining the queue (observable as waiting out the acquire_timeout)
993        // proves the slot was released.
994        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
995            max_concurrent: 1,
996            max_queue: 1,
997            acquire_timeout: Duration::from_millis(100),
998        });
999        let _held = limiter.acquire().await.expect("first permit");
1000        let cancelled = tokio::time::timeout(Duration::from_millis(20), limiter.acquire()).await;
1001        assert!(cancelled.is_err(), "waiter cancelled from outside");
1002
1003        let start = Instant::now();
1004        let err = limiter
1005            .acquire()
1006            .await
1007            .expect_err("permit never frees, waiter times out");
1008        assert_eq!(err.kind, BackendErrorKind::Backpressure);
1009        assert!(
1010            start.elapsed() >= Duration::from_millis(80),
1011            "must join the queue and wait out acquire_timeout — an instant \
1012             queue-full shed means the cancelled waiter leaked its slot"
1013        );
1014    }
1015
1016    #[tokio::test]
1017    async fn limiter_sheds_queue_full_at_nonzero_boundary() {
1018        // cap 1, queue 1: with the permit held and one waiter parked, a
1019        // third caller must shed instantly as queue-full — pinning the
1020        // fetch_add boundary arithmetic at a nonzero max_queue.
1021        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1022            max_concurrent: 1,
1023            max_queue: 1,
1024            acquire_timeout: Duration::from_millis(200),
1025        });
1026        let _held = limiter.acquire().await.expect("first permit");
1027        let waiter = async {
1028            // Parks in the queue immediately and times out after 200 ms.
1029            limiter.acquire().await
1030        };
1031        let third = async {
1032            tokio::time::sleep(Duration::from_millis(50)).await; // waiter parked
1033            let start = Instant::now();
1034            let err = limiter.acquire().await.expect_err("queue of 1 is full");
1035            assert_eq!(err.kind, BackendErrorKind::Backpressure);
1036            assert!(
1037                start.elapsed() < Duration::from_millis(100),
1038                "queue-full sheds instantly, not after the wait timeout"
1039            );
1040        };
1041        let (waited, ()) = tokio::join!(waiter, third);
1042        waited.expect_err("the parked waiter itself times out");
1043    }
1044
1045    #[test]
1046    fn retry_delay_is_capped_and_jittered() {
1047        let policy = RetryPolicy::new(RetryConfig {
1048            max_attempts: 5,
1049            base_delay: Duration::from_millis(100),
1050            max_delay: Duration::from_millis(300),
1051            jitter: true,
1052        });
1053        for attempt in 0..10 {
1054            let d = policy.delay(attempt);
1055            // cap 300ms × jitter [0.5, 1.5) → strictly under 450ms
1056            assert!(d < Duration::from_millis(450), "attempt {attempt}: {d:?}");
1057        }
1058        let no_jitter = RetryPolicy::new(RetryConfig {
1059            jitter: false,
1060            ..RetryConfig::default()
1061        });
1062        assert_eq!(no_jitter.delay(0), Duration::from_millis(100));
1063        assert_eq!(no_jitter.delay(1), Duration::from_millis(200));
1064        assert_eq!(no_jitter.delay(20), Duration::from_secs(5));
1065    }
1066}