Skip to main content

decdn_cache/
circuit_breaker.rs

1//! Per-origin half-open circuit-breaker for pull-through (#963).
2//!
3//! When an origin is fully unavailable, every cache miss would otherwise
4//! run the full retry cycle (up to `max_retries` with exponential
5//! backoff up to `max_backoff_ms`), serializing that backoff cost across
6//! all concurrent misses with no way to fast-fail until the origin
7//! recovers. This module adds a per-origin breaker so the cache fails
8//! fast and sheds load during a sustained outage, then probes for
9//! recovery.
10//!
11//! ## State machine
12//!
13//! Standard three-state breaker, one instance per origin in the
14//! fallback chain:
15//!
16//! - **CLOSED** — requests flow into the retry loop. Consecutive
17//!   origin-unavailable failures are counted; on reaching
18//!   `failure_threshold` the breaker trips to OPEN. Any success (or a
19//!   permanent per-object answer like 404) resets the count to zero.
20//! - **OPEN** — every miss fast-fails *before* the retry/backoff loop
21//!   (no backoff incurred) until `cooldown_ms` has elapsed since the
22//!   trip, at which point the next acquire transitions to HALF-OPEN.
23//! - **HALF-OPEN** — up to `half_open_max_calls` trial pulls are
24//!   admitted (each running its normal retry budget). A trial success
25//!   closes the breaker; a trial failure re-opens it and resets the
26//!   cooldown. Trials beyond the in-flight budget fast-fail like OPEN.
27//!
28//! ## Failure classification
29//!
30//! Only **origin-unavailable** outcomes count toward the threshold —
31//! transient failures that exhausted the retry budget. **Permanent
32//! per-object** outcomes (HTTP 404, other 4xx, decode/cap breaches) are
33//! recorded as *available*: the origin responded, it just can't serve
34//! *this* object. Tripping the breaker on 404s would shed load for
35//! content the origin is perfectly able to serve. See
36//! [`OriginOutcome`].
37//!
38//! ## Clock injection
39//!
40//! Timekeeping goes through the [`Clock`] trait so tests drive the
41//! cooldown deterministically with [`ManualClock`] instead of real
42//! `sleep`s. Production uses [`SystemClock`] (monotonic `Instant`).
43
44use std::sync::Arc;
45use std::sync::Mutex;
46use std::sync::PoisonError;
47use std::time::Duration;
48use std::time::Instant;
49
50use decdn_config_types::CircuitBreakerPolicy;
51
52use crate::metrics::CacheMetrics;
53
54/// Monotonic time source for the breaker, injectable so tests can drive
55/// the cooldown deterministically rather than sleeping.
56///
57/// The single method returns an opaque "now" as a [`Duration`] since an
58/// arbitrary fixed epoch — the breaker only ever compares two such
59/// values, never reads wall-clock time, so a `Duration` from a private
60/// origin is sufficient and keeps the manual clock a trivial counter.
61pub trait Clock: Send + Sync + std::fmt::Debug {
62    /// Monotonic now, as elapsed time since this clock's fixed epoch.
63    fn now(&self) -> Duration;
64}
65
66/// Production clock: monotonic [`Instant`] measured from the clock's
67/// construction. Never goes backwards, immune to wall-clock jumps.
68#[derive(Debug)]
69pub struct SystemClock {
70    epoch: Instant,
71}
72
73impl SystemClock {
74    /// Construct a system clock whose epoch is the moment of
75    /// construction.
76    #[must_use]
77    pub fn new() -> Self {
78        Self {
79            epoch: Instant::now(),
80        }
81    }
82}
83
84impl Default for SystemClock {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl Clock for SystemClock {
91    fn now(&self) -> Duration {
92        self.epoch.elapsed()
93    }
94}
95
96/// Test clock: a manually-advanced counter. Deterministic — no real
97/// time passes. Cloneable so a test can hold one handle and the breaker
98/// another, both observing the same advancing value.
99#[derive(Debug, Clone)]
100pub struct ManualClock {
101    now: Arc<Mutex<Duration>>,
102}
103
104impl ManualClock {
105    /// Construct a manual clock starting at `t = 0`.
106    #[must_use]
107    pub fn new() -> Self {
108        Self {
109            now: Arc::new(Mutex::new(Duration::ZERO)),
110        }
111    }
112
113    /// Advance the clock by `delta`. Saturates rather than panics on
114    /// overflow (a test advancing past `Duration::MAX` is a test bug,
115    /// not a reason to violate the anti-panic policy).
116    pub fn advance(&self, delta: Duration) {
117        let mut g = self.now.lock().unwrap_or_else(PoisonError::into_inner);
118        *g = g.saturating_add(delta);
119    }
120}
121
122impl Default for ManualClock {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl Clock for ManualClock {
129    fn now(&self) -> Duration {
130        *self.now.lock().unwrap_or_else(PoisonError::into_inner)
131    }
132}
133
134/// The health of a single per-origin pull-through attempt, as seen by
135/// the breaker. The engine maps each origin's
136/// `run_with_retry_classified` result onto exactly one of these.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum OriginOutcome {
139    /// The origin responded usefully — bytes committed, a definitive
140    /// `NotFound` (404), or a permanent per-object error (other 4xx,
141    /// decode/cap breach). The origin is *reachable*; the breaker treats
142    /// all of these as a success signal and resets the failure count.
143    Available,
144    /// The origin was unavailable — a transient failure that exhausted
145    /// the retry budget (5xx storm, connect/timeout/reset). This is the
146    /// only outcome that counts toward the trip threshold.
147    Unavailable,
148}
149
150/// The three breaker states. Exposed for tests and observability; the
151/// engine only ever inspects it via [`OriginBreaker::state`].
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum BreakerState {
154    /// Requests flow; consecutive failures counted.
155    Closed,
156    /// Fast-failing all misses until the cooldown elapses.
157    Open,
158    /// Admitting a limited number of trial pulls to probe recovery.
159    HalfOpen,
160}
161
162/// Whether the breaker admits a pull-through attempt right now.
163///
164/// A `Proceed` carries a [`TrialGuard`] that owns any HALF-OPEN trial
165/// slot reserved by [`OriginBreaker::acquire`]. The guard MUST be kept
166/// alive across the (possibly-cancelled) origin fetch and then resolved
167/// with [`TrialGuard::record`]; if it is dropped without `record` — the
168/// case when the surrounding future is cancelled mid-await — its `Drop`
169/// releases the reserved slot so a stuck breaker can never leak its
170/// half-open budget. See [`TrialGuard`].
171#[derive(Debug)]
172#[must_use = "a Proceed admission carries a TrialGuard that must be recorded or dropped"]
173pub enum Admission<'b> {
174    /// Proceed with the (retry-looped) origin fetch. Carries the state
175    /// the breaker was in at admission so the caller knows whether this
176    /// is a normal CLOSED request or a HALF-OPEN trial (only for
177    /// logging — `record` handles the transition either way), plus the
178    /// [`TrialGuard`] that releases the trial slot on cancellation.
179    Proceed(BreakerState, TrialGuard<'b>),
180    /// Short-circuit: the breaker is OPEN (or HALF-OPEN with its trial
181    /// budget exhausted). The caller must fast-fail this miss WITHOUT
182    /// running the retry/backoff loop.
183    ShortCircuit,
184}
185
186/// RAII guard for one admitted pull-through attempt.
187///
188/// Returned inside [`Admission::Proceed`] by [`OriginBreaker::acquire`].
189/// It exists to make the HALF-OPEN trial budget *cancellation-safe*: a
190/// half-open trial reserves a `half_open_in_flight` slot at admission,
191/// and that slot must be returned even if the surrounding async fetch is
192/// cancelled (client disconnect / timeout) before its outcome is known.
193///
194/// Resolution happens exactly one of two ways:
195///
196/// - [`TrialGuard::record`] commits the outcome — driving the
197///   CLOSED→OPEN, HALF-OPEN→CLOSED, and HALF-OPEN→OPEN transitions — and
198///   defuses the guard so `Drop` does nothing.
199/// - The guard is **dropped without `record`** (the future was
200///   cancelled): `Drop` releases the reserved HALF-OPEN slot (a no-op
201///   for a CLOSED admission, which reserves no slot) without recording
202///   any state transition. The breaker neither closes nor re-opens on a
203///   cancelled trial — it simply reclaims the budget so a later trial is
204///   admitted.
205///
206/// The guard borrows the breaker, so it cannot outlive it; the engine
207/// holds each breaker for the whole pull-through call, which strictly
208/// outlives the guard.
209#[derive(Debug)]
210pub struct TrialGuard<'b> {
211    /// The breaker this admission belongs to, against which
212    /// [`Self::record`] commits the outcome. `None` only for a fully
213    /// inert guard (disabled policy): no breaker, no slot, no recording.
214    breaker: Option<&'b OriginBreaker>,
215    /// Whether this admission reserved a HALF-OPEN trial slot that
216    /// `Drop` must reclaim if `record` is never called (cancellation).
217    /// `false` for a CLOSED admission, which reserves no slot — dropping
218    /// it must NOT touch `half_open_in_flight`. Cleared to `false` by
219    /// [`Self::record`] so a recorded trial is not also released on drop.
220    owns_slot: bool,
221}
222
223impl TrialGuard<'_> {
224    /// A fully inert guard: no breaker, no reserved slot. Dropping it is
225    /// a no-op and `record` does nothing. Used for disabled policies,
226    /// where there is neither a slot to release nor a transition to
227    /// record.
228    const fn inert() -> Self {
229        Self {
230            breaker: None,
231            owns_slot: false,
232        }
233    }
234
235    /// Commit the outcome of the admitted attempt and defuse the guard.
236    ///
237    /// Drives the breaker's state transitions (the private
238    /// `OriginBreaker::record_committed`) and clears `owns_slot` so the
239    /// guard's `Drop` does not also release the HALF-OPEN slot — `record`
240    /// already accounts for the slot it resolves. For an inert guard
241    /// (disabled policy) this is a no-op.
242    pub fn record(mut self, outcome: OriginOutcome) {
243        // Defuse first: from here `record_committed` owns the slot
244        // release, so `Drop` (running at end of scope) must not also
245        // reclaim it.
246        self.owns_slot = false;
247        if let Some(breaker) = self.breaker {
248            breaker.record_committed(outcome);
249        }
250    }
251}
252
253impl Drop for TrialGuard<'_> {
254    fn drop(&mut self) {
255        // Reached without a `record` call only on cancellation. Release
256        // the reserved HALF-OPEN slot — but only if this admission owned
257        // one (CLOSED admissions and inert guards own none) — without
258        // recording a transition, so a cancelled trial reclaims its
259        // budget instead of leaking it.
260        if self.owns_slot
261            && let Some(breaker) = self.breaker
262        {
263            breaker.release_cancelled_trial();
264        }
265    }
266}
267
268/// Internal mutable state, guarded by a single mutex. Kept small and
269/// `Copy` so the critical section is a few field writes.
270#[derive(Debug, Clone, Copy)]
271struct State {
272    phase: BreakerState,
273    /// Consecutive `Unavailable` outcomes while CLOSED. Reset to 0 on
274    /// any `Available`.
275    consecutive_failures: u32,
276    /// When the breaker last opened (clock time). Cooldown is measured
277    /// from here. Only meaningful while `phase == Open`.
278    opened_at: Duration,
279    /// Trial pulls admitted while HALF-OPEN and not yet resolved. Caps
280    /// the half-open concurrency at `half_open_max_calls`.
281    half_open_in_flight: u32,
282}
283
284/// Per-origin circuit-breaker. One instance fronts each origin's
285/// pull-through retry loop in [`crate::CacheEngine`].
286///
287/// Cheap to construct and `Send + Sync`; the engine holds one per origin
288/// in a parallel `Vec`. A disabled policy (`!policy.is_active()`) makes
289/// every [`Self::acquire`] return [`Admission::Proceed`] with an inert
290/// guard whose [`TrialGuard::record`] is a no-op, so an opted-out
291/// operator pays nothing but a mutex-free `is_active` check on the hot
292/// path.
293#[derive(Debug)]
294pub struct OriginBreaker {
295    policy: CircuitBreakerPolicy,
296    clock: Arc<dyn Clock>,
297    state: Mutex<State>,
298    metrics: Option<Arc<CacheMetrics>>,
299}
300
301impl OriginBreaker {
302    /// Construct a breaker for one origin under `policy`, using `clock`
303    /// for cooldown timekeeping and bumping transition counters on
304    /// `metrics` when present.
305    #[must_use]
306    pub fn new(
307        policy: CircuitBreakerPolicy,
308        clock: Arc<dyn Clock>,
309        metrics: Option<Arc<CacheMetrics>>,
310    ) -> Self {
311        Self {
312            policy,
313            clock,
314            metrics,
315            state: Mutex::new(State {
316                phase: BreakerState::Closed,
317                consecutive_failures: 0,
318                opened_at: Duration::ZERO,
319                half_open_in_flight: 0,
320            }),
321        }
322    }
323
324    /// Current state, for tests and observability. Note this may run the
325    /// lazy OPEN→HALF-OPEN cooldown check is *not* applied here — it
326    /// reports the raw stored phase. Use [`Self::acquire`] to drive
327    /// transitions.
328    #[must_use]
329    pub fn state(&self) -> BreakerState {
330        self.lock().phase
331    }
332
333    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
334        self.state.lock().unwrap_or_else(PoisonError::into_inner)
335    }
336
337    /// Decide whether to admit a pull-through attempt for this origin
338    /// *now*, applying the lazy OPEN→HALF-OPEN cooldown transition.
339    ///
340    /// Returns [`Admission::ShortCircuit`] when the breaker is OPEN
341    /// (cooldown not yet elapsed) or HALF-OPEN with no remaining trial
342    /// budget — the caller must fast-fail the miss without running the
343    /// retry loop. Otherwise returns [`Admission::Proceed`] carrying a
344    /// [`TrialGuard`]; if that admission was a HALF-OPEN trial, the guard
345    /// owns the reserved trial slot and MUST be resolved — either by
346    /// [`TrialGuard::record`] (which drives the transition) or by being
347    /// dropped on cancellation (which reclaims the slot). Either way the
348    /// slot is never leaked.
349    ///
350    /// A disabled policy always returns `Proceed(Closed, _)` with an
351    /// inert guard.
352    pub fn acquire(&self) -> Admission<'_> {
353        if !self.policy.is_active() {
354            return Admission::Proceed(BreakerState::Closed, TrialGuard::inert());
355        }
356        let now = self.clock.now();
357        let mut st = self.lock();
358        match st.phase {
359            // A CLOSED admission reserves no trial slot, so its guard is
360            // inert: only the recorded outcome matters, and dropping it
361            // on cancellation must not touch `half_open_in_flight`.
362            BreakerState::Closed => Admission::Proceed(BreakerState::Closed, self.closed_guard()),
363            BreakerState::Open => {
364                if self.cooldown_elapsed(&st, now) {
365                    // Transition to HALF-OPEN and admit this caller as
366                    // the first trial, handing it a slot-owning guard.
367                    st.phase = BreakerState::HalfOpen;
368                    st.half_open_in_flight = 1;
369                    Admission::Proceed(BreakerState::HalfOpen, self.trial_guard())
370                } else {
371                    self.short_circuit();
372                    Admission::ShortCircuit
373                }
374            }
375            BreakerState::HalfOpen => {
376                if st.half_open_in_flight < self.policy.half_open_max_calls {
377                    st.half_open_in_flight = st.half_open_in_flight.saturating_add(1);
378                    Admission::Proceed(BreakerState::HalfOpen, self.trial_guard())
379                } else {
380                    // Trial budget already in flight — shed extra load
381                    // until a trial resolves.
382                    self.short_circuit();
383                    Admission::ShortCircuit
384                }
385            }
386        }
387    }
388
389    /// Guard for a CLOSED (active-policy) admission: it carries the
390    /// breaker so `record` drives the CLOSED→OPEN transition, but owns no
391    /// half-open slot, so dropping it on cancellation must NOT touch
392    /// `half_open_in_flight` (a CLOSED request reserved none).
393    const fn closed_guard(&self) -> TrialGuard<'_> {
394        TrialGuard {
395            breaker: Some(self),
396            owns_slot: false,
397        }
398    }
399
400    /// Guard for a HALF-OPEN trial admission: carries the breaker and
401    /// owns the reserved slot, so a cancelled (dropped-without-record)
402    /// trial releases it.
403    const fn trial_guard(&self) -> TrialGuard<'_> {
404        TrialGuard {
405            breaker: Some(self),
406            owns_slot: true,
407        }
408    }
409
410    /// Release a HALF-OPEN trial slot reserved at admission when the
411    /// trial was cancelled before producing an outcome — `record` was
412    /// never called, so [`TrialGuard::drop`] reclaims the budget here.
413    ///
414    /// No state transition: a cancelled probe is inconclusive, so the
415    /// breaker neither closes nor re-opens. It only returns the slot so a
416    /// subsequent trial is admitted. Releasing while not HALF-OPEN (a
417    /// concurrent re-open already cleared `half_open_in_flight` to 0)
418    /// saturates at 0 rather than underflowing.
419    fn release_cancelled_trial(&self) {
420        let mut st = self.lock();
421        st.half_open_in_flight = st.half_open_in_flight.saturating_sub(1);
422    }
423
424    /// Record the committed outcome of a pull-through attempt that was
425    /// admitted by [`Self::acquire`]. Drives the CLOSED→OPEN,
426    /// HALF-OPEN→CLOSED, and HALF-OPEN→OPEN transitions.
427    ///
428    /// Reached only through [`TrialGuard::record`], so a disabled policy
429    /// never gets here (its admission carries an inert guard whose
430    /// `record` short-circuits). The HALF-OPEN arm releases the trial
431    /// slot this outcome resolves; the guard is already defused, so there
432    /// is no double-release.
433    fn record_committed(&self, outcome: OriginOutcome) {
434        let now = self.clock.now();
435        let mut st = self.lock();
436        match st.phase {
437            BreakerState::Closed => match outcome {
438                OriginOutcome::Available => st.consecutive_failures = 0,
439                OriginOutcome::Unavailable => {
440                    st.consecutive_failures = st.consecutive_failures.saturating_add(1);
441                    if st.consecutive_failures >= self.policy.failure_threshold {
442                        self.open(&mut st, now);
443                    }
444                }
445            },
446            BreakerState::HalfOpen => {
447                // Release the trial slot this outcome belongs to.
448                st.half_open_in_flight = st.half_open_in_flight.saturating_sub(1);
449                match outcome {
450                    OriginOutcome::Available => self.close(&mut st),
451                    OriginOutcome::Unavailable => self.open(&mut st, now),
452                }
453            }
454            BreakerState::Open => {
455                // A record landing while OPEN means a request admitted as
456                // a HALF-OPEN trial raced a re-open (another trial failed
457                // first). Fold its signal in without double-counting the
458                // transition: a failure keeps us OPEN and refreshes the
459                // cooldown; a success is ignored (the breaker already
460                // decided this origin is unhealthy this cycle — one
461                // straggler success doesn't override a concurrent
462                // failure's re-open).
463                if outcome == OriginOutcome::Unavailable {
464                    st.opened_at = now;
465                }
466            }
467        }
468    }
469
470    /// True once `cooldown_ms` has elapsed since the breaker opened.
471    /// A zero cooldown means "transition on the very next acquire".
472    fn cooldown_elapsed(&self, st: &State, now: Duration) -> bool {
473        let cooldown = Duration::from_millis(self.policy.cooldown_ms);
474        now.saturating_sub(st.opened_at) >= cooldown
475    }
476
477    /// Transition to OPEN, resetting the cooldown timer and clearing the
478    /// half-open trial budget. Bumps the trip counter.
479    fn open(&self, st: &mut State, now: Duration) {
480        st.phase = BreakerState::Open;
481        st.opened_at = now;
482        st.consecutive_failures = 0;
483        st.half_open_in_flight = 0;
484        if let Some(m) = &self.metrics {
485            m.circuit_breaker_trips.inc();
486        }
487    }
488
489    /// Transition to CLOSED, clearing all counters. Bumps the recovery
490    /// counter.
491    fn close(&self, st: &mut State) {
492        st.phase = BreakerState::Closed;
493        st.consecutive_failures = 0;
494        st.half_open_in_flight = 0;
495        if let Some(m) = &self.metrics {
496            m.circuit_breaker_recoveries.inc();
497        }
498    }
499
500    /// Bump the short-circuit (load-shed) counter.
501    fn short_circuit(&self) {
502        if let Some(m) = &self.metrics {
503            m.circuit_breaker_short_circuits.inc();
504        }
505    }
506}
507
508#[cfg(test)]
509#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
510mod tests;