dwara_core/resilience/health.rs
1//! Passive health checking / outlier detection (DW-012, feature analysis
2//! 4.5).
3//!
4//! Passive health watches REAL traffic outcomes — no synthetic probes are
5//! sent while an endpoint is serving. Each endpoint of an upstream carries
6//! one [`EndpointHealth`] tracker; the proxy's send path classifies every
7//! dispatched request's outcome and reports it to the picked endpoint's
8//! tracker ([`EndpointHealth::report`]). The load balancer consults
9//! [`EndpointHealth::acquire`] on every pick (lock-free: atomics only) and
10//! skips endpoints that are currently ejected.
11//!
12//! # Ejection model
13//!
14//! An endpoint is ejected when EITHER:
15//!
16//! - **consecutive failures**: `consecutive_failures` (default 5) transport
17//! or 5xx outcomes in a row, regardless of volume; or
18//! - **failure ratio with volume**: within the rolling `window_ms`
19//! (default 60 s) observation window, the 5xx/transport share of
20//! observations is >= `failure_ratio` (default 0.5) AND the window saw at
21//! least `failure_min_volume` (default 20) observations. The volume gate
22//! keeps a 2-of-3 blip from ejecting on a trickle of traffic.
23//!
24//! Outcome classification (reported at response-header resolution, the same
25//! point the in-flight guard releases): transport errors (connect timeout,
26//! refused, reset, client framing errors) are failures; HTTP status >= 500
27//! is a failure; 1xx-4xx are successes. 429 and 408 are deliberately
28//! successes in v1: they describe the CALLER or queueing pressure, not the
29//! endpoint's health, and ejecting on them would remove healthy capacity
30//! exactly when backpressure is needed (documented choice).
31//!
32//! Active health probes report through [`EndpointHealth::report_probe`],
33//! which drives the SAME consecutive streak and ejection/recovery
34//! transitions but never inserts an observation into the rolling window:
35//! synthetic probes never mix into real-traffic ratios.
36//!
37//! # Recovery (half-open)
38//!
39//! After `eject_ms` (default 30 s) the tracker moves to half-open on the
40//! next pick: the first `half_open_probes` (default 1) requests are allowed
41//! through (each pick consumes one probe); everything else keeps skipping
42//! the endpoint. A successful probe restores the endpoint to healthy and
43//! clears its failure history (so the old failures cannot immediately
44//! re-eject it); a failed probe re-ejects for another full `eject_ms`. A
45//! success observed on an EJECTED endpoint (only possible via the
46//! all-ejected fail-open path) also restores health — real traffic to it
47//! just worked, which is the strongest possible health signal.
48//!
49//! # Fail-open
50//!
51//! If EVERY endpoint of an upstream is ejected, picks fall back to the full
52//! set rather than blackholing traffic (documented choice; a gateway that
53//! returns 503 for a fully-ejected pool has converted an upstream brownout
54//! into a guaranteed outage). The balancer counts these fallbacks
55//! (`UpstreamLb::fail_open_picks`) so operators can see the degraded
56//! state.
57//!
58//! # Time
59//!
60//! All timing runs on a millisecond clock resolved per call from the owning
61//! balancer (see `UpstreamLb::now_ms`), which defaults to the system clock
62//! and can be swapped for a deterministic clock in tests
63//! (`UpstreamLb::set_health_clock`) — the same injection pattern the rate
64//! limiter (DW-004) uses.
65//!
66//! # Concurrency
67//!
68//! Status transitions are CAS loops on a status atomic; the observation
69//! window is a `Mutex<VecDeque>` mutated only on the report path (never on
70//! picks). Races between concurrent reports and picks resolve
71//! best-effort: a probe slot may occasionally be granted to a request that
72//! observes slightly stale state, which is harmless — the next report
73//! re-synchronizes the tracker. Tracker state (`Arc<EndpointHealth>`) is
74//! keyed by endpoint `address:port` and carried across config rebuilds like
75//! the in-flight counters; config changes to the health parameters apply to
76//! NEW observations (the window and consecutive counter live on).
77//!
78//! # Events (DW-044)
79//!
80//! A tracker constructed with [`EndpointHealth::with_events`] emits
81//! `endpoint_ejected` and `endpoint_recovered` on the two state
82//! transitions (bound to its upstream and `address:port` labels by the
83//! balancer at construction). Both passive reports and ACTIVE probe
84//! reports funnel through the same transition code, so either signal
85//! ejecting or recovering an endpoint emits. Emission is the event
86//! bus's bounded non-blocking hand-off (full queue = drop and count);
87//! `None` (direct/test constructions) is a documented no-op.
88
89use std::collections::VecDeque;
90// DW-025: under the `loom` dev feature the synchronization primitives are
91// swapped for loom's model-checked equivalents so the CAS transition logic
92// can be exhaustively explored; builds are bit-identical otherwise.
93#[cfg(feature = "loom")]
94use loom::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
95#[cfg(not(feature = "loom"))]
96use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
97// Arc stays std even under loom: it is a container, not a synchronization
98// primitive, and keeping it std avoids infecting cross-module signatures
99// (balance.rs Dispatch/HealthDispatch) with loom types.
100#[cfg(feature = "loom")]
101use loom::sync::Mutex;
102#[cfg(feature = "loom")]
103use std::sync::Arc;
104#[cfg(not(feature = "loom"))]
105use std::sync::{Arc, Mutex};
106
107/// Default rolling observation window in milliseconds.
108pub const DEFAULT_WINDOW_MS: u64 = 60_000;
109/// Default consecutive-failure ejection threshold.
110pub const DEFAULT_CONSECUTIVE_FAILURES: u32 = 5;
111/// Default failure ratio (0 < r <= 1).
112pub const DEFAULT_FAILURE_RATIO: f64 = 0.5;
113/// Default minimum observations in the window before the ratio applies.
114pub const DEFAULT_FAILURE_MIN_VOLUME: u32 = 20;
115/// Default ejection duration in milliseconds.
116pub const DEFAULT_EJECT_MS: u64 = 30_000;
117/// Default trial requests allowed through per half-open recovery attempt.
118pub const DEFAULT_HALF_OPEN_PROBES: u32 = 1;
119
120/// Resolved (validated) passive-health parameters, shared per upstream and
121/// snapshotted inside each balancer state generation. A rebuild with new
122/// config swaps the whole `Arc`; in-flight reports pin the generation they
123/// were dispatched with, so parameter changes apply only to new
124/// observations.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct HealthParams {
127 /// Rolling observation window for the failure ratio (milliseconds).
128 pub window_ms: u64,
129 /// Eject after this many consecutive transport/5xx outcomes.
130 pub consecutive_failures: u32,
131 /// Eject when the in-window failure share is >= this ratio AND volume
132 /// is >= `failure_min_volume`.
133 pub failure_ratio: f64,
134 /// Minimum observations in the window before `failure_ratio` applies.
135 pub failure_min_volume: u32,
136 /// How long an ejected endpoint stays out of rotation (milliseconds).
137 pub eject_ms: u64,
138 /// Trial requests allowed through per half-open recovery attempt.
139 pub half_open_probes: u32,
140 /// Recovery ramp window in milliseconds (REL-06, #217). When > 0,
141 /// a recovered endpoint ramps its effective weight from 1 to its
142 /// configured weight over this window. Default 0 = no ramp.
143 pub recovery_ramp_ms: u64,
144}
145
146impl HealthParams {
147 /// Resolve from the config form (serde already applied defaults; this
148 /// is the single place production defaults live for direct
149 /// construction too).
150 pub fn from_config(h: &crate::config::PassiveHealth) -> Self {
151 HealthParams {
152 window_ms: h.window_ms,
153 consecutive_failures: h.consecutive_failures,
154 failure_ratio: h.failure_ratio,
155 failure_min_volume: h.failure_min_volume,
156 eject_ms: h.eject_ms,
157 half_open_probes: h.half_open_probes,
158 recovery_ramp_ms: h.recovery_ramp_ms,
159 }
160 }
161}
162
163// Status atomic discriminants.
164const HEALTHY: u8 = 0;
165const EJECTED: u8 = 1;
166const HALF_OPEN: u8 = 2;
167
168/// Unix-epoch milliseconds (the health clock convention).
169fn system_now_ms() -> u64 {
170 std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .map(|d| d.as_millis() as u64)
173 .unwrap_or(0)
174}
175
176/// Per-endpoint passive health tracker. All pick-path reads are atomic;
177/// only the observation window takes a lock (report path). Share via `Arc`
178/// from the balancer state; carried across rebuilds for unchanged
179/// `address:port`.
180#[derive(Debug, Default)]
181pub struct EndpointHealth {
182 status: AtomicU8,
183 /// Ejection expiry (Unix-epoch ms); meaningful while status == EJECTED.
184 ejected_until_ms: AtomicU64,
185 /// Probe slots left in the current half-open attempt; meaningful while
186 /// status == HALF_OPEN.
187 probes_remaining: AtomicU32,
188 /// Consecutive-failure streak (reported failures only). Atomic;
189 /// only mutated on the report path, under the events lock.
190 consecutive_failures: AtomicU32,
191 /// Rolling (timestamp_ms, is_failure) observations inside the window.
192 events: Mutex<VecDeque<(u64, bool)>>,
193 /// Total ejections observed on this tracker (observability).
194 ejections: AtomicU64,
195 /// Ejection/recovery event sink (DW-044); `None` = no event wiring
196 /// (tests, direct construction). Bound to this tracker's upstream
197 /// and `address:port` labels by the balancer at construction — the
198 /// tracker itself knows its state machine, not its own address.
199 sink: Option<crate::events::EndpointEvents>,
200 /// REL-06 (#217): Unix-epoch ms when the endpoint recovered from
201 /// ejection (a half-open probe succeeded). Used by the load
202 /// balancer to compute the recovery ramp weight. 0 = the endpoint
203 /// was never ejected (or the ramp is disabled).
204 recovered_at_ms: AtomicU64,
205}
206
207impl EndpointHealth {
208 /// Fresh tracker: healthy, empty window.
209 pub fn new() -> Self {
210 Self::default()
211 }
212
213 /// Fresh tracker with ejection/recovery events (DW-044); see the
214 /// module docs.
215 pub fn with_events(sink: crate::events::EndpointEvents) -> Self {
216 EndpointHealth {
217 sink: Some(sink),
218 ..Self::default()
219 }
220 }
221
222 /// Whether a pick may use this endpoint at `now` (Unix-epoch ms);
223 /// consumes one probe slot when the endpoint is half-open. Lock-free.
224 ///
225 /// Side effects: an expired ejection transitions to half-open here
226 /// (the first pick after `eject_ms` re-arms the probe budget) — the
227 /// transition therefore happens lazily on traffic rather than on a
228 /// timer.
229 pub fn acquire(&self, params: &HealthParams, now: u64) -> bool {
230 match self.status.load(Ordering::Acquire) {
231 HEALTHY => true,
232 EJECTED => {
233 if now < self.ejected_until_ms.load(Ordering::Acquire) {
234 return false;
235 }
236 // Ejection expired: one winner arms the probe budget and
237 // flips to half-open; everyone then races for slots.
238 if self
239 .status
240 .compare_exchange(EJECTED, HALF_OPEN, Ordering::AcqRel, Ordering::Acquire)
241 .is_ok()
242 {
243 self.probes_remaining
244 .store(params.half_open_probes, Ordering::Release);
245 }
246 self.try_probe()
247 }
248 _ => self.try_probe(),
249 }
250 }
251
252 /// Whether a pick MAY consider this endpoint at `now` (Unix-epoch ms)
253 /// — non-consuming. The balancer's candidate filter calls this for
254 /// every endpoint; the endpoint actually SELECTED then consumes a
255 /// half-open probe slot via [`EndpointHealth::consume_probe`].
256 /// Consuming only at selection keeps a half-open attempt from
257 /// deadlocking: probe slots are never burned by picks that go
258 /// elsewhere. Side effect: an expired ejection transitions to
259 /// half-open here (the first traffic after `eject_ms` re-arms the
260 /// probe budget) — the transition happens lazily on traffic rather
261 /// than on a timer.
262 pub fn is_candidate(&self, params: &HealthParams, now: u64) -> bool {
263 match self.status.load(Ordering::Acquire) {
264 HEALTHY => true,
265 EJECTED => {
266 if now < self.ejected_until_ms.load(Ordering::Acquire) {
267 return false;
268 }
269 if self
270 .status
271 .compare_exchange(EJECTED, HALF_OPEN, Ordering::AcqRel, Ordering::Acquire)
272 .is_ok()
273 {
274 self.probes_remaining
275 .store(params.half_open_probes, Ordering::Release);
276 }
277 true
278 }
279 _ => self.probes_remaining.load(Ordering::Acquire) > 0,
280 }
281 }
282
283 /// Consume one half-open probe slot for a pick that SELECTED this
284 /// endpoint. Best-effort: if a concurrent pick took the last slot,
285 /// this pick still proceeds (one extra request beyond the probe
286 /// budget may reach a recovering endpoint; the next report
287 /// re-synchronizes the tracker).
288 pub fn consume_probe(&self) {
289 let _ = self
290 .probes_remaining
291 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |left| {
292 if left > 0 {
293 Some(left - 1)
294 } else {
295 None
296 }
297 });
298 }
299
300 /// Race for a half-open probe slot; at least one slot per attempt is
301 /// granted (the pick that wins the EJECTED->HALF_OPEN transition above
302 /// may lose its own slot race to a concurrent pick — acceptable, the
303 /// probe still happens against this endpoint).
304 fn try_probe(&self) -> bool {
305 loop {
306 let left = self.probes_remaining.load(Ordering::Acquire);
307 if left == 0 {
308 return false;
309 }
310 if self
311 .probes_remaining
312 .compare_exchange(left, left - 1, Ordering::AcqRel, Ordering::Acquire)
313 .is_ok()
314 {
315 return true;
316 }
317 }
318 }
319
320 /// Whether the endpoint is currently available WITHOUT consuming a
321 /// probe (observability; not the pick path).
322 pub fn is_available(&self, now: u64) -> bool {
323 match self.status.load(Ordering::Acquire) {
324 HEALTHY => true,
325 EJECTED => now >= self.ejected_until_ms.load(Ordering::Acquire),
326 _ => self.probes_remaining.load(Ordering::Acquire) > 0,
327 }
328 }
329
330 /// Total times this tracker has ejected an endpoint (observability).
331 pub fn ejections(&self) -> u64 {
332 self.ejections.load(Ordering::Relaxed)
333 }
334
335 /// Human state label for observability surfaces (admin `/health`,
336 /// DW-022): `healthy`, `ejected`, or `half_open`. An ejection whose
337 /// window has expired but not yet transitioned reports `half_open`
338 /// (the next pick arms the probe budget), mirroring
339 /// [`Self::is_available`]'s view of expiry.
340 pub fn state_label(&self, now: u64) -> &'static str {
341 match self.status.load(Ordering::Acquire) {
342 HEALTHY => "healthy",
343 EJECTED => {
344 if now >= self.ejected_until_ms.load(Ordering::Acquire) {
345 "half_open"
346 } else {
347 "ejected"
348 }
349 }
350 _ => "half_open",
351 }
352 }
353
354 /// Report one observation for this endpoint at `now` (Unix-epoch ms):
355 /// `is_failure` = transport error or HTTP status >= 500 (see the
356 /// module docs for the 1xx-4xx policy). Applies the ejection rules
357 /// against `params`.
358 pub fn report(&self, params: &HealthParams, now: u64, is_failure: bool) {
359 let mut events = self.events.lock().expect("health tracker poisoned");
360 events.push_back((now, is_failure));
361 let window = params.window_ms;
362 while events
363 .front()
364 .is_some_and(|&(t, _)| now.saturating_sub(t) >= window)
365 {
366 events.pop_front();
367 }
368 self.apply_observation(&mut events, params, now, is_failure);
369 }
370
371 /// Report one ACTIVE (synthetic probe) observation at `now` (Unix-epoch
372 /// ms). Runs the same state machine as [`EndpointHealth::report`] —
373 /// the shared consecutive-failure streak and every ejection/recovery
374 /// transition — but the observation NEVER enters the rolling
375 /// failure-ratio window: that window is real traffic only, so probes
376 /// cannot contaminate the passive ratios (the ratio rule also stays
377 /// disabled by the caller's parameters as a second line of defense).
378 pub fn report_probe(&self, params: &HealthParams, now: u64, is_failure: bool) {
379 let mut events = self.events.lock().expect("health tracker poisoned");
380 self.apply_observation(&mut events, params, now, is_failure);
381 }
382
383 /// Streak update plus status transitions, shared by passive and probe
384 /// reports. Caller holds the events lock (probe reports hold it too,
385 /// so transitions stay consistent with the window they read).
386 fn apply_observation(
387 &self,
388 events: &mut VecDeque<(u64, bool)>,
389 params: &HealthParams,
390 now: u64,
391 is_failure: bool,
392 ) {
393 if is_failure {
394 self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
395 } else {
396 self.consecutive_failures.store(0, Ordering::Relaxed);
397 }
398
399 match self.status.load(Ordering::Acquire) {
400 HALF_OPEN => {
401 if is_failure {
402 self.eject_locked(events, params, now);
403 } else {
404 // Successful probe (or a fail-open success racing the
405 // probe): back to healthy with a clean history.
406 self.recover_locked(events);
407 }
408 }
409 EJECTED => {
410 if !is_failure {
411 // Only reachable via the all-ejected fail-open path:
412 // real traffic to this endpoint just succeeded.
413 self.recover_locked(events);
414 }
415 // Failures while ejected (fail-open traffic) leave the
416 // ejection standing; the expiry/half-open cycle is the
417 // designed recovery path.
418 }
419 _ => {
420 if is_failure && self.should_eject_locked(events, params) {
421 self.eject_locked(events, params, now);
422 }
423 }
424 }
425 }
426
427 /// Ejection rules for a healthy endpoint: the consecutive streak OR the
428 /// in-window ratio with sufficient volume. The events guard is held by
429 /// the caller (so the window snapshot and the decision are consistent).
430 fn should_eject_locked(&self, events: &VecDeque<(u64, bool)>, params: &HealthParams) -> bool {
431 if self.consecutive_failures.load(Ordering::Relaxed) >= params.consecutive_failures {
432 return true;
433 }
434 let volume = events.len();
435 if (volume as u32) < params.failure_min_volume {
436 return false;
437 }
438 let failures = events.iter().filter(|(_, f)| *f).count();
439 (failures as f64) >= params.failure_ratio * volume as f64
440 }
441
442 /// Move to EJECTED until `now + eject_ms`, resetting the streak and
443 /// the window (a clean slate for the half-open attempt). Caller holds
444 /// the events lock.
445 fn eject_locked(&self, events: &mut VecDeque<(u64, bool)>, params: &HealthParams, now: u64) {
446 self.ejected_until_ms
447 .store(now + params.eject_ms, Ordering::Release);
448 self.status.store(EJECTED, Ordering::Release);
449 self.consecutive_failures.store(0, Ordering::Relaxed);
450 events.clear();
451 self.ejections.fetch_add(1, Ordering::Relaxed);
452 // DW-044: emitted under the events lock, immediately after the
453 // transition — a `try_send`, never a block.
454 if let Some(sink) = &self.sink {
455 sink.emit(crate::events::EventKind::EndpointEjected);
456 }
457 }
458
459 /// Move to HEALTHY with a clean history. Caller holds the events lock.
460 fn recover_locked(&self, events: &mut VecDeque<(u64, bool)>) {
461 self.status.store(HEALTHY, Ordering::Release);
462 self.consecutive_failures.store(0, Ordering::Relaxed);
463 events.clear();
464 // REL-06 (#217): record the recovery timestamp so the load
465 // balancer can apply a recovery ramp. The timestamp is the
466 // system clock (the same clock used for ejection expiry).
467 self.recovered_at_ms
468 .store(system_now_ms(), Ordering::Relaxed);
469 // DW-044: see eject_locked. Recovery covers both paths that
470 // restore an endpoint (a successful half-open probe and the
471 // all-ejected fail-open success).
472 if let Some(sink) = &self.sink {
473 sink.emit(crate::events::EventKind::EndpointRecovered);
474 }
475 }
476
477 /// REL-06 (#217): the Unix-epoch ms when the endpoint last recovered
478 /// from ejection, or 0 if it was never ejected (or the ramp is
479 /// disabled). Used by the load balancer to compute the recovery
480 /// ramp weight multiplier.
481 pub fn recovered_at_ms(&self) -> u64 {
482 self.recovered_at_ms.load(Ordering::Relaxed)
483 }
484}
485
486/// The per-dispatch health report handle carried out of a pick: the
487/// endpoint's tracker plus the parameter generation the pick ran against.
488/// The send path reports the outcome exactly once.
489#[derive(Clone)]
490pub struct HealthDispatch {
491 pub(crate) tracker: Arc<EndpointHealth>,
492 pub(crate) params: Arc<HealthParams>,
493}
494
495impl HealthDispatch {
496 /// Report one outcome (transport error or status >= 500 = failure) at
497 /// `now` (Unix-epoch ms). Called when the response headers resolve or
498 /// the send fails at transport level.
499 pub fn report(&self, now: u64, is_failure: bool) {
500 self.tracker.report(&self.params, now, is_failure);
501 }
502
503 /// The tracker behind this dispatch (observability/tests).
504 pub fn tracker(&self) -> &Arc<EndpointHealth> {
505 &self.tracker
506 }
507}