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}