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}