use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::manifest::MAX_BACKEND_WEIGHT;
use crate::upstream::UpstreamGroup;
pub(crate) const MAX_TABLE_LEN: usize = 65_536;
#[derive(Debug)]
pub(crate) struct WeightedBackends {
groups: Vec<Arc<UpstreamGroup>>,
table: Box<[u16]>,
cursor: AtomicUsize,
}
impl WeightedBackends {
pub(crate) fn new(targets: Vec<(Arc<UpstreamGroup>, u32)>) -> Result<Self, String> {
let weights: Vec<u32> = targets.iter().map(|(_, w)| *w).collect();
validate_split(&weights)?;
let groups: Vec<Arc<UpstreamGroup>> = targets
.iter()
.filter(|(_, w)| *w > 0)
.map(|(g, _)| g.clone())
.collect();
let nonzero: Vec<u32> = weights.into_iter().filter(|&w| w > 0).collect();
let divisor = nonzero.iter().copied().reduce(gcd).unwrap_or(1).max(1);
let reduced: Vec<u32> = nonzero.iter().map(|&w| w / divisor).collect();
Ok(Self {
groups,
table: weighted_schedule(&reduced),
cursor: AtomicUsize::new(0),
})
}
#[allow(clippy::indexing_slicing)]
pub(crate) fn pick(&self) -> Option<Arc<UpstreamGroup>> {
if !self.groups.iter().any(|g| g.has_eligible()) {
return None;
}
let len = self.table.len();
if len == 0 {
return None;
}
let start = self.cursor.fetch_add(1, Ordering::Relaxed) % len;
for off in 0..len {
let backend = self.table[(start + off) % len] as usize;
debug_assert!(backend < self.groups.len());
if self.groups[backend].has_eligible() {
return Some(self.groups[backend].clone());
}
}
None
}
}
pub(crate) fn validate_split(weights: &[u32]) -> Result<usize, String> {
if weights.is_empty() {
return Err("a route has no backends".to_string());
}
if let Some(&w) = weights.iter().find(|&&w| w > MAX_BACKEND_WEIGHT) {
return Err(format!(
"backend weight {w} exceeds the maximum {MAX_BACKEND_WEIGHT}"
));
}
let divisor = weights.iter().copied().filter(|&w| w > 0).reduce(gcd);
let Some(divisor) = divisor else {
return Err("every backend weight is zero (the whole route is drained)".to_string());
};
let total: usize = weights.iter().map(|&w| (w / divisor) as usize).sum();
if total > MAX_TABLE_LEN {
return Err(format!(
"reduced traffic-split table ({total}) exceeds {MAX_TABLE_LEN}; use smaller proportional weights"
));
}
Ok(total)
}
fn gcd(a: u32, b: u32) -> u32 {
let (mut a, mut b) = (a, b);
while b != 0 {
(a, b) = (b, a % b);
}
a
}
#[allow(clippy::indexing_slicing)]
fn weighted_schedule(weights: &[u32]) -> Box<[u16]> {
let total: i64 = weights.iter().map(|&w| w as i64).sum();
let mut deficit = vec![0i64; weights.len()];
debug_assert_eq!(deficit.len(), weights.len());
let mut schedule: Vec<u16> = Vec::with_capacity(total as usize);
for _ in 0..total {
let mut best = 0usize;
for i in 0..weights.len() {
deficit[i] += weights[i] as i64;
if deficit[i] > deficit[best] {
best = i;
}
}
deficit[best] -= total;
schedule.push(best as u16);
}
schedule.into_boxed_slice()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{
AddressSpec, CircuitBreaker, HealthConfig, LbAlgorithm, OutlierDetection, Upstream,
};
use crate::upstream::UpstreamRegistry;
fn group(name: &str, healthy: bool) -> Arc<UpstreamGroup> {
let reg = UpstreamRegistry::new();
reg.reconcile(
&[Upstream {
name: name.to_string(),
addresses: vec![AddressSpec::Bare("127.0.0.1:9000".to_string())],
lb_algorithm: LbAlgorithm::RoundRobin,
hash: None,
tls: None,
resolve_interval_ms: 0,
health: HealthConfig {
path: "/healthz".to_string(),
interval_ms: 1000,
timeout_ms: 500,
healthy_threshold: 1,
unhealthy_threshold: 1,
port: None,
},
request_timeout_ms: 30_000,
max_retries: 1,
overall_timeout_ms: 0,
circuit_breaker: CircuitBreaker::default(),
outlier_detection: OutlierDetection::default(),
}],
std::path::Path::new("."),
)
.unwrap();
let g = reg.group(name).unwrap();
if healthy {
g.endpoints().instances[0].record_probe_success();
}
g
}
fn distribution(w: &WeightedBackends, n: usize) -> std::collections::HashMap<String, usize> {
let mut counts = std::collections::HashMap::new();
for _ in 0..n {
let g = w.pick().expect("a healthy backend exists");
*counts.entry(g.name.clone()).or_insert(0) += 1;
}
counts
}
#[test]
fn gcd_reduces_ratio() {
assert_eq!(gcd(5, 95), 5);
assert_eq!(gcd(95, 5), 5);
assert_eq!(gcd(7, 0), 7);
assert_eq!(gcd(50, 50), 50);
}
#[test]
fn validate_split_rejects_degenerate_weights() {
assert!(validate_split(&[]).is_err(), "no backends");
assert!(
validate_split(&[0, 0]).is_err(),
"every weight zero is rejected"
);
assert!(
validate_split(&[MAX_BACKEND_WEIGHT + 1]).is_err(),
"over-cap weight is rejected"
);
assert!(
validate_split(&[999_983, 999_979]).is_err(),
"a pathological coprime split is rejected by the table cap"
);
assert_eq!(validate_split(&[5, 95]).unwrap(), 20);
assert_eq!(validate_split(&[1]).unwrap(), 1);
assert_eq!(
validate_split(&[0, 7]).unwrap(),
1,
"a drained backend drops out"
);
}
#[test]
fn schedule_has_exact_per_item_counts() {
let schedule = weighted_schedule(&[1, 19]);
assert_eq!(schedule.len(), 20);
assert_eq!(schedule.iter().filter(|&&b| b == 0).count(), 1);
assert_eq!(schedule.iter().filter(|&&b| b == 1).count(), 19);
}
#[test]
fn schedule_is_evenly_interleaved_not_blocky() {
let schedule = weighted_schedule(&[1, 19]);
assert_ne!(schedule[0], 0, "minority is not at the front (blocky)");
assert_ne!(schedule[19], 0, "minority is not at the back (blocky)");
let s = weighted_schedule(&[3, 7]);
let zeros: Vec<usize> = s
.iter()
.enumerate()
.filter_map(|(i, &b)| (b == 0).then_some(i))
.collect();
assert_eq!(zeros.len(), 3);
let max_gap = zeros
.iter()
.zip(zeros.iter().cycle().skip(1))
.map(|(&a, &b)| if b > a { b - a } else { b + s.len() - a })
.max()
.unwrap();
assert!(
max_gap <= 4,
"minority is evenly spread (max gap {max_gap} ≤ 4)"
);
}
#[test]
fn split_is_proportional_over_a_full_cycle() {
let w =
WeightedBackends::new(vec![(group("v2", true), 5), (group("v1", true), 95)]).unwrap();
let d = distribution(&w, 100);
assert_eq!(d.get("v2"), Some(&5));
assert_eq!(d.get("v1"), Some(&95));
}
#[test]
fn single_backend_takes_all_traffic() {
let w = WeightedBackends::new(vec![(group("only", true), 1)]).unwrap();
let d = distribution(&w, 50);
assert_eq!(d.get("only"), Some(&50));
}
#[test]
fn unhealthy_backend_renormalizes_to_healthy() {
let w =
WeightedBackends::new(vec![(group("up", true), 1), (group("down", false), 1)]).unwrap();
let d = distribution(&w, 40);
assert_eq!(
d.get("up"),
Some(&40),
"the dead backend's share moves to the healthy one"
);
assert_eq!(d.get("down"), None);
}
#[test]
fn all_unhealthy_fails_closed_none() {
let w =
WeightedBackends::new(vec![(group("a", false), 1), (group("b", false), 1)]).unwrap();
assert!(
w.pick().is_none(),
"no eligible backend → None → the fast path 503s"
);
}
}