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, Freshness, 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 // Forwarded, not defaulted: the default would call `self.get` and drop
737 // the inner backend's freshness, unbounding the L1 backfill.
738 async fn get_with_freshness(
739 &self,
740 key: &str,
741 ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
742 self.guarded(|| self.inner.get_with_freshness(key)).await
743 }
744
745 async fn set(
746 &self,
747 key: &str,
748 value: Vec<u8>,
749 ttl: Option<Duration>,
750 ) -> Result<(), BackendError> {
751 // Clone per attempt: the inner call consumes the buffer.
752 self.guarded(|| self.inner.set(key, value.clone(), ttl))
753 .await
754 }
755
756 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
757 self.guarded(|| self.inner.delete(key)).await
758 }
759
760 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
761 self.guarded(|| self.inner.exists(key)).await
762 }
763
764 async fn health(&self) -> Result<HealthStatus, BackendError> {
765 self.inner.health().await
766 }
767
768 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
769 self.inner.as_lockable()
770 }
771}
772
773/// Wrap `inner` in a [`ReliableBackend`] and re-share it, handing back the
774/// breaker (if configured) for the client's state accessor.
775pub(crate) fn wrap_reliable(
776 inner: SharedBackend,
777 config: ReliabilityConfig,
778) -> (SharedBackend, Option<Arc<CircuitBreaker>>) {
779 let reliable = ReliableBackend::new(inner, config);
780 let breaker = reliable.breaker.clone();
781 #[cfg(not(feature = "unsync"))]
782 let shared: SharedBackend = std::sync::Arc::new(reliable);
783 #[cfg(feature = "unsync")]
784 let shared: SharedBackend = std::rc::Rc::new(reliable);
785 (shared, breaker)
786}
787
788// ── Unit tests ───────────────────────────────────────────────────────────────
789
790#[cfg(test)]
791#[allow(clippy::expect_used)] // test-only: failed acquire/probe should panic loudly
792mod tests {
793 use super::*;
794 use crate::error::BackendErrorKind;
795
796 fn breaker(failure_threshold: u32, open_timeout: Duration) -> CircuitBreaker {
797 CircuitBreaker::new(CircuitBreakerConfig {
798 failure_threshold,
799 success_threshold: 2,
800 open_timeout,
801 half_open_max_calls: 2,
802 rolling_window: Duration::from_secs(60),
803 })
804 }
805
806 /// Admit a call and immediately report its outcome.
807 fn admit_and(cb: &CircuitBreaker, outcome: &Outcome) {
808 let permit = cb.try_acquire().expect("breaker admits the call");
809 permit.complete(outcome);
810 }
811
812 #[test]
813 fn breaker_opens_after_threshold_and_fails_fast() {
814 let cb = breaker(3, Duration::from_secs(60));
815 for _ in 0..3 {
816 admit_and(&cb, &Outcome::Failure);
817 }
818 assert_eq!(cb.state(), CircuitState::Open);
819 let err = cb.try_acquire().expect_err("open breaker fails fast");
820 assert_eq!(err.kind, BackendErrorKind::CircuitOpen);
821 assert!(!err.kind.is_retryable());
822 }
823
824 #[test]
825 fn breaker_ignores_permanent_errors() {
826 let cb = breaker(2, Duration::from_secs(60));
827 for _ in 0..10 {
828 admit_and(&cb, &Outcome::Neutral);
829 }
830 assert_eq!(cb.state(), CircuitState::Closed);
831 }
832
833 #[test]
834 fn breaker_half_open_recovers_on_successes() {
835 let cb = breaker(1, Duration::from_millis(0));
836 admit_and(&cb, &Outcome::Failure);
837 // open_timeout of zero → immediately half-open on next inspection
838 assert_eq!(cb.state(), CircuitState::HalfOpen);
839 for _ in 0..2 {
840 admit_and(&cb, &Outcome::Success);
841 }
842 assert_eq!(cb.state(), CircuitState::Closed);
843 }
844
845 #[test]
846 fn breaker_half_open_reopens_on_failure() {
847 let cb = breaker(1, Duration::from_millis(0));
848 admit_and(&cb, &Outcome::Failure);
849 assert_eq!(cb.state(), CircuitState::HalfOpen);
850 admit_and(&cb, &Outcome::Failure);
851 // Freshly re-opened with a zero timeout flips half-open again on
852 // inspection, so assert via the internal state before inspecting.
853 assert!(matches!(cb.lock().state, State::Open { .. }));
854 }
855
856 #[test]
857 fn breaker_half_open_slot_released_by_neutral_outcome() {
858 let cb = breaker(1, Duration::from_millis(0));
859 admit_and(&cb, &Outcome::Failure);
860 assert_eq!(cb.state(), CircuitState::HalfOpen);
861 // Exhaust both probe slots with permanent errors...
862 admit_and(&cb, &Outcome::Neutral);
863 admit_and(&cb, &Outcome::Neutral);
864 // ...and the breaker still admits probes instead of wedging.
865 let permit = cb
866 .try_acquire()
867 .expect("neutral outcomes release their probe slots");
868 permit.complete(&Outcome::Neutral);
869 }
870
871 #[test]
872 fn breaker_half_open_closes_when_success_threshold_exceeds_probe_cap() {
873 // success_threshold (3) deliberately exceeds half_open_max_calls (1):
874 // with a single in-flight probe slot, the breaker can only ever reach
875 // three successes if each non-closing success RELEASES its slot. Before
876 // the fix this wedged half-open forever — the slot filled after the
877 // first success (which stalled at 1 < 3), so no further probe was
878 // admitted and the breaker never re-closed.
879 let cb = CircuitBreaker::new(CircuitBreakerConfig {
880 failure_threshold: 1,
881 success_threshold: 3,
882 open_timeout: Duration::from_millis(0),
883 half_open_max_calls: 1,
884 rolling_window: Duration::from_secs(60),
885 });
886 admit_and(&cb, &Outcome::Failure);
887 assert_eq!(cb.state(), CircuitState::HalfOpen);
888 for _ in 0..3 {
889 let permit = cb
890 .try_acquire()
891 .expect("a non-closing success must release its probe slot");
892 permit.complete(&Outcome::Success);
893 }
894 assert_eq!(cb.state(), CircuitState::Closed);
895 }
896
897 #[test]
898 fn breaker_dropped_permit_releases_probe_slot() {
899 // A probe future cancelled (caller timeout / select!) or panicked
900 // before recording an outcome must not consume its slot forever:
901 // exhaust every half-open slot with plain drops and the breaker must
902 // still admit probes instead of wedging half-open until restart.
903 let cb = breaker(1, Duration::from_millis(0));
904 admit_and(&cb, &Outcome::Failure);
905 assert_eq!(cb.state(), CircuitState::HalfOpen);
906 for _ in 0..2 {
907 let permit = cb.try_acquire().expect("half-open admits a probe");
908 drop(permit); // cancelled before any outcome
909 }
910 let permit = cb
911 .try_acquire()
912 .expect("dropped permits release their probe slots");
913 permit.complete(&Outcome::Success);
914 }
915
916 #[test]
917 fn breaker_closed_permit_drop_does_not_touch_half_open_accounting() {
918 // A call admitted while CLOSED holds no probe slot; cancelling it
919 // must not free (or corrupt) slots in a half-open window that opened
920 // after its admission.
921 let cb = breaker(1, Duration::from_millis(0));
922 let closed_permit = cb.try_acquire().expect("closed breaker admits calls");
923 // Another call's failure opens the breaker, then zero timeout flips
924 // it half-open with a fresh probe window.
925 admit_and(&cb, &Outcome::Failure);
926 assert_eq!(cb.state(), CircuitState::HalfOpen);
927 let p1 = cb.try_acquire().expect("probe slot 1");
928 let p2 = cb.try_acquire().expect("probe slot 2");
929 drop(closed_permit); // must be a no-op: it never took a slot
930 assert!(
931 cb.try_acquire().is_err(),
932 "probe cap must still be enforced after a closed-state permit drops"
933 );
934 p1.complete(&Outcome::Success);
935 p2.complete(&Outcome::Success);
936 assert_eq!(cb.state(), CircuitState::Closed);
937 }
938
939 #[test]
940 fn reliability_default_enables_backpressure_with_python_parity_defaults() {
941 let config = ReliabilityConfig::default();
942 let bp = config.backpressure.expect("backpressure is on by default");
943 assert_eq!(bp.max_concurrent, 100);
944 assert_eq!(bp.max_queue, 1000);
945 assert_eq!(bp.acquire_timeout, Duration::from_millis(100));
946 }
947
948 #[tokio::test]
949 async fn limiter_clamps_zero_max_concurrent_to_one() {
950 let limiter = ConcurrencyLimiter::new(BackpressureConfig {
951 max_concurrent: 0,
952 max_queue: 0,
953 acquire_timeout: Duration::from_millis(10),
954 });
955 let permit = limiter
956 .acquire()
957 .await
958 .expect("0 behaves as 1 — one permit exists");
959 drop(permit);
960 }
961
962 #[tokio::test]
963 async fn limiter_clamps_huge_max_concurrent_instead_of_panicking() {
964 // usize::MAX is the natural "unbounded" sentinel; Semaphore::new
965 // panics above MAX_PERMITS, so the constructor must clamp.
966 let limiter = ConcurrencyLimiter::new(BackpressureConfig {
967 max_concurrent: usize::MAX,
968 max_queue: 0,
969 acquire_timeout: Duration::from_millis(10),
970 });
971 let permit = limiter.acquire().await.expect("clamped limiter admits");
972 drop(permit);
973 }
974
975 #[tokio::test]
976 async fn limiter_sheds_immediately_when_queue_disabled() {
977 let limiter = ConcurrencyLimiter::new(BackpressureConfig {
978 max_concurrent: 1,
979 max_queue: 0,
980 acquire_timeout: Duration::from_secs(5),
981 });
982 let _held = limiter.acquire().await.expect("first permit");
983 let start = Instant::now();
984 let err = limiter
985 .acquire()
986 .await
987 .expect_err("saturated with no waiting queue");
988 assert_eq!(err.kind, BackendErrorKind::Backpressure);
989 assert!(!err.kind.is_retryable());
990 assert!(
991 start.elapsed() < Duration::from_millis(500),
992 "queue-full sheds immediately, not after acquire_timeout"
993 );
994 }
995
996 #[tokio::test]
997 async fn limiter_waiting_slot_released_on_cancelled_wait() {
998 // A waiter cancelled mid-acquire (caller timeout / select!) must free
999 // its queue slot via the QueueSlot drop guard. With max_queue: 1, a
1000 // leaked slot would shed the next waiter instantly as queue-full;
1001 // joining the queue (observable as waiting out the acquire_timeout)
1002 // proves the slot was released.
1003 let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1004 max_concurrent: 1,
1005 max_queue: 1,
1006 acquire_timeout: Duration::from_millis(100),
1007 });
1008 let _held = limiter.acquire().await.expect("first permit");
1009 let cancelled = tokio::time::timeout(Duration::from_millis(20), limiter.acquire()).await;
1010 assert!(cancelled.is_err(), "waiter cancelled from outside");
1011
1012 let start = Instant::now();
1013 let err = limiter
1014 .acquire()
1015 .await
1016 .expect_err("permit never frees, waiter times out");
1017 assert_eq!(err.kind, BackendErrorKind::Backpressure);
1018 assert!(
1019 start.elapsed() >= Duration::from_millis(80),
1020 "must join the queue and wait out acquire_timeout — an instant \
1021 queue-full shed means the cancelled waiter leaked its slot"
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn limiter_sheds_queue_full_at_nonzero_boundary() {
1027 // cap 1, queue 1: with the permit held and one waiter parked, a
1028 // third caller must shed instantly as queue-full — pinning the
1029 // fetch_add boundary arithmetic at a nonzero max_queue.
1030 let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1031 max_concurrent: 1,
1032 max_queue: 1,
1033 acquire_timeout: Duration::from_millis(200),
1034 });
1035 let _held = limiter.acquire().await.expect("first permit");
1036 let waiter = async {
1037 // Parks in the queue immediately and times out after 200 ms.
1038 limiter.acquire().await
1039 };
1040 let third = async {
1041 tokio::time::sleep(Duration::from_millis(50)).await; // waiter parked
1042 let start = Instant::now();
1043 let err = limiter.acquire().await.expect_err("queue of 1 is full");
1044 assert_eq!(err.kind, BackendErrorKind::Backpressure);
1045 assert!(
1046 start.elapsed() < Duration::from_millis(100),
1047 "queue-full sheds instantly, not after the wait timeout"
1048 );
1049 };
1050 let (waited, ()) = tokio::join!(waiter, third);
1051 waited.expect_err("the parked waiter itself times out");
1052 }
1053
1054 #[test]
1055 fn retry_delay_is_capped_and_jittered() {
1056 let policy = RetryPolicy::new(RetryConfig {
1057 max_attempts: 5,
1058 base_delay: Duration::from_millis(100),
1059 max_delay: Duration::from_millis(300),
1060 jitter: true,
1061 });
1062 for attempt in 0..10 {
1063 let d = policy.delay(attempt);
1064 // cap 300ms × jitter [0.5, 1.5) → strictly under 450ms
1065 assert!(d < Duration::from_millis(450), "attempt {attempt}: {d:?}");
1066 }
1067 let no_jitter = RetryPolicy::new(RetryConfig {
1068 jitter: false,
1069 ..RetryConfig::default()
1070 });
1071 assert_eq!(no_jitter.delay(0), Duration::from_millis(100));
1072 assert_eq!(no_jitter.delay(1), Duration::from_millis(200));
1073 assert_eq!(no_jitter.delay(20), Duration::from_secs(5));
1074 }
1075}