Skip to main content

plecto_control/upstream/
outlier.rs

1//! Outlier detection (ADR 000032): eject an instance from rotation when it MISBEHAVES on live
2//! traffic (consecutive gateway-class 5xx), a third resilience axis distinct from active health
3//! ("is it reachable?") and the circuit breaker ("is it saturated?").
4
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7
8use super::instance::UpstreamInstance;
9use super::{UpstreamGroup, now_millis};
10
11/// Upper bound on the outlier ejection-time exponential backoff (ADR 000032): the window is
12/// `base · 2^min(eject_count, cap)`, so the cap bounds it at `base · 2^6` (64×) however many times an
13/// instance flaps.
14const OUTLIER_BACKOFF_SHIFT_CAP: u32 = 6;
15
16impl UpstreamGroup {
17    /// Whether outlier detection is enabled for this upstream (ADR 000032).
18    pub(super) fn outlier_enabled(&self) -> bool {
19        self.outlier_consecutive > 0
20    }
21
22    /// Record one forward's outcome against `instance` for outlier detection (ADR 000032).
23    /// `gateway_failure` is `true` only for a gateway-class 5xx (502/503/504) the upstream actually
24    /// RETURNED — never a circuit-breaker shed or a per-try timeout (those are other axes). Returns
25    /// `true` iff this call ejected the instance (the caller bumps the ejection metric). A success
26    /// resets the failure streak and ejection backoff; consecutive failures past the threshold eject
27    /// the instance for a backing-off window — unless that would push the pool past
28    /// `max_ejection_percent`, in which case it stays in rotation (fail-closed must not self-DoS).
29    pub fn record_outcome(&self, instance: &Arc<UpstreamInstance>, gateway_failure: bool) -> bool {
30        if !self.outlier_enabled() {
31            return false;
32        }
33        // a poisoned lock means a thread panicked mid-transition; fail safe (no ejection).
34        let Ok(mut c) = instance.counters.lock() else {
35            return false;
36        };
37        if !gateway_failure {
38            c.consecutive_gw_fail = 0;
39            c.outlier_eject_count = 0;
40            return false;
41        }
42        c.consecutive_gw_fail = c.consecutive_gw_fail.saturating_add(1);
43        if c.consecutive_gw_fail < self.outlier_consecutive {
44            return false;
45        }
46
47        // Threshold reached. Honour the ejection cap: never eject so many that the pool drops below
48        // its working minimum (`100 - max_ejection_percent`). Integer math, no float.
49        //
50        // The count-check-eject sequence below reads GROUP-WIDE state (how many peer instances are
51        // currently ejected) — that must be serialized across every instance in the group, not just
52        // guarded by this instance's own `counters` lock, or two instances crossing their threshold
53        // in the same instant (a correlated backend blip) could each observe "cap not yet reached"
54        // and both eject, silently exceeding `max_ejection_percent`.
55        let Ok(_decision) = self.outlier_decision.lock() else {
56            // a poisoned decision lock means a thread panicked mid-transition; fail safe (no eject).
57            c.consecutive_gw_fail = 0;
58            return false;
59        };
60        let now_ms = now_millis();
61        let endpoints = self.endpoints();
62        let already_ejected = endpoints
63            .instances
64            .iter()
65            .filter(|i| i.is_outlier_ejected(now_ms))
66            .count();
67        if (already_ejected + 1) * 100
68            > self.outlier_max_ejection_percent as usize * endpoints.instances.len()
69        {
70            // Cap reached — keep this instance in rotation, but reset its streak so it gets a fresh
71            // threshold's worth of chances rather than re-tripping on the very next failure.
72            c.consecutive_gw_fail = 0;
73            return false;
74        }
75
76        // Eject for `base · 2^min(eject_count, cap)` — exponential backoff, bounded. Still under
77        // `outlier_decision`: the store below is what `already_ejected` observes on the next racing
78        // instance's count, so it must land before the lock releases.
79        let shift = c.outlier_eject_count.min(OUTLIER_BACKOFF_SHIFT_CAP);
80        let window = self.outlier_base_ejection.saturating_mul(1u32 << shift);
81        instance.outlier_ejected_until_ms.store(
82            now_ms.saturating_add(window.as_millis() as u64),
83            Ordering::Release,
84        );
85        c.outlier_eject_count = c.outlier_eject_count.saturating_add(1);
86        c.consecutive_gw_fail = 0;
87        true
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::manifest::{
95        AddressSpec, CircuitBreaker, HealthConfig, LbAlgorithm, OutlierDetection, Upstream,
96    };
97    use crate::upstream::UpstreamRegistry;
98
99    fn health(healthy_threshold: u32, unhealthy_threshold: u32) -> HealthConfig {
100        HealthConfig {
101            path: "/healthz".to_string(),
102            interval_ms: 100,
103            timeout_ms: 50,
104            healthy_threshold,
105            unhealthy_threshold,
106            port: None,
107        }
108    }
109
110    /// Build a healthy group with outlier detection configured (ADR 000032).
111    fn outlier_group(
112        addrs: &[&str],
113        consecutive: u32,
114        base_ms: u64,
115        max_pct: u32,
116    ) -> Arc<UpstreamGroup> {
117        let reg = UpstreamRegistry::new();
118        reg.reconcile(
119            &[Upstream {
120                name: "u".to_string(),
121                addresses: addrs
122                    .iter()
123                    .map(|s| AddressSpec::Bare(s.to_string()))
124                    .collect(),
125                lb_algorithm: LbAlgorithm::RoundRobin,
126                hash: None,
127                tls: None,
128                resolve_interval_ms: 0,
129                health: health(1, 1),
130                request_timeout_ms: 30_000,
131                max_retries: 0,
132                overall_timeout_ms: 0,
133                circuit_breaker: CircuitBreaker::default(),
134                outlier_detection: OutlierDetection {
135                    consecutive_gateway_failures: consecutive,
136                    base_ejection_time_ms: base_ms,
137                    max_ejection_percent: max_pct,
138                },
139            }],
140            std::path::Path::new("."),
141        )
142        .unwrap();
143        let g = reg.group("u").unwrap();
144        for inst in &g.endpoints().instances {
145            inst.record_probe_success(); // cold-start: all healthy
146        }
147        g
148    }
149
150    #[test]
151    fn outlier_ejects_after_consecutive_gateway_failures_and_success_resets() {
152        let g = outlier_group(&["a:1", "b:2"], 2, 60_000, 100);
153        let a = g.endpoints().instances[0].clone();
154        assert!(
155            !g.record_outcome(&a, true),
156            "1st failure is below the threshold"
157        );
158        // a success between failures resets the streak.
159        assert!(!g.record_outcome(&a, false));
160        assert!(
161            !g.record_outcome(&a, true),
162            "streak reset → this is the 1st again"
163        );
164        assert!(
165            g.record_outcome(&a, true),
166            "2nd consecutive failure ejects (returns true)"
167        );
168        assert!(a.is_outlier_ejected(now_millis()), "ejected from rotation");
169        assert!(
170            a.is_healthy(),
171            "but the health bit is untouched — outlier detection is a separate axis (ADR 000032)"
172        );
173    }
174
175    #[test]
176    fn outlier_ejection_window_expires() {
177        let g = outlier_group(&["a:1", "b:2"], 1, 1000, 100);
178        let a = g.endpoints().instances[0].clone();
179        assert!(
180            g.record_outcome(&a, true),
181            "threshold 1 → one failure ejects"
182        );
183        let now = now_millis();
184        assert!(a.is_outlier_ejected(now), "ejected at `now`");
185        assert!(
186            !a.is_outlier_ejected(now + 2000),
187            "the 1s window has expired 2s later — auto-return, no probe needed"
188        );
189    }
190
191    #[test]
192    fn outlier_max_ejection_percent_keeps_some_in_rotation() {
193        // 3 instances, 50% cap → at most 1 may be ejected; the rest stay in rotation even while
194        // failing, so fail-closed never becomes a self-inflicted total outage.
195        let g = outlier_group(&["a:1", "b:2", "c:3"], 1, 60_000, 50);
196        let a = g.endpoints().instances[0].clone();
197        let b = g.endpoints().instances[1].clone();
198        assert!(g.record_outcome(&a, true), "a ejects (1/3 within 50%)");
199        assert!(
200            !g.record_outcome(&b, true),
201            "b is NOT ejected — a 2nd ejection (2/3) would exceed the 50% cap"
202        );
203        assert!(!b.is_outlier_ejected(now_millis()), "b stays in rotation");
204    }
205
206    #[test]
207    fn concurrent_threshold_crossings_never_exceed_max_ejection_percent() {
208        // Regression test: two instances crossing their failure threshold in the same instant
209        // (a correlated backend blip) must not both read "cap not yet reached" and both eject —
210        // `outlier_decision` serializes the count-check-eject sequence across the whole group.
211        use std::sync::Barrier;
212        use std::thread;
213
214        // 4 instances, 50% cap → at most 2 may ever be ejected at once, no matter how many
215        // threads cross the threshold at the same instant.
216        let g = outlier_group(&["a:1", "b:2", "c:3", "d:4"], 1, 60_000, 50);
217        let instances = g.endpoints().instances.clone();
218        let barrier = Arc::new(Barrier::new(instances.len()));
219
220        let handles: Vec<_> = instances
221            .iter()
222            .cloned()
223            .map(|inst| {
224                let g = g.clone();
225                let b = barrier.clone();
226                thread::spawn(move || {
227                    b.wait();
228                    g.record_outcome(&inst, true)
229                })
230            })
231            .collect();
232        for h in handles {
233            h.join().unwrap();
234        }
235
236        let now = now_millis();
237        let actually_ejected = instances
238            .iter()
239            .filter(|i| i.is_outlier_ejected(now))
240            .count();
241        assert!(
242            actually_ejected * 100 <= 50 * instances.len(),
243            "at most 50% of the pool may be ejected even under a 4-way simultaneous threshold \
244             crossing, got {actually_ejected}/{}",
245            instances.len()
246        );
247    }
248
249    #[test]
250    fn outlier_disabled_never_ejects() {
251        let g = outlier_group(&["a:1"], 0, 60_000, 100); // consecutive 0 = disabled
252        let a = g.endpoints().instances[0].clone();
253        for _ in 0..100 {
254            assert!(
255                !g.record_outcome(&a, true),
256                "a disabled policy never ejects"
257            );
258        }
259        assert!(!a.is_outlier_ejected(now_millis()));
260    }
261
262    #[test]
263    fn outlier_ejected_instance_is_skipped_by_pick() {
264        let g = outlier_group(&["a:1", "b:2"], 1, 60_000, 100);
265        let a = g.endpoints().instances[0].clone();
266        assert!(g.record_outcome(&a, true), "eject a");
267        for _ in 0..6 {
268            assert_eq!(
269                g.pick(None).unwrap().address(),
270                "b:2",
271                "round-robin skips the outlier-ejected (but still healthy) instance"
272            );
273        }
274    }
275}