Skip to main content

plecto_control/upstream/
instance.rs

1//! Per-instance active-health-check state machine (ADR 000017).
2
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5
6use crate::manifest::HealthConfig;
7
8/// One backend instance (`host:port`) of an upstream, with its health state (ADR 000017).
9///
10/// The hot path (`pick` → [`UpstreamInstance::is_healthy`]) reads a lock-free `AtomicBool`. State
11/// transitions take a small per-instance `Mutex` so "increment a counter, compare the threshold,
12/// flip the bit, reset" is race-free — but the mutex is touched only on a probe (cold, every
13/// interval) or a passive connect failure (rare), never on the success hot path.
14#[derive(Debug)]
15pub struct UpstreamInstance {
16    address: String,
17    /// Load-balancing weight (ADR 000035): biases the least-request comparison and the Maglev table
18    /// share toward higher-capacity instances. `1` for a bare address. Immutable for the instance's
19    /// life; a weight change builds a fresh instance on reconcile (like a health-policy change).
20    weight: u32,
21    /// The lock-free read surface for `pick`. Written only while holding `counters`.
22    healthy: AtomicBool,
23    /// Active forwarded-request count to THIS instance (ADR 000035), the least-request load signal.
24    /// Incremented when an attempt selects this instance and decremented when the attempt ends (RAII,
25    /// across retries). Distinct from the per-group circuit-breaker in-flight (ADR 000028): that caps
26    /// the upstream's saturation, this drives per-instance selection. Only touched under
27    /// `least_request`; round-robin / maglev leave it at 0.
28    pub(super) in_flight: AtomicUsize,
29    pub(super) counters: Mutex<HealthCounters>,
30    healthy_threshold: u32,
31    unhealthy_threshold: u32,
32    /// Outlier ejection deadline (ms since epoch, `0` = not ejected); the lock-free read surface for
33    /// `pick` (ADR 000032). Written by `record_outcome` while holding `counters`. Time-based, so it
34    /// auto-expires when the window passes — independent of the `healthy` bit (a separate axis).
35    pub(super) outlier_ejected_until_ms: AtomicU64,
36}
37
38#[derive(Debug)]
39pub(super) struct HealthCounters {
40    pub(super) consecutive_ok: u32,
41    pub(super) consecutive_fail: u32,
42    /// Whether this instance has EVER been healthy. While `false`, a single successful probe
43    /// promotes it (cold-start fast path, ADR 000017); afterwards the full `healthy_threshold`
44    /// applies for re-entry after an eject.
45    pub(super) ever_healthy: bool,
46    /// Consecutive gateway-class 5xx on live traffic, for outlier detection (ADR 000032). Reset by a
47    /// non-failure outcome; reaching the policy threshold ejects the instance.
48    pub(super) consecutive_gw_fail: u32,
49    /// How many times this instance has been outlier-ejected, for the exponential ejection-time
50    /// backoff (ADR 000032). Reset on a successful outcome.
51    pub(super) outlier_eject_count: u32,
52}
53
54impl UpstreamInstance {
55    pub(super) fn new(address: String, weight: u32, health: &HealthConfig) -> Self {
56        Self {
57            address,
58            // a 0 weight would divide by zero in the least-request ratio; validation rejects it, but
59            // clamp to >= 1 as defence in depth (data-plane no-panic).
60            weight: weight.max(1),
61            // pessimistic: a fresh instance is out of rotation until a probe passes (ADR 000017).
62            healthy: AtomicBool::new(false),
63            in_flight: AtomicUsize::new(0),
64            counters: Mutex::new(HealthCounters {
65                consecutive_ok: 0,
66                consecutive_fail: 0,
67                ever_healthy: false,
68                consecutive_gw_fail: 0,
69                outlier_eject_count: 0,
70            }),
71            // a 0 threshold would be a footgun (never promote / instant eject); clamp to >= 1.
72            healthy_threshold: health.healthy_threshold.max(1),
73            unhealthy_threshold: health.unhealthy_threshold.max(1),
74            outlier_ejected_until_ms: AtomicU64::new(0),
75        }
76    }
77
78    /// This instance's `host:port`.
79    pub fn address(&self) -> &str {
80        &self.address
81    }
82
83    /// This instance's load-balancing weight (ADR 000035), `>= 1`.
84    pub fn weight(&self) -> u32 {
85        self.weight
86    }
87
88    /// Current active forwarded-request count to this instance (ADR 000035) — the least-request load
89    /// signal. Lock-free read.
90    pub fn in_flight(&self) -> usize {
91        self.in_flight.load(Ordering::Relaxed)
92    }
93
94    /// Whether this instance is currently in rotation. Lock-free — the round-robin hot path.
95    pub fn is_healthy(&self) -> bool {
96        self.healthy.load(Ordering::Acquire)
97    }
98
99    /// Whether this instance is currently outlier-ejected at `now_ms` (ADR 000032). Lock-free — the
100    /// `pick` hot path. The ejection auto-expires when its window passes (no probe needed); this is a
101    /// distinct axis from `is_healthy` (an instance can be probe-healthy yet outlier-ejected).
102    pub fn is_outlier_ejected(&self, now_ms: u64) -> bool {
103        self.outlier_ejected_until_ms.load(Ordering::Acquire) > now_ms
104    }
105
106    /// Record a successful active probe (a 2xx within the timeout). Promotes a pessimistic / ejected
107    /// instance once it reaches its threshold — one success the first time ever, `healthy_threshold`
108    /// after a later eject — and resets the consecutive-failure streak.
109    pub fn record_probe_success(&self) {
110        // a poisoned lock means a thread panicked mid-transition; fail safe (leave state as-is).
111        let Ok(mut c) = self.counters.lock() else {
112            return;
113        };
114        c.consecutive_fail = 0;
115        if self.healthy.load(Ordering::Acquire) {
116            return; // already in rotation; nothing to promote
117        }
118        c.consecutive_ok = c.consecutive_ok.saturating_add(1);
119        let need = if c.ever_healthy {
120            self.healthy_threshold
121        } else {
122            1 // cold-start fast path: first ever promotion needs a single success
123        };
124        if c.consecutive_ok >= need {
125            c.ever_healthy = true;
126            c.consecutive_ok = 0;
127            self.healthy.store(true, Ordering::Release);
128            tracing::info!(address = %self.address, "upstream instance became healthy");
129        }
130    }
131
132    /// Record a failed active probe (non-2xx, timeout, or connect error).
133    pub fn record_probe_failure(&self) {
134        self.record_failure("active probe");
135    }
136
137    /// Record a *passive* failure — a real forwarded request that could not even connect to this
138    /// instance (ADR 000017). It demotes exactly like a probe failure, but can only ever demote: an
139    /// ejected instance receives no traffic, so only the active prober restores it.
140    pub fn record_passive_failure(&self) {
141        self.record_failure("passive request");
142    }
143
144    fn record_failure(&self, source: &'static str) {
145        let Ok(mut c) = self.counters.lock() else {
146            return;
147        };
148        c.consecutive_ok = 0;
149        c.consecutive_fail = c.consecutive_fail.saturating_add(1);
150        if self.healthy.load(Ordering::Acquire) && c.consecutive_fail >= self.unhealthy_threshold {
151            c.consecutive_fail = 0;
152            self.healthy.store(false, Ordering::Release);
153            tracing::warn!(
154                address = %self.address,
155                source,
156                "upstream instance became unhealthy"
157            );
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn health(healthy_threshold: u32, unhealthy_threshold: u32) -> HealthConfig {
167        HealthConfig {
168            path: "/healthz".to_string(),
169            interval_ms: 100,
170            timeout_ms: 50,
171            healthy_threshold,
172            unhealthy_threshold,
173            port: None,
174        }
175    }
176
177    fn instance(h: &HealthConfig) -> UpstreamInstance {
178        UpstreamInstance::new("127.0.0.1:9000".to_string(), 1, h)
179    }
180
181    #[test]
182    fn starts_pessimistic_and_first_probe_promotes() {
183        // ADR 000017: a fresh instance is unhealthy; the FIRST successful probe alone promotes it,
184        // even when healthy_threshold > 1 (cold-start fast path).
185        let h = health(3, 3);
186        let inst = instance(&h);
187        assert!(!inst.is_healthy(), "fresh instance starts pessimistic");
188        inst.record_probe_success();
189        assert!(
190            inst.is_healthy(),
191            "one success promotes a never-yet-healthy instance"
192        );
193    }
194
195    #[test]
196    fn ejects_after_unhealthy_threshold_then_needs_full_healthy_threshold() {
197        // healthy_threshold=2, unhealthy_threshold=2.
198        let h = health(2, 2);
199        let inst = instance(&h);
200        inst.record_probe_success(); // cold-start: healthy after 1
201        assert!(inst.is_healthy());
202
203        inst.record_probe_failure();
204        assert!(
205            inst.is_healthy(),
206            "one failure is below the eject threshold"
207        );
208        inst.record_probe_failure();
209        assert!(!inst.is_healthy(), "two consecutive failures eject");
210
211        // re-entry now needs the FULL healthy_threshold (it has been healthy before)
212        inst.record_probe_success();
213        assert!(
214            !inst.is_healthy(),
215            "one success is not enough to re-enter after an eject"
216        );
217        inst.record_probe_success();
218        assert!(inst.is_healthy(), "healthy_threshold successes restore it");
219    }
220
221    #[test]
222    fn a_success_resets_the_failure_streak() {
223        let h = health(1, 3);
224        let inst = instance(&h);
225        inst.record_probe_success();
226        inst.record_probe_failure();
227        inst.record_probe_failure();
228        inst.record_probe_success(); // resets the streak
229        inst.record_probe_failure();
230        inst.record_probe_failure();
231        assert!(inst.is_healthy(), "non-consecutive failures must not eject");
232    }
233
234    #[test]
235    fn passive_failure_demotes_a_healthy_instance() {
236        // ADR 000017: a real request's connect failure feeds the SAME state machine and demotes.
237        let h = health(1, 2);
238        let inst = instance(&h);
239        inst.record_probe_success();
240        assert!(inst.is_healthy());
241        inst.record_passive_failure();
242        inst.record_passive_failure();
243        assert!(
244            !inst.is_healthy(),
245            "passive failures eject like probe failures"
246        );
247    }
248
249    #[test]
250    fn zero_thresholds_are_clamped_to_one() {
251        // A manifest typo (`healthy_threshold = 0` / `unhealthy_threshold = 0`) must not become a
252        // config-induced DoS. Without the `.max(1)` clamp a 0 healthy_threshold would make a
253        // never-yet-healthy instance promote on the cold-start path anyway, but a 0
254        // unhealthy_threshold would eject a healthy instance the instant it served — and re-entry
255        // could be impossible. Clamping both to >=1 makes "one success promotes, one failure
256        // ejects, one success restores" hold, never "never promote" or "instant eject".
257        let inst = instance(&health(0, 0));
258        assert!(!inst.is_healthy(), "still starts pessimistic");
259        inst.record_probe_success();
260        assert!(
261            inst.is_healthy(),
262            "one success promotes (healthy_threshold clamped to >=1)"
263        );
264        inst.record_probe_failure();
265        assert!(
266            !inst.is_healthy(),
267            "one real failure ejects — not instant-eject before any failure"
268        );
269        inst.record_probe_success();
270        assert!(
271            inst.is_healthy(),
272            "one success restores after an eject (re-entry is possible)"
273        );
274    }
275}