Skip to main content

micromegas_object_cache/
circuit_breaker.rs

1//! A small, domain-agnostic circuit breaker: closed/open/probing state driven
2//! by consecutive-failure counting and a fixed cooldown. Deliberately free of
3//! any cache-specific naming so it stays reusable (and so `imetric!`'s
4//! literal-name requirement doesn't leak into it) — state transitions are
5//! *returned* to the caller, which owns the metrics and logs.
6//!
7//! See `tasks/1360_cache_client_circuit_breaker_plan.md` ("The breaker")
8//! for the full design rationale.
9
10use std::sync::Mutex;
11use std::time::{Duration, Instant};
12
13/// Tunables for a `CircuitBreaker`.
14#[derive(Debug, Clone)]
15pub struct CircuitBreakerConfig {
16    /// Consecutive unresponsive requests that trip the breaker. `0` disables it.
17    pub failure_threshold: u32,
18    /// How long the circuit stays open before one probe is admitted. Fixed,
19    /// not backed off — see "Why the cooldown is fixed" in the plan. The
20    /// client passes its `stall_timeout`, reusing it rather than adding a
21    /// second knob; an occasional probe overlapping the next admitted request
22    /// is harmless, since there is no doubling for it to corrupt.
23    pub cooldown: Duration,
24}
25
26impl Default for CircuitBreakerConfig {
27    fn default() -> Self {
28        Self {
29            failure_threshold: 5,
30            cooldown: Duration::from_secs(3),
31        }
32    }
33}
34
35/// What a caller may do with the guarded resource right now.
36#[derive(Debug, PartialEq, Eq, Clone, Copy)]
37pub enum Admission {
38    /// Closed: use it normally.
39    Allow,
40    /// Open, cooldown elapsed: this one request probes for recovery.
41    Probe,
42    /// Open: skip it entirely — no connection, no timeout cost.
43    Bypass,
44}
45
46/// A state change worth reporting, returned so the caller emits its own
47/// metrics/logs and the breaker stays domain-agnostic.
48#[derive(Debug, PartialEq, Eq, Clone, Copy)]
49#[must_use]
50pub enum Transition {
51    None,
52    Opened { cooldown: Duration },
53    Closed,
54}
55
56#[derive(Debug)]
57struct State {
58    /// Consecutive unresponsive requests while closed; any response resets it.
59    consecutive: u32,
60    /// `Some(t)` => open: bypass until `t`, then admit one probe.
61    open_until: Option<Instant>,
62}
63
64/// Gates access to a flaky resource: trips after `failure_threshold`
65/// consecutive unresponsive reports, stays open for a fixed `cooldown`, then
66/// admits exactly one probe request to test recovery.
67///
68/// Each clock-dependent method has an `_at(now: Instant)` form (the real
69/// logic) plus a wrapper that passes `Instant::now()`, so the state machine
70/// is unit-testable with a synthetic clock and no sleeps.
71#[derive(Debug)]
72pub struct CircuitBreaker {
73    config: CircuitBreakerConfig,
74    state: Mutex<State>,
75}
76
77impl CircuitBreaker {
78    pub fn new(config: CircuitBreakerConfig) -> Self {
79        Self {
80            config,
81            state: Mutex::new(State {
82                consecutive: 0,
83                open_until: None,
84            }),
85        }
86    }
87
88    pub fn admit(&self) -> Admission {
89        self.admit_at(Instant::now())
90    }
91
92    pub fn admit_at(&self, now: Instant) -> Admission {
93        if self.config.failure_threshold == 0 {
94            return Admission::Allow;
95        }
96        let mut state = self.state.lock().expect("circuit breaker mutex poisoned");
97        match state.open_until {
98            None => Admission::Allow,
99            Some(t) if now < t => Admission::Bypass,
100            Some(_) => {
101                // Re-arm before probing: a failed probe therefore needs no
102                // extra bookkeeping, and a cancelled/dropped probe future
103                // can't leave the circuit permanently stuck open.
104                state.open_until = Some(now + self.config.cooldown);
105                Admission::Probe
106            }
107        }
108    }
109
110    /// Same read of state as `admit`, but never returns `Probe` and never
111    /// mutates `open_until` — for a caller (`prefetch`) that must not be able
112    /// to consume the single per-cooldown probe slot a demand read would
113    /// otherwise receive.
114    pub fn admit_bypass_only(&self) -> Admission {
115        if self.config.failure_threshold == 0 {
116            return Admission::Allow;
117        }
118        let state = self.state.lock().expect("circuit breaker mutex poisoned");
119        match state.open_until {
120            None => Admission::Allow,
121            Some(_) => Admission::Bypass,
122        }
123    }
124
125    /// The resource completed an operation (any HTTP status counts — it's alive).
126    pub fn record_responsive(&self) -> Transition {
127        self.record_responsive_at(Instant::now())
128    }
129
130    pub fn record_responsive_at(&self, _now: Instant) -> Transition {
131        let mut state = self.state.lock().expect("circuit breaker mutex poisoned");
132        let was_open = state.open_until.take().is_some();
133        state.consecutive = 0;
134        if was_open {
135            Transition::Closed
136        } else {
137            Transition::None
138        }
139    }
140
141    /// The resource lost: an abandon-budget expiry, a stall, a connect
142    /// failure, or a transport error (see "Abandon vs. unresponsive").
143    pub fn record_unresponsive(&self) -> Transition {
144        self.record_unresponsive_at(Instant::now())
145    }
146
147    pub fn record_unresponsive_at(&self, now: Instant) -> Transition {
148        let mut state = self.state.lock().expect("circuit breaker mutex poisoned");
149        if state.open_until.is_some() {
150            // Already open: a failed probe needs no action (admit_at re-armed
151            // the window when it handed the probe out), and a stale
152            // pre-trip report is a pure no-op.
153            return Transition::None;
154        }
155        if self.config.failure_threshold == 0 {
156            // Breaker disabled; never accumulate.
157            return Transition::None;
158        }
159        state.consecutive = state.consecutive.saturating_add(1);
160        if state.consecutive >= self.config.failure_threshold {
161            let cooldown = self.config.cooldown;
162            state.open_until = Some(now + cooldown);
163            Transition::Opened { cooldown }
164        } else {
165            Transition::None
166        }
167    }
168}