use std::sync::Arc;
use std::sync::atomic::Ordering;
use super::instance::UpstreamInstance;
use super::{UpstreamGroup, now_millis};
const OUTLIER_BACKOFF_SHIFT_CAP: u32 = 6;
impl UpstreamGroup {
pub(super) fn outlier_enabled(&self) -> bool {
self.outlier_consecutive > 0
}
pub fn record_outcome(&self, instance: &Arc<UpstreamInstance>, gateway_failure: bool) -> bool {
if !self.outlier_enabled() {
return false;
}
let Ok(mut c) = instance.counters.lock() else {
return false;
};
if !gateway_failure {
c.consecutive_gw_fail = 0;
c.outlier_eject_count = 0;
return false;
}
c.consecutive_gw_fail = c.consecutive_gw_fail.saturating_add(1);
if c.consecutive_gw_fail < self.outlier_consecutive {
return false;
}
let Ok(_decision) = self.outlier_decision.lock() else {
c.consecutive_gw_fail = 0;
return false;
};
let now_ms = now_millis();
let endpoints = self.endpoints();
let already_ejected = endpoints
.instances
.iter()
.filter(|i| i.is_outlier_ejected(now_ms))
.count();
if (already_ejected + 1) * 100
> self.outlier_max_ejection_percent as usize * endpoints.instances.len()
{
c.consecutive_gw_fail = 0;
return false;
}
let shift = c.outlier_eject_count.min(OUTLIER_BACKOFF_SHIFT_CAP);
let window = self.outlier_base_ejection.saturating_mul(1u32 << shift);
instance.outlier_ejected_until_ms.store(
now_ms.saturating_add(window.as_millis() as u64),
Ordering::Release,
);
c.outlier_eject_count = c.outlier_eject_count.saturating_add(1);
c.consecutive_gw_fail = 0;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{
AddressSpec, CircuitBreaker, HealthConfig, LbAlgorithm, OutlierDetection, Upstream,
};
use crate::upstream::UpstreamRegistry;
fn health(healthy_threshold: u32, unhealthy_threshold: u32) -> HealthConfig {
HealthConfig {
path: "/healthz".to_string(),
interval_ms: 100,
timeout_ms: 50,
healthy_threshold,
unhealthy_threshold,
port: None,
}
}
fn outlier_group(
addrs: &[&str],
consecutive: u32,
base_ms: u64,
max_pct: u32,
) -> Arc<UpstreamGroup> {
let reg = UpstreamRegistry::new();
reg.reconcile(
&[Upstream {
name: "u".to_string(),
addresses: addrs
.iter()
.map(|s| AddressSpec::Bare(s.to_string()))
.collect(),
lb_algorithm: LbAlgorithm::RoundRobin,
hash: None,
tls: None,
resolve_interval_ms: 0,
health: health(1, 1),
request_timeout_ms: 30_000,
max_retries: 0,
overall_timeout_ms: 0,
circuit_breaker: CircuitBreaker::default(),
outlier_detection: OutlierDetection {
consecutive_gateway_failures: consecutive,
base_ejection_time_ms: base_ms,
max_ejection_percent: max_pct,
},
}],
std::path::Path::new("."),
)
.unwrap();
let g = reg.group("u").unwrap();
for inst in &g.endpoints().instances {
inst.record_probe_success(); }
g
}
#[test]
fn outlier_ejects_after_consecutive_gateway_failures_and_success_resets() {
let g = outlier_group(&["a:1", "b:2"], 2, 60_000, 100);
let a = g.endpoints().instances[0].clone();
assert!(
!g.record_outcome(&a, true),
"1st failure is below the threshold"
);
assert!(!g.record_outcome(&a, false));
assert!(
!g.record_outcome(&a, true),
"streak reset → this is the 1st again"
);
assert!(
g.record_outcome(&a, true),
"2nd consecutive failure ejects (returns true)"
);
assert!(a.is_outlier_ejected(now_millis()), "ejected from rotation");
assert!(
a.is_healthy(),
"but the health bit is untouched — outlier detection is a separate axis (ADR 000032)"
);
}
#[test]
fn outlier_ejection_window_expires() {
let g = outlier_group(&["a:1", "b:2"], 1, 1000, 100);
let a = g.endpoints().instances[0].clone();
assert!(
g.record_outcome(&a, true),
"threshold 1 → one failure ejects"
);
let now = now_millis();
assert!(a.is_outlier_ejected(now), "ejected at `now`");
assert!(
!a.is_outlier_ejected(now + 2000),
"the 1s window has expired 2s later — auto-return, no probe needed"
);
}
#[test]
fn outlier_max_ejection_percent_keeps_some_in_rotation() {
let g = outlier_group(&["a:1", "b:2", "c:3"], 1, 60_000, 50);
let a = g.endpoints().instances[0].clone();
let b = g.endpoints().instances[1].clone();
assert!(g.record_outcome(&a, true), "a ejects (1/3 within 50%)");
assert!(
!g.record_outcome(&b, true),
"b is NOT ejected — a 2nd ejection (2/3) would exceed the 50% cap"
);
assert!(!b.is_outlier_ejected(now_millis()), "b stays in rotation");
}
#[test]
fn concurrent_threshold_crossings_never_exceed_max_ejection_percent() {
use std::sync::Barrier;
use std::thread;
let g = outlier_group(&["a:1", "b:2", "c:3", "d:4"], 1, 60_000, 50);
let instances = g.endpoints().instances.clone();
let barrier = Arc::new(Barrier::new(instances.len()));
let handles: Vec<_> = instances
.iter()
.cloned()
.map(|inst| {
let g = g.clone();
let b = barrier.clone();
thread::spawn(move || {
b.wait();
g.record_outcome(&inst, true)
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let now = now_millis();
let actually_ejected = instances
.iter()
.filter(|i| i.is_outlier_ejected(now))
.count();
assert!(
actually_ejected * 100 <= 50 * instances.len(),
"at most 50% of the pool may be ejected even under a 4-way simultaneous threshold \
crossing, got {actually_ejected}/{}",
instances.len()
);
}
#[test]
fn outlier_disabled_never_ejects() {
let g = outlier_group(&["a:1"], 0, 60_000, 100); let a = g.endpoints().instances[0].clone();
for _ in 0..100 {
assert!(
!g.record_outcome(&a, true),
"a disabled policy never ejects"
);
}
assert!(!a.is_outlier_ejected(now_millis()));
}
#[test]
fn outlier_ejected_instance_is_skipped_by_pick() {
let g = outlier_group(&["a:1", "b:2"], 1, 60_000, 100);
let a = g.endpoints().instances[0].clone();
assert!(g.record_outcome(&a, true), "eject a");
for _ in 0..6 {
assert_eq!(
g.pick(None).unwrap().address(),
"b:2",
"round-robin skips the outlier-ejected (but still healthy) instance"
);
}
}
}