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}