use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
};
use praxis_core::health::ClusterHealthState;
use smallvec::SmallVec;
use super::endpoint::WeightedEndpoint;
const LCG_A: u64 = 6_364_136_223_846_793_005;
const LCG_C: u64 = 1_442_695_040_888_963_407;
pub(crate) struct PowerOfTwoChoices {
pub(crate) counters: HashMap<Arc<str>, AtomicUsize>,
endpoints: Vec<WeightedEndpoint>,
rng: AtomicU64,
}
impl PowerOfTwoChoices {
pub(crate) fn new(endpoints: Vec<WeightedEndpoint>) -> Self {
let counters = endpoints
.iter()
.map(|ep| (Arc::clone(&ep.address), AtomicUsize::new(0)))
.collect();
Self {
counters,
endpoints,
rng: AtomicU64::new(1),
}
}
#[expect(clippy::indexing_slicing, reason = "keyed by endpoints built in new()")]
pub(crate) fn select(&self, health: Option<&ClusterHealthState>) -> Option<Arc<str>> {
if self.endpoints.is_empty() {
return None;
}
let candidates = self.healthy_candidates(health);
let total_w: usize = candidates.iter().map(|ep| ep.weight as usize).sum();
if candidates.len() <= 1 || total_w <= 1 {
let fallback = &self.endpoints[0];
let ep = candidates.first().copied().unwrap_or(fallback);
return Some(Arc::clone(&ep.address));
}
let (a, b) = self.pick_two(total_w);
let ep_a = weight_index(&candidates, a, total_w);
let ep_b = weight_index(&candidates, b, total_w);
let chosen = self.less_loaded(ep_a, ep_b);
self.counters[&*chosen.address].fetch_add(1, Ordering::AcqRel);
Some(Arc::clone(&chosen.address))
}
pub(crate) fn release(&self, addr: &str) {
if let Some(counter) = self.counters.get(addr) {
_ = counter.fetch_update(Ordering::Release, Ordering::Relaxed, |v| Some(v.saturating_sub(1)));
}
}
#[expect(clippy::indexing_slicing, reason = "keyed by endpoints built in new()")]
fn less_loaded<'a>(&self, a: &'a WeightedEndpoint, b: &'a WeightedEndpoint) -> &'a WeightedEndpoint {
let load_a = self.counters[&*a.address].load(Ordering::Acquire);
let load_b = self.counters[&*b.address].load(Ordering::Acquire);
match load_a.cmp(&load_b) {
core::cmp::Ordering::Less => a,
core::cmp::Ordering::Greater => b,
core::cmp::Ordering::Equal => {
if a.weight >= b.weight {
a
} else {
b
}
},
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "modulo total_weight bounds the result to usize range"
)]
fn pick_two(&self, total_weight: usize) -> (usize, usize) {
let r1 = self.next_random();
let mut r2 = self.next_random();
let a = (r1 as usize) % total_weight;
let mut b = (r2 as usize) % total_weight;
while b == a {
r2 = r2.wrapping_mul(LCG_A).wrapping_add(LCG_C);
b = (r2 as usize) % total_weight;
}
(a, b)
}
fn next_random(&self) -> u64 {
self.rng
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |s| {
Some(s.wrapping_mul(LCG_A).wrapping_add(LCG_C))
})
.unwrap_or(0)
}
#[expect(clippy::indexing_slicing, reason = "bounds checked by ep.index < len()")]
fn healthy_candidates(&self, health: Option<&ClusterHealthState>) -> SmallVec<[&WeightedEndpoint; 8]> {
if let Some(state) = health {
let healthy: SmallVec<[_; 8]> = self
.endpoints
.iter()
.filter(|ep| ep.index < state.endpoints().len() && state.endpoints()[ep.index].is_healthy())
.collect();
if !healthy.is_empty() {
return healthy;
}
}
self.endpoints.iter().collect()
}
}
#[expect(clippy::expect_used, reason = "total_weight > 0 guaranteed by caller")]
fn weight_index<'a>(endpoints: &[&'a WeightedEndpoint], slot: usize, total_weight: usize) -> &'a WeightedEndpoint {
let slot = slot % total_weight;
let mut cumulative = 0_usize;
for ep in endpoints {
cumulative += ep.weight as usize;
if slot < cumulative {
return ep;
}
}
endpoints.last().expect("endpoints must be non-empty")
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::too_many_lines,
reason = "tests"
)]
mod tests {
use std::sync::{Arc, atomic::Ordering};
use praxis_core::health::{ClusterHealthEntry, EndpointHealth};
use super::*;
#[test]
fn single_endpoint_always_selected() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0)]);
for _ in 0..10 {
assert_eq!(
&*p2c.select(None).unwrap(),
"10.0.0.1:80",
"single endpoint must always be returned"
);
}
}
#[test]
fn distributes_across_endpoints() {
let p2c = PowerOfTwoChoices::new(vec![
ep("10.0.0.1:80", 1, 0),
ep("10.0.0.2:80", 1, 1),
ep("10.0.0.3:80", 1, 2),
]);
for _ in 0..30 {
let addr = p2c.select(None).unwrap();
p2c.release(&addr);
}
let c1 = p2c.counters["10.0.0.1:80"].load(Ordering::Relaxed);
let c2 = p2c.counters["10.0.0.2:80"].load(Ordering::Relaxed);
let c3 = p2c.counters["10.0.0.3:80"].load(Ordering::Relaxed);
assert_eq!(c1 + c2 + c3, 0, "all counters should be zero after release");
}
#[test]
fn prefers_less_loaded() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0), ep("10.0.0.2:80", 1, 1)]);
p2c.counters["10.0.0.1:80"].store(100, Ordering::Relaxed);
let mut picked_2 = 0_u32;
for _ in 0..20 {
let addr = p2c.select(None).unwrap();
if &*addr == "10.0.0.2:80" {
picked_2 += 1;
}
p2c.release(&addr);
}
assert!(
picked_2 > 15,
"heavily loaded endpoint should be avoided: picked_2={picked_2}"
);
}
#[test]
fn weight_biases_sampling() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0), ep("10.0.0.2:80", 9, 1)]);
let mut counts = HashMap::new();
for _ in 0..100 {
let addr = p2c.select(None).unwrap();
*counts.entry(Arc::clone(&addr)).or_insert(0_u32) += 1;
p2c.release(&addr);
}
let heavy = counts.get("10.0.0.2:80").copied().unwrap_or(0);
assert!(heavy > 60, "weight-9 endpoint should get majority: heavy={heavy}");
}
#[test]
fn skips_unhealthy() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0), ep("10.0.0.2:80", 1, 1)]);
let state = health_state(2);
state.endpoints()[0].mark_unhealthy();
for _ in 0..10 {
assert_eq!(
&*p2c.select(Some(&state)).unwrap(),
"10.0.0.2:80",
"should skip unhealthy endpoint"
);
p2c.release("10.0.0.2:80");
}
}
#[test]
fn panic_mode_when_all_unhealthy() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0), ep("10.0.0.2:80", 1, 1)]);
let state = health_state(2);
state.endpoints()[0].mark_unhealthy();
state.endpoints()[1].mark_unhealthy();
let addr = p2c.select(Some(&state)).unwrap();
assert!(
&*addr == "10.0.0.1:80" || &*addr == "10.0.0.2:80",
"panic mode should still return an endpoint"
);
}
#[test]
fn release_does_not_underflow() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0)]);
p2c.release("10.0.0.1:80");
assert_eq!(
p2c.counters["10.0.0.1:80"].load(Ordering::Relaxed),
0,
"release without select should not underflow"
);
}
#[test]
fn release_unknown_addr_is_noop() {
let p2c = PowerOfTwoChoices::new(vec![ep("10.0.0.1:80", 1, 0)]);
p2c.release("10.0.0.99:80");
}
#[test]
fn concurrent_select_and_release() {
let p2c = Arc::new(PowerOfTwoChoices::new(vec![
ep("10.0.0.1:80", 1, 0),
ep("10.0.0.2:80", 1, 1),
]));
let handles: Vec<_> = (0..50)
.map(|_| {
let p = Arc::clone(&p2c);
std::thread::spawn(move || {
let addr = p.select(None).unwrap();
p.release(&addr);
})
})
.collect();
for h in handles {
h.join().expect("thread should not panic");
}
let c1 = p2c.counters["10.0.0.1:80"].load(Ordering::Relaxed);
let c2 = p2c.counters["10.0.0.2:80"].load(Ordering::Relaxed);
assert_eq!(c1 + c2, 0, "all counters should be zero after paired select+release");
}
fn ep(addr: &str, weight: u32, index: usize) -> WeightedEndpoint {
WeightedEndpoint {
address: Arc::from(addr),
weight,
index,
}
}
fn health_state(n: usize) -> ClusterHealthState {
let healths: Vec<_> = (0..n).map(|_| EndpointHealth::new()).collect();
let addrs: Vec<_> = (0..n).map(|i| Arc::from(format!("10.0.0.{i}:80").as_str())).collect();
Arc::new(ClusterHealthEntry::new(healths, addrs, None, None))
}
}