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;