1use std::sync::Arc;
6use std::sync::atomic::Ordering;
7
8use super::instance::UpstreamInstance;
9use super::{UpstreamGroup, now_millis};
10
11const OUTLIER_BACKOFF_SHIFT_CAP: u32 = 6;
15
16impl UpstreamGroup {
17 pub(super) fn outlier_enabled(&self) -> bool {
19 self.outlier_consecutive > 0
20 }
21
22 pub fn record_outcome(&self, instance: &Arc<UpstreamInstance>, gateway_failure: bool) -> bool {
30 if !self.outlier_enabled() {
31 return false;
32 }
33 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 let Ok(_decision) = self.outlier_decision.lock() else {
56 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 c.consecutive_gw_fail = 0;
73 return false;
74 }
75
76 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 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(); }
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 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 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 use std::sync::Barrier;
212 use std::thread;
213
214 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); 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}