dwara_core/dataplane/balance.rs
1//! Per-upstream load balancing (DW-011, feature analysis 4.3).
2//!
3//! One [`UpstreamLb`] per upstream holds the endpoint set behind an
4//! [`ArcSwap`] (`LbState`), so [`UpstreamLb::pick`] is lock-free: it loads
5//! the current state snapshot and runs the configured algorithm with no
6//! mutex on the hot path. Endpoint mutation happens only on config reload
7//! via [`UpstreamLb::rebuild`], which swaps in a new state atomically —
8//! in-flight requests holding an older state keep working against it.
9//!
10//! Algorithms (config `load_balancer`):
11//!
12//! - `round_robin` — smooth weighted round-robin (the nginx algorithm):
13//! each pick adds every endpoint's effective weight to its running
14//! `current_weight`, selects the maximum, and subtracts the total from
15//! the winner. The result interleaves endpoints deterministically in
16//! proportion to their weights (weights {5,1,2} produce the classic
17//! `a a b a c a a` pattern) with period = sum of weights; over any full
18//! period each endpoint is picked exactly its weight many times.
19//! - `least_requests` — the endpoint with the fewest in-flight requests
20//! (see the inflight counters below) wins; ties break to the lowest
21//! index. Slow start is NOT applied here (documented choice: least-conn
22//! balances on observed load, not weights; a ramping endpoint naturally
23//! receives traffic only when others are busier, which is the same
24//! conservative effect without inventing a weight model).
25//! - `random` — "power of two choices": two distinct endpoints are drawn
26//! uniformly at random and the one with the lower in-flight count wins
27//! (ties to the lower index). With large fan-out this converges to
28//! least-conn at a fraction of the coordination cost.
29//! - `ip_hash` — consistent hashing (ketama): each endpoint is hashed onto
30//! a ring with vnode count per unit of weight ([`KETAMA_VNODES`] = 160
31//! per weight unit, so a weight-w endpoint gets 160*w vnodes); a pick
32//! hashes its key (the client IP, plumbed by the proxy) and takes the
33//! first ring entry at or after that hash. Because vnodes are additive
34//! (no max-normalization), adding or removing an endpoint remaps only
35//! ~1/(n+1) of keys for equal weights and keys between unchanged
36//! endpoints stay put. Ring and key hashing use an inline, fully
37//! specified FNV-1a hash with a murmur3-style finalizer — stable
38//! across Rust versions, unlike `DefaultHasher` (SipHash), which
39//! makes no cross-version stability guarantee and would silently
40//! remap every sticky session after a toolchain bump. Slow start is
41//! NOT applied to the ring (documented
42//! choice: rebuilding a ring mid-ramp would remap keys; consistency is
43//! the point of the algorithm). With no key (e.g. the TLS-passthrough
44//! path) ip_hash falls back to smooth weighted round-robin.
45//!
46//! In-flight counting: every endpoint carries an `AtomicU64` in-flight
47//! counter. The upstream handle increments the picked endpoint's counter
48//! at dispatch and decrements when the response (headers) resolves — NOT
49//! when the streaming body completes, which the pool cannot observe
50//! without wrapping bodies (documented approximation; it biases
51//! least-conn/random-2 slightly optimistic during long streams).
52//!
53//! Slow start (`slow_start_ms`, default 0 = off): an endpoint entering the
54//! set ramps its effective weight from a floor of 1 to its configured
55//! weight over the window, measured from the moment it entered the set.
56//! The ramp applies to smooth WRR and (through weights) ip_hash vnode
57//! counts are exempt as above. Endpoints already in the set when
58//! slow_start is newly enabled do NOT ramp — their carried entry instant
59//! predates the window; only genuinely new addresses ramp.
60//! Recovery-ramp after a health failure is
61//! future work (DW-015 owns health state) — the effective-weight function
62//! is the same one that integration will call.
63//!
64//! Hot endpoint sets / weight changes without restart: config reload
65//! rebuilds the registry and calls [`UpstreamLb::rebuild`] with the new
66//! endpoint list. Endpoints whose `address:port` is unchanged KEEP their
67//! in-flight counter, `current_weight` (WRR phase), and slow-start entry
68//! instant; new addresses start fresh (in-flight 0, full slow-start ramp
69//! if configured). Documented reset semantics: removed endpoints drop
70//! their state outright; re-adding one later is a fresh entry.
71//!
72//! Randomness for `random` (random-2) is a process-local xorshift64* seed
73//! under a CAS loop — no `rand` dependency, adequate uniformity for two
74//! choices.
75
76use std::collections::BTreeMap;
77use std::str::FromStr as _;
78use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
79use std::sync::{Arc, RwLock};
80use std::time::{Duration, Instant};
81
82use arc_swap::ArcSwap;
83use http::uri::Authority;
84use http::HeaderValue;
85
86use crate::config::limits::{KETAMA_VNODES, MAGLEV_TABLE_SIZE};
87use crate::config::{Endpoint, LoadBalancer, PassiveHealth, PeakEwmaConfig};
88use crate::resilience::health::{EndpointHealth, HealthDispatch, HealthParams};
89
90/// Peak-EWMA latency tracker (DW-090): one per endpoint when the
91/// upstream's algorithm is `peak_ewma`. Holds the current EWMA cost
92/// (`cost_ns`, in nanoseconds) and the timestamp of the last update
93/// (`stamp`, in nanoseconds since the Unix epoch) as atomics so
94/// [`UpstreamLb::record_latency`] is lock-free on the hot path. Carried
95/// across rebuilds by `address:port` (exactly like `inflight` and
96/// `health`): the live tracker survives a config reload so a latency
97/// history accumulated under one generation is not thrown away by the
98/// next.
99///
100/// The Finagle peak-EWMA formula (see [`UpstreamLb::record_latency`]):
101/// a new RTT that EXCEEDS the current cost replaces it outright (the
102/// "peak" — a slow response is remembered in full so the balancer
103/// degrades an outlier immediately), otherwise the cost decays toward
104/// the new RTT at rate `w = exp(-td / tau)` where `td` is the time
105/// since the last update and `tau` is the decay window. Selection
106/// scores endpoints by `cost_ns * (inflight + 1)` (lowest wins).
107pub struct PeakEwmaTracker {
108 cost_ns: AtomicU64,
109 stamp: AtomicU64,
110}
111
112impl PeakEwmaTracker {
113 /// Build a fresh tracker with the given initial cost (nanoseconds)
114 /// and a stamp of "now" so the first update's time delta is small.
115 fn new(initial_cost_ns: u64) -> Self {
116 let now = system_now_ns();
117 PeakEwmaTracker {
118 cost_ns: AtomicU64::new(initial_cost_ns),
119 stamp: AtomicU64::new(now),
120 }
121 }
122
123 /// Current EWMA cost in nanoseconds.
124 pub fn cost_ns(&self) -> u64 {
125 self.cost_ns.load(Ordering::Relaxed)
126 }
127}
128
129/// Unix-epoch nanosecond clock for the peak-EWMA stamp. System clock in
130/// production; the stamp only needs to be monotonic-ish for the decay
131/// delta (a reload that moves the clock backward produces a small or
132/// zero `td`, which makes the first post-reload update near-peak —
133/// harmless and self-correcting on the next observation).
134fn system_now_ns() -> u64 {
135 std::time::SystemTime::now()
136 .duration_since(std::time::UNIX_EPOCH)
137 .map(|d| d.as_nanos() as u64)
138 .unwrap_or(0)
139}
140
141/// `address:port` with IPv6 literals bracketed. `::1:8080` is not a
142/// parseable URI authority; `[::1]:8080` is.
143///
144/// PERF: lives here (next to the endpoint rows it describes) because it
145/// is computed ONCE per endpoint at [`LbState`] build time; the dispatch
146/// path clones the result instead of formatting it per attempt.
147fn endpoint_authority(address: &str, port: u16) -> Arc<str> {
148 // PERF: single format! instead of two (host + authority).
149 if address.parse::<std::net::Ipv6Addr>().is_ok() {
150 format!("[{address}]:{port}").into()
151 } else {
152 format!("{address}:{port}").into()
153 }
154}
155
156/// One endpoint's runtime row inside an [`LbState`].
157struct LbEndpoint {
158 address: String,
159 port: u16,
160 /// PERF: the endpoint's dial authority ("address:port", IPv6
161 /// bracketed) and its parsed HTTP forms, computed once here instead
162 /// of formatted + parsed on every dispatch. The `Arc<str>` and the
163 /// refcount-backed `Authority`/`HeaderValue` clones cost an atomic
164 /// bump per pick. The parsed pair is `None` only if the configured
165 /// address somehow fails to parse (validation rejects those); the
166 /// send path then falls back to parsing the authority string per
167 /// attempt — exactly the pre-optimization behavior.
168 authority: Arc<str>,
169 http_authority: Option<Authority>,
170 host_value: Option<HeaderValue>,
171 /// Configured weight (>= 1; validation enforces).
172 weight: u32,
173 /// DW-094 (Ent): the endpoint's region (e.g. `us-east-1`), used
174 /// for locality-aware routing and data residency enforcement.
175 /// `None` = locality-agnostic (always eligible).
176 region: Option<String>,
177 /// DW-094 (Ent): the endpoint's zone within a region.
178 zone: Option<String>,
179 /// When this endpoint entered the set (slow-start clock). Carried
180 /// across rebuilds for unchanged addresses.
181 entered: Instant,
182 /// In-flight request count (incremented at dispatch by the handle).
183 /// Shared (`Arc`) so a rebuild carries the LIVE counter, not a copy:
184 /// guards taken before a swap decrement the same counter the next
185 /// state reads.
186 inflight: Arc<AtomicU64>,
187 /// Smooth-WRR running weight (the nginx algorithm's per-endpoint
188 /// accumulator). Carried across rebuilds for unchanged addresses as a
189 /// SHARED cell (exactly like `inflight`): a pick in flight against
190 /// the old snapshot while a reload swaps in a new state mutates the
191 /// same accumulator the next generation reads, so no phase step is
192 /// lost (#128, DW-011 review).
193 current_weight: Arc<AtomicI64>,
194 /// Passive health tracker (DW-012); present only when the upstream
195 /// configures `health`. Carried across rebuilds for unchanged
196 /// addresses, exactly like `inflight`: the live tracker survives the
197 /// swap, so consecutive-failure streaks and the observation window
198 /// survive config reloads.
199 health: Option<Arc<EndpointHealth>>,
200 /// Peak-EWMA latency tracker (DW-090); present only when the
201 /// upstream's algorithm is `peak_ewma`. Carried across rebuilds for
202 /// unchanged addresses, exactly like `inflight`: the live cost
203 /// history survives the swap, so a latency trend accumulated under
204 /// one generation is not reset by the next.
205 peak_ewma: Option<Arc<PeakEwmaTracker>>,
206}
207
208impl LbEndpoint {
209 fn new(e: &Endpoint) -> Self {
210 let authority = endpoint_authority(&e.address, e.port);
211 LbEndpoint {
212 address: e.address.clone(),
213 port: e.port,
214 authority: Arc::clone(&authority),
215 http_authority: Authority::from_str(&authority).ok(),
216 host_value: HeaderValue::from_str(&authority).ok(),
217 weight: e.weight.max(1),
218 region: e.region.clone(),
219 zone: e.zone.clone(),
220 entered: Instant::now(),
221 inflight: Arc::new(AtomicU64::new(0)),
222 current_weight: Arc::new(AtomicI64::new(0)),
223 health: None,
224 peak_ewma: None,
225 }
226 }
227
228 /// Same endpoint (address:port unchanged): keep live counters, WRR
229 /// phase, and slow-start clock; take the new configured weight. The
230 /// WRR phase carries as the LIVE shared cell, not a copied value, so
231 /// a pick racing the rebuild cannot strand a phase step in the old
232 /// state (#128).
233 fn carried_from(old: &LbEndpoint, e: &Endpoint) -> Self {
234 // same_target guarantees address:port — and therefore the whole
235 // precomputed authority trio — is unchanged; carry it by clone.
236 LbEndpoint {
237 address: old.address.clone(),
238 port: old.port,
239 authority: Arc::clone(&old.authority),
240 http_authority: old.http_authority.clone(),
241 host_value: old.host_value.clone(),
242 weight: e.weight.max(1),
243 region: e.region.clone(),
244 zone: e.zone.clone(),
245 entered: old.entered,
246 inflight: Arc::clone(&old.inflight),
247 current_weight: Arc::clone(&old.current_weight),
248 health: old.health.clone(),
249 peak_ewma: old.peak_ewma.clone(),
250 }
251 }
252
253 fn same_target(&self, e: &Endpoint) -> bool {
254 self.address == e.address && self.port == e.port
255 }
256}
257
258/// Immutable endpoint-set snapshot + algorithm parameters. Swapped
259/// atomically by [`UpstreamLb::rebuild`].
260struct LbState {
261 endpoints: Vec<LbEndpoint>,
262 algorithm: LoadBalancer,
263 slow_start: Duration,
264 /// Ketama ring (hash -> endpoint index); empty unless ip_hash.
265 ring: BTreeMap<u64, usize>,
266 /// Maglev lookup table (DP-07, #236): fixed-size array mapping
267 /// `hash % table_size` to an endpoint index. Empty unless the
268 /// algorithm is `maglev`.
269 maglev_table: Vec<usize>,
270 /// Resolved passive-health parameters for this generation (DW-012);
271 /// `None` = passive health disabled (no ejection, no filtering).
272 health: Option<Arc<HealthParams>>,
273 /// Peak-EWMA decay tau in nanoseconds (DW-090); only meaningful when
274 /// `algorithm == PeakEwma`. Stored on the state so
275 /// [`UpstreamLb::record_latency`] reads the generation's tau without
276 /// a config lookup on the hot path.
277 peak_ewma_tau_ns: u64,
278 /// Peak-EWMA resolved config (DW-090); the discovery task reads this
279 /// so a live endpoint-set swap uses the same tuning as the initial
280 /// build (a custom `decay_ms` / `default_rtt_ms` survives a DNS
281 /// refresh). `None` when the algorithm is not `peak_ewma`.
282 peak_ewma_cfg: Option<Arc<PeakEwmaConfig>>,
283 /// DW-094 (Ent): resolved locality-aware routing config for this
284 /// upstream. `None` = locality-aware routing disabled (the OSS
285 /// behavior — all endpoints are eligible regardless of region/zone).
286 locality: Option<Arc<crate::config::UpstreamLocality>>,
287}
288
289impl LbState {
290 /// Effective weight after the slow-start ramp (floor 1 so a ramping
291 /// endpoint is never excluded from the set).
292 fn effective_weight(&self, e: &LbEndpoint) -> i64 {
293 if self.slow_start.is_zero() {
294 return e.weight as i64;
295 }
296 let elapsed = e.entered.elapsed();
297 if elapsed >= self.slow_start {
298 e.weight as i64
299 } else {
300 let w = e.weight as u128;
301 let ramped = (w * elapsed.as_millis()) / self.slow_start.as_millis().max(1);
302 ramped.max(1) as i64
303 }
304 }
305
306 /// REL-06 (#217): recovery ramp multiplier for an endpoint that
307 /// recently recovered from ejection. Returns a value in [0.0, 1.0]
308 /// representing the fraction of the endpoint's configured weight to
309 /// apply. 1.0 = full weight (ramp complete or disabled). The ramp
310 /// starts at a floor (proportional to 1/weight) and linearly
311 /// increases to 1.0 over `recovery_ramp_ms`.
312 fn recovery_ramp_factor(&self, e: &LbEndpoint) -> f64 {
313 let Some(params) = &self.health else {
314 return 1.0;
315 };
316 if params.recovery_ramp_ms == 0 {
317 return 1.0;
318 }
319 let Some(tracker) = &e.health else {
320 return 1.0;
321 };
322 let recovered_at = tracker.recovered_at_ms();
323 if recovered_at == 0 {
324 return 1.0;
325 }
326 let now = system_now_ms();
327 let elapsed = now.saturating_sub(recovered_at);
328 if elapsed >= params.recovery_ramp_ms {
329 return 1.0;
330 }
331 // Linear ramp from 0.1 (floor) to 1.0 over the window.
332 0.1 + 0.9 * (elapsed as f64 / params.recovery_ramp_ms as f64)
333 }
334}
335
336/// DW-094 (Ent): the result of locality-aware filtering in
337/// [`UpstreamLb::choose`].
338enum LocalityResult {
339 /// The candidate set was restricted to a subset (local endpoints
340 /// or residency-filtered endpoints).
341 Restricted(Vec<usize>),
342 /// The candidate set was not changed (locality filtering was a
343 /// no-op — either all endpoints are local, or the mode allows
344 /// fallback and no restriction was needed). Carries the original
345 /// health-filtered candidate set.
346 Unchanged(Option<Vec<usize>>),
347 /// The request was denied — no endpoints satisfy the locality
348 /// constraints (strict mode with no local endpoints, or data
349 /// residency with no allowed endpoints). The balancer returns
350 /// `None` → 503.
351 Denied,
352}
353
354/// A load balancer for one upstream: lock-free picks over an atomically
355/// swappable endpoint set. Share via `Arc`; cheap to read from any task.
356pub struct UpstreamLb {
357 state: ArcSwap<LbState>,
358 rng: AtomicU64,
359 /// Millisecond clock for passive-health timing (DW-012). System clock
360 /// in production; swappable for deterministic clocks in tests
361 /// (`set_health_clock`). Read on every pick when health is enabled.
362 health_clock: RwLock<fn() -> u64>,
363 /// Picks that fell back to the full endpoint set because every
364 /// endpoint was ejected (fail-open; see `choose`). Observability.
365 fail_open_picks: AtomicU64,
366 /// DW-042: the upstream-scoped event emitter, kept so the DNS
367 /// discovery task can pass it to `rebuild_with_health_and_events`
368 /// when it swaps the endpoint set live (new endpoints get
369 /// event-bound health trackers, matching the reload path). None
370 /// when the balancer was built without events.
371 events: Option<crate::events::UpstreamEmitter>,
372 /// DW-094 (Ent): the edge's locality context (region/zone). Set
373 /// once at dataplane startup from env vars or edge labels; read on
374 /// every pick when the upstream has `locality` configured. Default
375 /// (empty) = no locality filtering (the OSS behavior).
376 locality_ctx: RwLock<crate::config::LocalityContext>,
377}
378
379fn xorshift(x: u64) -> u64 {
380 let mut x = x.wrapping_add(0x9E3779B97F4A7C15);
381 x ^= x >> 12;
382 x ^= x << 25;
383 x ^= x >> 27;
384 x.wrapping_mul(0x2545F4914F6CDD1D)
385}
386
387/// Default passive-health clock: Unix-epoch milliseconds.
388fn system_now_ms() -> u64 {
389 std::time::SystemTime::now()
390 .duration_since(std::time::UNIX_EPOCH)
391 .map(|d| d.as_millis() as u64)
392 .unwrap_or(0)
393}
394
395#[allow(clippy::too_many_arguments)]
396fn build_state(
397 endpoints: &[Endpoint],
398 algorithm: LoadBalancer,
399 slow_start: Duration,
400 previous: Option<&LbState>,
401 health: Option<Arc<HealthParams>>,
402 events: Option<&crate::events::UpstreamEmitter>,
403 peak_ewma_cfg: Option<&PeakEwmaConfig>,
404 locality: Option<&crate::config::UpstreamLocality>,
405) -> LbState {
406 let mut eps: Vec<LbEndpoint> = Vec::with_capacity(endpoints.len());
407 for e in endpoints {
408 let carried = previous.and_then(|p| {
409 p.endpoints
410 .iter()
411 .find(|old| old.same_target(e))
412 .map(|old| LbEndpoint::carried_from(old, e))
413 });
414 eps.push(carried.unwrap_or_else(|| LbEndpoint::new(e)));
415 }
416 // Passive health invariant: when this generation has health enabled,
417 // EVERY endpoint carries a tracker (fresh ones for new addresses, the
418 // carried live tracker for unchanged addresses; enabling health on a
419 // rebuild gives unchanged addresses a fresh tracker with no history).
420 // A fresh tracker is created WITH its ejection/recovery event binding
421 // (DW-044) when the balancer was built with an upstream emitter.
422 if health.is_some() {
423 for e in &mut eps {
424 if e.health.is_none() {
425 let tracker = match events {
426 Some(events) => {
427 EndpointHealth::with_events(events.for_endpoint(&endpoint_label(e)))
428 }
429 None => EndpointHealth::new(),
430 };
431 e.health = Some(Arc::new(tracker));
432 }
433 }
434 }
435 // Peak-EWMA invariant (DW-090): when the algorithm is peak_ewma,
436 // EVERY endpoint carries a latency tracker (fresh ones for new
437 // addresses, the carried live tracker for unchanged addresses).
438 // The initial cost is `default_rtt_ms` in nanoseconds; the decay
439 // tau is resolved once here and stored on the state.
440 let peak_ewma_tau_ns = if algorithm == LoadBalancer::PeakEwma {
441 let decay_ms = peak_ewma_cfg.and_then(|c| c.decay_ms).unwrap_or(10_000);
442 let default_rtt_ms = peak_ewma_cfg.and_then(|c| c.default_rtt_ms).unwrap_or(250);
443 let initial_cost_ns = default_rtt_ms.saturating_mul(1_000_000);
444 for e in &mut eps {
445 if e.peak_ewma.is_none() {
446 e.peak_ewma = Some(Arc::new(PeakEwmaTracker::new(initial_cost_ns)));
447 }
448 }
449 decay_ms.saturating_mul(1_000_000)
450 } else {
451 0
452 };
453 let peak_ewma_cfg = peak_ewma_cfg.map(|c| Arc::new(c.clone()));
454 let ring = if algorithm == LoadBalancer::IpHash {
455 build_ring(&eps)
456 } else {
457 BTreeMap::new()
458 };
459 let maglev_table = if algorithm == LoadBalancer::Maglev {
460 build_maglev_table(&eps)
461 } else {
462 Vec::new()
463 };
464 LbState {
465 endpoints: eps,
466 algorithm,
467 slow_start,
468 ring,
469 maglev_table,
470 health,
471 peak_ewma_tau_ns,
472 peak_ewma_cfg,
473 locality: locality.map(|l| Arc::new(l.clone())),
474 }
475}
476
477/// Ketama ring construction: vnode count is PER UNIT OF WEIGHT — endpoint
478/// i gets `KETAMA_VNODES * weight_i` vnodes. Footprint is additive (no
479/// max-normalization), so an endpoint's vnode positions never move when
480/// another endpoint with a different weight joins or leaves: adding a
481/// weight-w endpoint to total weight W remaps only ~w/(W+w) of keys, and
482/// keys between unchanged endpoints stay put. Validation bounds the total
483/// ring size at `config::limits::MAX_RING_VNODES`. Collisions (same ring
484/// hash) keep the later endpoint — harmless at 64-bit hash widths.
485fn build_ring(eps: &[LbEndpoint]) -> BTreeMap<u64, usize> {
486 let mut ring = BTreeMap::new();
487 for (i, e) in eps.iter().enumerate() {
488 let vnodes = (KETAMA_VNODES * e.weight.max(1) as u64).max(1);
489 for v in 0..vnodes {
490 let mut h = Fnv1a::new();
491 h.update(e.address.as_bytes());
492 h.update(&e.port.to_be_bytes());
493 h.update(&v.to_be_bytes());
494 ring.insert(h.finish(), i);
495 }
496 }
497 ring
498}
499
500/// Maglev lookup table construction (DP-07, #236). The Maglev algorithm
501/// (Google's network load balancer) builds a fixed-size lookup table
502/// where each slot maps to an endpoint. Each endpoint generates a
503/// permutation of the table indices; the table is populated by walking
504/// through each endpoint's permutation in round-robin order, filling
505/// the first empty slot each endpoint's permutation points to. This
506/// produces a table with even distribution and minimal remapping on
507/// endpoint changes.
508///
509/// The table size must be prime (65537 by default). Weight is applied
510/// by repeating each endpoint's entry in the population walk (a
511/// weight-w endpoint gets w "slots" in the round-robin, giving it
512/// proportionally more table entries).
513fn build_maglev_table(eps: &[LbEndpoint]) -> Vec<usize> {
514 let n = eps.len();
515 if n == 0 {
516 return Vec::new();
517 }
518 let m = MAGLEV_TABLE_SIZE;
519 // Build each endpoint's permutation: offset[i] and skip[i] are
520 // computed from the endpoint's identity hash. The permutation is
521 // (offset + j * skip) % m for j = 0, 1, 2, ...
522 let permutations: Vec<(u64, u64)> = eps
523 .iter()
524 .map(|e| {
525 let mut h = Fnv1a::new();
526 h.update(e.address.as_bytes());
527 h.update(&e.port.to_be_bytes());
528 let raw = h.finish();
529 let offset = raw % m as u64;
530 let skip = (raw / m as u64 % (m as u64 - 1) + 1) % (m as u64 - 1);
531 (offset, skip)
532 })
533 .collect();
534 // Build the table: for each endpoint, maintain a cursor into its
535 // permutation. Walk through endpoints in round-robin order (repeating
536 // weight times), filling the first empty slot each permutation
537 // points to. Continue until the table is full.
538 let mut table = vec![usize::MAX; m];
539 // Weighted cursor: each endpoint appears `weight` times in the
540 // round-robin walk.
541 let weighted_indices: Vec<usize> = eps
542 .iter()
543 .enumerate()
544 .flat_map(|(i, e)| std::iter::repeat_n(i, e.weight.max(1) as usize))
545 .collect();
546 if weighted_indices.is_empty() {
547 return Vec::new();
548 }
549 let mut cursors = vec![0u64; n];
550 let mut filled = 0usize;
551 while filled < m {
552 for &ep_idx in &weighted_indices {
553 let (offset, skip) = permutations[ep_idx];
554 // Find the next empty slot for this endpoint.
555 loop {
556 let slot = ((offset + cursors[ep_idx] * skip) % m as u64) as usize;
557 cursors[ep_idx] += 1;
558 if table[slot] == usize::MAX {
559 table[slot] = ep_idx;
560 filled += 1;
561 break;
562 }
563 // Safety: the permutation visits all slots, so this loop
564 // terminates (when the table is nearly full, some
565 // endpoints may need to skip many filled slots, but the
566 // total work is bounded by O(m * n)).
567 }
568 if filled >= m {
569 break;
570 }
571 }
572 }
573 table
574}
575
576/// Hash a client key onto ring space (same FNV-1a as the ring uses).
577/// pub(crate): the DW-040 service split hashes branch keys with the
578/// SAME function so a sticky cookie maps consistently across both
579/// decision layers.
580pub(crate) fn key_hash(key: &str) -> u64 {
581 let mut h = Fnv1a::new();
582 h.update(key.as_bytes());
583 h.finish()
584}
585
586/// Inline FNV-1a (64-bit). Ring positions and lookup keys must hash
587/// identically for the life of a deployment, including across Rust
588/// toolchain upgrades — `DefaultHasher` (SipHash) makes no stability
589/// guarantee across Rust versions, so sticky sessions could silently
590/// remap wholesale after a toolchain bump. FNV-1a is fully specified,
591/// trivially stable, and adequate quality for consistent-hash buckets
592/// (it is not used for any adversarial/table-index purpose).
593struct Fnv1a(u64);
594
595impl Fnv1a {
596 fn new() -> Self {
597 Fnv1a(0xcbf2_9ce4_8422_2325)
598 }
599
600 fn update(&mut self, bytes: &[u8]) {
601 for &b in bytes {
602 self.0 ^= b as u64;
603 self.0 = self.0.wrapping_mul(0x100_0000_01b3);
604 }
605 }
606
607 /// Finish with a murmur3-style 64-bit finalizer: raw FNV-1a output
608 /// keeps poor high-bit diffusion (outputs for inputs of different
609 /// lengths occupy systematically different magnitude ranges), which
610 /// collapses consistent-hash rings. The finalizer is itself fully
611 /// specified and version-stable.
612 fn finish(self) -> u64 {
613 let mut x = self.0;
614 x ^= x >> 33;
615 x = x.wrapping_mul(0xff51_afd7_ed55_8ccd);
616 x ^= x >> 33;
617 x = x.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
618 x ^= x >> 33;
619 x
620 }
621}
622
623/// `address:port` label for one endpoint row, IPv6 literals bracketed
624/// (the same spelling `upstream.rs` dials and the `endpoint_health`
625/// metric uses, so an ejection event and the gauge series agree).
626fn endpoint_label(e: &LbEndpoint) -> String {
627 let host = if e.address.parse::<std::net::Ipv6Addr>().is_ok() {
628 format!("[{}]", e.address)
629 } else {
630 e.address.clone()
631 };
632 format!("{host}:{}", e.port)
633}
634
635/// Smooth weighted round-robin pick (nginx algorithm) over effective
636/// weights, restricted to the candidate indices. Period = sum of effective
637/// weights; over any full period each endpoint is picked exactly its
638/// effective-weight many times.
639///
640/// `cand == None` means "every endpoint index" and runs allocation-free
641/// (the passive-health-disabled / fail-open paths); `Some(slice)` restricts
642/// the walk to the filtered candidate set.
643fn smooth_weighted_rr(state: &LbState, cand: Option<&[usize]>) -> usize {
644 let n = cand.map_or(state.endpoints.len(), |c| c.len());
645 let resolve = |i: usize| cand.map_or(i, |c| c[i]);
646 let mut total: i64 = 0;
647 let mut best: usize = resolve(0);
648 let mut best_cw: i64 = i64::MIN;
649 for i in 0..n {
650 let e = &state.endpoints[resolve(i)];
651 let w = state.effective_weight(e);
652 // REL-06 (#217): apply the recovery ramp factor to reduce
653 // the effective weight of a recently-recovered endpoint.
654 let w = if w > 0 {
655 let factor = state.recovery_ramp_factor(e);
656 ((w as f64) * factor).round() as i64
657 } else {
658 w
659 };
660 let w = w.max(1);
661 total += w;
662 let cw = e.current_weight.fetch_add(w, Ordering::Relaxed) + w;
663 // Strict >: ties keep the lowest index (deterministic).
664 if cw > best_cw {
665 best_cw = cw;
666 best = resolve(i);
667 }
668 }
669 if total <= 0 {
670 // Unreachable for validated sets (weights >= 1); degrade to the
671 // first candidate rather than dividing by zero elsewhere.
672 return best;
673 }
674 state.endpoints[best]
675 .current_weight
676 .fetch_sub(total, Ordering::Relaxed);
677 best
678}
679
680impl UpstreamLb {
681 /// Build a balancer with a fresh endpoint set (all slow-start clocks
682 /// start now) and passive health DISABLED. See [`UpstreamLb::new_with_health`].
683 pub fn new(endpoints: &[Endpoint], algorithm: LoadBalancer, slow_start: Duration) -> Arc<Self> {
684 Self::new_with_health(endpoints, algorithm, slow_start, None)
685 }
686
687 /// `new` with passive health (DW-012): when `health` is `Some`, every
688 /// endpoint gets a fresh health tracker and picks skip ejected
689 /// endpoints (all-ejected falls back to the full set; see `choose`).
690 /// The config form is resolved via [`HealthParams::from_config`];
691 /// validation guarantees its bounds.
692 pub fn new_with_health(
693 endpoints: &[Endpoint],
694 algorithm: LoadBalancer,
695 slow_start: Duration,
696 health: Option<&PassiveHealth>,
697 ) -> Arc<Self> {
698 Self::new_with_health_and_events(endpoints, algorithm, slow_start, health, None, None, None)
699 }
700
701 /// `new_with_health` with ejection/recovery events (DW-044): fresh
702 /// trackers are created bound to `events` (an upstream-labeled
703 /// emitter), so their ejection/recovery transitions emit onto the
704 /// event bus. Carried trackers keep the binding they were built
705 /// with. `None` behaves exactly like [`UpstreamLb::new_with_health`].
706 /// `peak_ewma` (DW-090) is the per-upstream tuning consulted only
707 /// when `algorithm == PeakEwma`; `None` uses the built-in defaults.
708 /// `locality` (DW-094, Ent) is the per-upstream locality-aware
709 /// routing config; `None` disables locality filtering.
710 #[allow(clippy::too_many_arguments)]
711 pub fn new_with_health_and_events(
712 endpoints: &[Endpoint],
713 algorithm: LoadBalancer,
714 slow_start: Duration,
715 health: Option<&PassiveHealth>,
716 events: Option<&crate::events::UpstreamEmitter>,
717 peak_ewma: Option<&PeakEwmaConfig>,
718 locality: Option<&crate::config::UpstreamLocality>,
719 ) -> Arc<Self> {
720 let seed = std::time::SystemTime::now()
721 .duration_since(std::time::UNIX_EPOCH)
722 .map(|d| d.as_nanos() as u64)
723 .unwrap_or(0x853c49e6748fea9b)
724 ^ (&endpoints as *const _ as u64)
725 ^ std::process::id() as u64;
726 let health = health.map(|h| Arc::new(HealthParams::from_config(h)));
727 Arc::new(UpstreamLb {
728 state: ArcSwap::from_pointee(build_state(
729 endpoints, algorithm, slow_start, None, health, events, peak_ewma, locality,
730 )),
731 rng: AtomicU64::new(seed | 1),
732 health_clock: RwLock::new(system_now_ms),
733 fail_open_picks: AtomicU64::new(0),
734 events: events.cloned(),
735 locality_ctx: RwLock::new(crate::config::LocalityContext::default()),
736 })
737 }
738
739 /// Hot-swap the endpoint set and/or algorithm (passive health
740 /// disabled; see [`UpstreamLb::rebuild_with_health`]). Unchanged
741 /// addresses keep their in-flight counters, WRR phase, slow-start
742 /// entry instant, and health trackers; new addresses start fresh.
743 /// Atomic; concurrent picks see either the old or the new set, never
744 /// a mix.
745 pub fn rebuild(&self, endpoints: &[Endpoint], algorithm: LoadBalancer, slow_start: Duration) {
746 self.rebuild_with_health(endpoints, algorithm, slow_start, None);
747 }
748
749 /// `rebuild` with passive health parameters for the new generation
750 /// (DW-012). Unchanged addresses keep their LIVE trackers (streak and
751 /// observation window survive the reload); the new parameters apply
752 /// to NEW observations only. Health state is carried per
753 /// `address:port`, exactly like in-flight counters; removed endpoints
754 /// drop their trackers outright and re-added ones start healthy with
755 /// no history.
756 pub fn rebuild_with_health(
757 &self,
758 endpoints: &[Endpoint],
759 algorithm: LoadBalancer,
760 slow_start: Duration,
761 health: Option<&PassiveHealth>,
762 ) {
763 self.rebuild_with_health_and_events(
764 endpoints, algorithm, slow_start, health, None, None, None,
765 );
766 }
767
768 /// `rebuild_with_health` with ejection/recovery events (DW-044):
769 /// trackers created by THIS rebuild bind `events`; carried trackers
770 /// keep their original binding (the emitter is per-dataplane and
771 /// stable, so in practice the two agree). `None` behaves exactly
772 /// like [`UpstreamLb::rebuild_with_health`]. `peak_ewma` (DW-090) is
773 /// the per-upstream tuning consulted only when
774 /// `algorithm == PeakEwma`; `None` uses the built-in defaults.
775 /// `locality` (DW-094, Ent) is the per-upstream locality-aware
776 /// routing config; `None` disables locality filtering for this
777 /// generation.
778 #[allow(clippy::too_many_arguments)]
779 pub fn rebuild_with_health_and_events(
780 &self,
781 endpoints: &[Endpoint],
782 algorithm: LoadBalancer,
783 slow_start: Duration,
784 health: Option<&PassiveHealth>,
785 events: Option<&crate::events::UpstreamEmitter>,
786 peak_ewma: Option<&PeakEwmaConfig>,
787 locality: Option<&crate::config::UpstreamLocality>,
788 ) {
789 let prev = self.state.load_full();
790 let health = health.map(|h| Arc::new(HealthParams::from_config(h)));
791 self.state.store(Arc::new(build_state(
792 endpoints,
793 algorithm,
794 slow_start,
795 Some(&prev),
796 health,
797 events,
798 peak_ewma,
799 locality,
800 )));
801 }
802
803 /// DW-042: `rebuild_with_health_and_events` taking RESOLVED health
804 /// parameters (`Arc<HealthParams>`) instead of the config form. The
805 /// DNS discovery task reads the resolved parameters from the current
806 /// LbState (via [`UpstreamLb::health_config`]) so a live endpoint-set
807 /// swap uses the same health parameters as the initial build without
808 /// re-resolving the config form. `peak_ewma` (DW-090) is the
809 /// per-upstream tuning consulted only when
810 /// `algorithm == PeakEwma`; `None` uses the built-in defaults.
811 /// `locality` (DW-094, Ent) is the per-upstream locality-aware
812 /// routing config; `None` disables locality filtering for this
813 /// generation.
814 #[allow(clippy::too_many_arguments)]
815 pub fn rebuild_with_resolved_health_and_events(
816 &self,
817 endpoints: &[Endpoint],
818 algorithm: LoadBalancer,
819 slow_start: Duration,
820 health: Option<Arc<HealthParams>>,
821 events: Option<&crate::events::UpstreamEmitter>,
822 peak_ewma: Option<&PeakEwmaConfig>,
823 locality: Option<&crate::config::UpstreamLocality>,
824 ) {
825 let prev = self.state.load_full();
826 self.state.store(Arc::new(build_state(
827 endpoints,
828 algorithm,
829 slow_start,
830 Some(&prev),
831 health,
832 events,
833 peak_ewma,
834 locality,
835 )));
836 }
837
838 /// Current passive-health clock reading (Unix-epoch ms).
839 pub fn now_ms(&self) -> u64 {
840 (*self.health_clock.read().expect("health clock poisoned"))()
841 }
842
843 /// Replace the passive-health clock (Unix-epoch milliseconds; must be
844 /// cheap). Intended for tests and other time-controlled setups — the
845 /// same injection pattern as the rate limiter's clock (DW-004).
846 /// Production code keeps the default system clock.
847 pub fn set_health_clock(&self, clock: fn() -> u64) {
848 *self.health_clock.write().expect("health clock poisoned") = clock;
849 }
850
851 /// Picks that served from the full endpoint set because every
852 /// endpoint was ejected (fail-open). See `choose`.
853 pub fn fail_open_picks(&self) -> u64 {
854 self.fail_open_picks.load(Ordering::Relaxed)
855 }
856
857 /// Number of endpoints in the current set.
858 pub fn len(&self) -> usize {
859 self.state.load().endpoints.len()
860 }
861
862 /// Whether the current set is empty (only via unvalidated configs).
863 pub fn is_empty(&self) -> bool {
864 self.len() == 0
865 }
866
867 /// DW-042: the load-balancing algorithm of the current state. The
868 /// discovery task reads this so a live endpoint-set swap uses the
869 /// same algorithm as the initial build.
870 pub fn algorithm(&self) -> LoadBalancer {
871 self.state.load().algorithm
872 }
873
874 /// DW-042: the slow-start window of the current state. The discovery
875 /// task reads this so a live endpoint-set swap applies the same
876 /// slow-start ramp as the initial build.
877 pub fn slow_start(&self) -> Duration {
878 self.state.load().slow_start
879 }
880
881 /// DW-042: the resolved passive-health parameters of the current
882 /// state, if passive health is enabled. The discovery task reads
883 /// this so a live endpoint-set swap creates health trackers with
884 /// the same parameters as the initial build.
885 pub fn health_config(&self) -> Option<Arc<HealthParams>> {
886 self.state.load().health.clone()
887 }
888
889 /// DW-090: the resolved peak-EWMA config of the current state, if
890 /// the algorithm is `peak_ewma`. The discovery task reads this so a
891 /// live endpoint-set swap uses the same tuning (decay tau, initial
892 /// cost) as the initial build.
893 pub fn peak_ewma_config(&self) -> Option<Arc<PeakEwmaConfig>> {
894 self.state.load().peak_ewma_cfg.clone()
895 }
896
897 /// DW-042: the upstream-scoped event emitter, if the balancer was
898 /// built with one. The discovery task passes this to
899 /// `rebuild_with_health_and_events` so new endpoints get
900 /// event-bound health trackers.
901 pub fn events(&self) -> Option<crate::events::UpstreamEmitter> {
902 self.events.clone()
903 }
904
905 /// DW-094 (Ent): set the edge's locality context (region/zone).
906 /// Applied to every upstream's balancer by the dataplane at startup
907 /// (from env vars or edge labels). Read on every pick when the
908 /// upstream has `locality` configured; ignored otherwise.
909 pub fn set_locality_context(&self, ctx: crate::config::LocalityContext) {
910 *self.locality_ctx.write().expect("locality ctx poisoned") = ctx;
911 }
912
913 /// DW-094 (Ent): the resolved locality-aware routing config for the
914 /// current generation, if any. The discovery task reads this so a
915 /// live endpoint-set swap preserves the locality config.
916 pub fn locality_config(&self) -> Option<Arc<crate::config::UpstreamLocality>> {
917 self.state.load().locality.clone()
918 }
919
920 /// `address:port` of endpoint `idx` in the current set.
921 pub fn endpoint(&self, idx: usize) -> Option<(String, u16)> {
922 let s = self.state.load();
923 s.endpoints.get(idx).map(|e| (e.address.clone(), e.port))
924 }
925
926 /// Every endpoint's `address:port` and passive-health tracker in the
927 /// current set, in state order. Trackers are present only when the
928 /// generation carries passive health (DW-012). This is the ACTIVE
929 /// health engine's (DW-013) view: probe loops target a SPECIFIC
930 /// endpoint and report into its live tracker, bypassing balancing.
931 /// Single atomic state load, so address and tracker always correspond.
932 pub fn health_targets(&self) -> Vec<(String, u16, Option<Arc<EndpointHealth>>)> {
933 let s = self.state.load();
934 s.endpoints
935 .iter()
936 .map(|e| (e.address.clone(), e.port, e.health.clone()))
937 .collect()
938 }
939
940 /// Current in-flight count for endpoint `idx`.
941 pub fn inflight(&self, idx: usize) -> u64 {
942 self.state
943 .load()
944 .endpoints
945 .get(idx)
946 .map(|e| e.inflight.load(Ordering::Relaxed))
947 .unwrap_or(0)
948 }
949
950 /// Increment endpoint `idx`'s in-flight counter; returns a guard that
951 /// decrements it on drop. Used by the upstream handle around dispatch.
952 pub fn acquire_inflight(self: &Arc<Self>, idx: usize) -> Option<InflightGuard> {
953 let state = self.state.load_full();
954 state.endpoints.get(idx)?;
955 state.endpoints[idx]
956 .inflight
957 .fetch_add(1, Ordering::Relaxed);
958 Some(InflightGuard { state, idx })
959 }
960
961 /// Record an observed latency for endpoint `idx` (DW-090). Updates
962 /// the endpoint's peak-EWMA cost tracker using the Finagle formula:
963 ///
964 /// - `w = exp(-td / tau)` where `td` is the time since the last
965 /// update and `tau` is the decay window (`peak_ewma.decay_ms`).
966 /// - If `rtt > cost` (the new RTT exceeds the current cost), the
967 /// cost is replaced outright (the "peak" — a slow response is
968 /// remembered in full so the balancer degrades an outlier
969 /// immediately).
970 /// - Otherwise `cost = cost * w + rtt * (1 - w)` (the cost decays
971 /// toward the new RTT).
972 ///
973 /// No-op when the endpoint has no peak-EWMA tracker (the algorithm
974 /// is not `peak_ewma`, or the index is out of range). Lock-free:
975 /// the tracker's `cost_ns` and `stamp` are atomics. The latency is
976 /// recorded for ALL outcomes (success, error, timeout) since even a
977 /// timeout is a latency signal.
978 pub fn record_latency(&self, idx: usize, duration: Duration) {
979 let state = self.state.load();
980 let Some(e) = state.endpoints.get(idx) else {
981 return;
982 };
983 let Some(tracker) = &e.peak_ewma else {
984 return;
985 };
986 let tau_ns = state.peak_ewma_tau_ns.max(1) as f64;
987 let now = system_now_ns();
988 let prev_stamp = tracker.stamp.load(Ordering::Relaxed);
989 let td = now.saturating_sub(prev_stamp) as f64;
990 let w = (-td / tau_ns).exp();
991 let prev_cost = tracker.cost_ns.load(Ordering::Relaxed);
992 let rtt_ns = duration.as_nanos() as u64;
993 let new_cost = if rtt_ns > prev_cost {
994 // Peak: a slow response replaces the cost outright.
995 rtt_ns
996 } else {
997 // Decay toward the new RTT.
998 ((prev_cost as f64) * w + (rtt_ns as f64) * (1.0 - w)) as u64
999 };
1000 tracker.cost_ns.store(new_cost, Ordering::Relaxed);
1001 tracker.stamp.store(now, Ordering::Relaxed);
1002 }
1003
1004 /// The current peak-EWMA cost (nanoseconds) for endpoint `idx`, or
1005 /// `None` when the endpoint has no tracker (the algorithm is not
1006 /// `peak_ewma` or the index is out of range). Test/diagnostic helper.
1007 pub fn peak_ewma_cost(&self, idx: usize) -> Option<u64> {
1008 let state = self.state.load();
1009 state.endpoints.get(idx).and_then(|e| {
1010 e.peak_ewma
1011 .as_ref()
1012 .map(|t| t.cost_ns.load(Ordering::Relaxed))
1013 })
1014 }
1015
1016 /// Pick the endpoint index for one dispatch. `key` is the hash key
1017 /// (client IP) used by `ip_hash`; other algorithms ignore it. Lock-free.
1018 pub fn pick(&self, key: Option<&str>) -> Option<usize> {
1019 let state = self.state.load_full();
1020 self.choose(&state, key)
1021 }
1022
1023 /// Single-load pick returning the index AND the endpoint's
1024 /// `address:port` from the SAME state snapshot (no reload can interleave
1025 /// between pick and resolution). For dispatch paths that do not need
1026 /// an in-flight guard (e.g. TLS passthrough).
1027 pub fn pick_endpoint(&self, key: Option<&str>) -> Option<(usize, String, u16)> {
1028 let state = self.state.load_full();
1029 let idx = self.choose(&state, key)?;
1030 state
1031 .endpoints
1032 .get(idx)
1033 .map(|e| (idx, e.address.clone(), e.port))
1034 }
1035
1036 /// Single-load pick + in-flight acquisition: the algorithm choice, the
1037 /// endpoint resolution, and the counter increment all happen against
1038 /// ONE `ArcSwap` snapshot, so a concurrent rebuild cannot detach the
1039 /// guard from the endpoint it counted (the guard pins that snapshot).
1040 /// Prefer this over separate `pick`/`endpoint`/`acquire_inflight`
1041 /// calls on any dispatch path.
1042 pub fn pick_for_dispatch(self: &Arc<Self>, key: Option<&str>) -> Option<Dispatch> {
1043 let state = self.state.load_full();
1044 let idx = self.choose(&state, key)?;
1045 let e = state.endpoints.get(idx)?;
1046 e.inflight.fetch_add(1, Ordering::Relaxed);
1047 let health = match (&state.health, &e.health) {
1048 (Some(params), Some(tracker)) => Some(HealthDispatch {
1049 tracker: Arc::clone(tracker),
1050 params: Arc::clone(params),
1051 }),
1052 _ => None,
1053 };
1054 Some(Dispatch {
1055 idx,
1056 address: e.address.clone(),
1057 port: e.port,
1058 // PERF: the precomputed authority trio travels with the
1059 // pick (refcount bumps) — the send path formats and parses
1060 // nothing per attempt.
1061 authority: Arc::clone(&e.authority),
1062 http_authority: e.http_authority.clone(),
1063 host_value: e.host_value.clone(),
1064 health,
1065 guard: Some(InflightGuard { state, idx }),
1066 })
1067 }
1068
1069 /// DW-094 (Ent): the result of locality-aware filtering on the
1070 /// candidate set.
1071 fn apply_locality(
1072 &self,
1073 state: &LbState,
1074 loc: &crate::config::UpstreamLocality,
1075 ctx: &crate::config::LocalityContext,
1076 health_cand: &Option<Vec<usize>>,
1077 ) -> LocalityResult {
1078 // Materialize the current candidate set (health-filtered or full).
1079 let base: Vec<usize> = health_cand
1080 .clone()
1081 .unwrap_or_else(|| (0..state.endpoints.len()).collect());
1082 // Step 1: data residency hard constraint. Drop endpoints whose
1083 // region is not in `allowed_regions` (when `enforce_data_residency`
1084 // is true) or is in `denied_regions`. Endpoints with no region
1085 // are always allowed (locality-agnostic).
1086 let residency_filtered: Vec<usize> = base
1087 .iter()
1088 .copied()
1089 .filter(|&i| {
1090 let ep = &state.endpoints[i];
1091 if let Some(ep_region) = ep.region.as_deref() {
1092 if loc.denied_regions.iter().any(|d| d == ep_region) {
1093 return false;
1094 }
1095 }
1096 if loc.enforce_data_residency {
1097 if let Some(ep_region) = ep.region.as_deref() {
1098 if !loc.allowed_regions.iter().any(|a| a == ep_region) {
1099 return false;
1100 }
1101 }
1102 }
1103 true
1104 })
1105 .collect();
1106 // If residency filtering empties the set, the constraint is
1107 // unsatisfiable — deny (residency is a hard constraint).
1108 if residency_filtered.is_empty() && loc.enforce_data_residency {
1109 return LocalityResult::Denied;
1110 }
1111 let after_residency: &[usize] = if residency_filtered.len() < base.len() {
1112 &residency_filtered
1113 } else {
1114 &base
1115 };
1116 // Step 2: locality mode filtering. Partition into local (region
1117 // matches the edge's context) and non-local. Within the local
1118 // set, further prefer endpoints in the same zone when the edge
1119 // has a zone set (zone-level locality).
1120 let local: Vec<usize> = after_residency
1121 .iter()
1122 .copied()
1123 .filter(|&i| ctx.region_matches(state.endpoints[i].region.as_deref()))
1124 .collect();
1125 // Zone refinement: if the edge has a zone and there are
1126 // same-zone endpoints in the local set, restrict to them.
1127 let zone_refined: Vec<usize> = if ctx.zone.is_some() {
1128 let zoned: Vec<usize> = local
1129 .iter()
1130 .copied()
1131 .filter(|&i| ctx.zone_matches(state.endpoints[i].zone.as_deref()))
1132 .collect();
1133 if !zoned.is_empty() && zoned.len() < local.len() {
1134 zoned
1135 } else {
1136 local
1137 }
1138 } else {
1139 local
1140 };
1141 match loc.mode {
1142 crate::config::LocalityMode::Strict => {
1143 if zone_refined.is_empty() {
1144 LocalityResult::Denied
1145 } else if zone_refined.len() < after_residency.len() {
1146 LocalityResult::Restricted(zone_refined)
1147 } else {
1148 LocalityResult::Unchanged(health_cand.clone())
1149 }
1150 }
1151 crate::config::LocalityMode::Prefer => {
1152 if !zone_refined.is_empty() && zone_refined.len() < after_residency.len() {
1153 LocalityResult::Restricted(zone_refined)
1154 } else {
1155 LocalityResult::Unchanged(health_cand.clone())
1156 }
1157 }
1158 crate::config::LocalityMode::Failover => {
1159 if !zone_refined.is_empty() {
1160 if zone_refined.len() < after_residency.len() {
1161 LocalityResult::Restricted(zone_refined)
1162 } else {
1163 LocalityResult::Unchanged(health_cand.clone())
1164 }
1165 } else if residency_filtered.len() < base.len() {
1166 LocalityResult::Restricted(residency_filtered.clone())
1167 } else {
1168 LocalityResult::Unchanged(health_cand.clone())
1169 }
1170 }
1171 }
1172 }
1173
1174 /// Algorithm choice over one pinned snapshot; returns an endpoint
1175 /// index valid in `state`.
1176 ///
1177 /// Passive health (DW-012): when the generation carries health
1178 /// parameters, ejected endpoints leave the candidate set BEFORE the
1179 /// algorithm runs (all algorithms respect the filtered set; ip_hash
1180 /// walks the ring forward to the next healthy owner so keys stay as
1181 /// sticky as ejection allows). When EVERY endpoint is ejected, the
1182 /// candidate set falls back to the full set — fail-open rather than
1183 /// blackhole: a fully-ejected pool serving degraded traffic beats a
1184 /// gateway answering 503 for every request. Fail-open picks are
1185 /// counted ([`UpstreamLb::fail_open_picks`]) so operators can see the
1186 /// degraded state. Half-open endpoints join the candidate set and the
1187 /// endpoint a pick actually SELECTS consumes one trial slot (see
1188 /// `EndpointHealth::is_candidate` / `EndpointHealth::consume_probe`).
1189 ///
1190 /// Allocation discipline: the candidate set is `None` (= every
1191 /// endpoint index, walked by index — no heap allocation) whenever
1192 /// passive health is disabled or the pool fail-opens; only a genuinely
1193 /// filtered health path materializes a candidate `Vec`. The selected
1194 /// endpoint consumes its half-open probe slot on EVERY return path,
1195 /// including the single-candidate shortcut.
1196 fn choose(&self, state: &Arc<LbState>, key: Option<&str>) -> Option<usize> {
1197 if state.endpoints.is_empty() {
1198 return None;
1199 }
1200 let (cand, _health_filtered) = match &state.health {
1201 Some(params) => {
1202 let now = self.now_ms();
1203 let avail: Vec<usize> = state
1204 .endpoints
1205 .iter()
1206 .enumerate()
1207 .filter(|(_, e)| {
1208 e.health
1209 .as_ref()
1210 .is_none_or(|h| h.is_candidate(params, now))
1211 })
1212 .map(|(i, _)| i)
1213 .collect();
1214 if avail.is_empty() {
1215 self.fail_open_picks.fetch_add(1, Ordering::Relaxed);
1216 (None, false)
1217 } else {
1218 let filtered = avail.len() < state.endpoints.len();
1219 (Some(avail), filtered)
1220 }
1221 }
1222 None => (None, false),
1223 };
1224 // DW-094 (Ent): locality-aware filtering. Applied AFTER health
1225 // filtering and BEFORE the algorithm. When the upstream has no
1226 // `locality` config or the edge has no locality context, this is
1227 // a no-op (the OSS behavior). See [`UpstreamLb::apply_locality`]
1228 // for the mode/residency semantics.
1229 let cand = match &state.locality {
1230 Some(loc) => {
1231 let ctx = self.locality_ctx.read().expect("locality ctx poisoned");
1232 if ctx.is_set() {
1233 match self.apply_locality(state, loc, &ctx, &cand) {
1234 LocalityResult::Restricted(set) => Some(set),
1235 LocalityResult::Unchanged(orig) => orig,
1236 LocalityResult::Denied => return None,
1237 }
1238 } else {
1239 cand
1240 }
1241 }
1242 None => cand,
1243 };
1244 // Recompute `filtered`: true when the candidate set is a strict
1245 // subset of the full endpoint set (health-filtered and/or
1246 // locality-filtered). Used by the IpHash ring walk to skip
1247 // ineligible owners.
1248 let filtered = cand
1249 .as_ref()
1250 .is_some_and(|c| c.len() < state.endpoints.len());
1251 // Single candidate: no algorithm to run (random-2 needs two), but
1252 // a half-open selection still spends its probe slot.
1253 let n = cand.as_ref().map_or(state.endpoints.len(), Vec::len);
1254 if n == 1 {
1255 let idx = cand.as_deref().map_or(0, |c| c[0]);
1256 if let Some(h) = &state.endpoints[idx].health {
1257 h.consume_probe();
1258 }
1259 return Some(idx);
1260 }
1261 // Index view of the candidate set: `None` iterates 0..len
1262 // directly (allocation-free); `Some` maps through the slice.
1263 let resolve = |i: usize| cand.as_deref().map_or(i, |c| c[i]);
1264 let idx = match state.algorithm {
1265 LoadBalancer::RoundRobin => smooth_weighted_rr(state, cand.as_deref()),
1266 LoadBalancer::LeastRequests => (0..n)
1267 .map(resolve)
1268 .min_by_key(|&i| (state.endpoints[i].inflight.load(Ordering::Relaxed), i))
1269 .unwrap_or_else(|| resolve(0)),
1270 LoadBalancer::Random => {
1271 let a = (self.next_rand() % n as u64) as usize;
1272 let mut b = (self.next_rand() % (n as u64 - 1)) as usize;
1273 if b >= a {
1274 b += 1; // a != b, uniform over the rest
1275 }
1276 let (ia, ib) = (&state.endpoints[resolve(a)], &state.endpoints[resolve(b)]);
1277 let (fa, fb) = (
1278 ia.inflight.load(Ordering::Relaxed),
1279 ib.inflight.load(Ordering::Relaxed),
1280 );
1281 if fa < fb {
1282 resolve(a)
1283 } else if fb < fa {
1284 resolve(b)
1285 } else {
1286 resolve(a).min(resolve(b)) // ties: lower index, deterministic
1287 }
1288 }
1289 LoadBalancer::IpHash => match key {
1290 Some(k) => {
1291 let h = key_hash(k);
1292 // Membership set for the ring walk, built once per
1293 // choose() when filtering is active: O(1) lookups
1294 // instead of a linear scan per vnode visited.
1295 let eligible_set: Option<Vec<bool>> = filtered.then(|| {
1296 let mut set = vec![false; state.endpoints.len()];
1297 for &i in cand.as_deref().unwrap_or(&[]) {
1298 set[i] = true;
1299 }
1300 set
1301 });
1302 // Walk the ring from the key's hash; when filtering is
1303 // active, skip ejected owners (and wrap) so the key
1304 // lands on the next healthy endpoint instead of a
1305 // black hole.
1306 let eligible = |i: usize| eligible_set.as_ref().is_none_or(|set| set[i]);
1307 state
1308 .ring
1309 .range(h..)
1310 .find(|(_, &i)| eligible(i))
1311 .map(|(_, &i)| i)
1312 .or_else(|| {
1313 state
1314 .ring
1315 .iter()
1316 .find(|(_, &i)| eligible(i))
1317 .map(|(_, &i)| i)
1318 })
1319 .unwrap_or_else(|| smooth_weighted_rr(state, cand.as_deref()))
1320 }
1321 None => smooth_weighted_rr(state, cand.as_deref()),
1322 },
1323 // Maglev (DP-07, #236): O(1) lookup table. Hash the key,
1324 // index into the table, and verify the result is eligible.
1325 // When filtering is active and the table entry is not
1326 // eligible, fall back to linear probing of the table
1327 // (wrapping) to find the next eligible entry. With no key,
1328 // fall back to smooth WRR.
1329 LoadBalancer::Maglev => match key {
1330 Some(k) => {
1331 let h = key_hash(k);
1332 if state.maglev_table.is_empty() {
1333 return smooth_weighted_rr(state, cand.as_deref()).into();
1334 }
1335 let m = state.maglev_table.len() as u64;
1336 let start = (h % m) as usize;
1337 if !filtered {
1338 // Fast path: no filtering, direct lookup.
1339 return state.maglev_table[start].into();
1340 }
1341 // Filtered path: linear probe for the first eligible
1342 // entry (wrapping). At most one full table scan.
1343 let eligible_set: Vec<bool> = {
1344 let mut set = vec![false; state.endpoints.len()];
1345 for &i in cand.as_deref().unwrap_or(&[]) {
1346 set[i] = true;
1347 }
1348 set
1349 };
1350 for offset in 0..state.maglev_table.len() {
1351 let idx = state.maglev_table[(start + offset) % state.maglev_table.len()];
1352 if eligible_set[idx] {
1353 return idx.into();
1354 }
1355 }
1356 // No eligible entry found; fall back to WRR.
1357 smooth_weighted_rr(state, cand.as_deref())
1358 }
1359 None => smooth_weighted_rr(state, cand.as_deref()),
1360 },
1361 // Peak-EWMA (DW-090, Finagle-style): score each candidate by
1362 // `cost_ns * (inflight + 1)` and pick the lowest. The cost
1363 // is the endpoint's atomic EWMA latency tracker; `inflight`
1364 // is the live in-flight count. A slow endpoint with few
1365 // in-flight requests can still win a pick while a fast one
1366 // is saturated. Ties break to the lower index
1367 // (deterministic). When no tracker is present (should not
1368 // happen for a peak_ewma generation — `build_state` creates
1369 // one for every endpoint — but defensive), fall back to
1370 // least-requests over the candidate set.
1371 LoadBalancer::PeakEwma => (0..n)
1372 .map(resolve)
1373 .min_by_key(|&i| {
1374 let cost = state.endpoints[i]
1375 .peak_ewma
1376 .as_ref()
1377 .map(|t| t.cost_ns.load(Ordering::Relaxed))
1378 .unwrap_or(0);
1379 let inflight = state.endpoints[i].inflight.load(Ordering::Relaxed);
1380 // u128 so the product of two u64s cannot overflow.
1381 (cost as u128 * (inflight as u128 + 1), i as u64)
1382 })
1383 .unwrap_or_else(|| resolve(0)),
1384 };
1385 // The SELECTED endpoint spends a half-open probe slot (no-op for
1386 // healthy endpoints; best-effort under races — see
1387 // `EndpointHealth::consume_probe`).
1388 if state.health.is_some() {
1389 if let Some(h) = &state.endpoints[idx].health {
1390 h.consume_probe();
1391 }
1392 }
1393 Some(idx)
1394 }
1395
1396 /// CAS-loop xorshift draw for random-2.
1397 fn next_rand(&self) -> u64 {
1398 loop {
1399 let x = self.rng.load(Ordering::Relaxed);
1400 let nx = xorshift(x);
1401 if self
1402 .rng
1403 .compare_exchange(x, nx, Ordering::Relaxed, Ordering::Relaxed)
1404 .is_ok()
1405 {
1406 return nx;
1407 }
1408 }
1409 }
1410}
1411
1412/// Drops => decrement of the endpoint's in-flight counter. Holds an `Arc`
1413/// to the state it counted in, so a concurrent rebuild never strays the
1414/// decrement onto a different endpoint set.
1415pub struct InflightGuard {
1416 state: Arc<LbState>,
1417 idx: usize,
1418}
1419
1420impl Drop for InflightGuard {
1421 fn drop(&mut self) {
1422 self.state.endpoints[self.idx]
1423 .inflight
1424 .fetch_sub(1, Ordering::Relaxed);
1425 }
1426}
1427
1428/// One dispatch's pick: the endpoint chosen, its `address:port`, the
1429/// passive-health report handle (DW-012), and the in-flight guard for the
1430/// same snapshot the pick came from. Dropping the `Dispatch` (after the
1431/// response headers resolve) releases the guard.
1432pub struct Dispatch {
1433 /// Endpoint index in the snapshot the pick ran against (informational).
1434 pub idx: usize,
1435 /// Picked endpoint's address.
1436 pub address: String,
1437 /// Picked endpoint's port.
1438 pub port: u16,
1439 /// PERF: the endpoint's precomputed dial authority and its parsed
1440 /// HTTP forms, cloned from the [`LbEndpoint`] row at pick time —
1441 /// built once per endpoint at state build, so a dispatch formats
1442 /// and parses nothing. `pub(crate)`: consumed by the upstream send
1443 /// path, not part of the public surface.
1444 pub(crate) authority: Arc<str>,
1445 pub(crate) http_authority: Option<Authority>,
1446 pub(crate) host_value: Option<HeaderValue>,
1447 /// Passive health report handle for the picked endpoint, present only
1448 /// when the upstream configures `health`. The send path reports the
1449 /// outcome (transport error / status >= 500 = failure) when the
1450 /// response headers resolve.
1451 pub health: Option<HealthDispatch>,
1452 guard: Option<InflightGuard>,
1453}
1454
1455impl Dispatch {
1456 /// Release the in-flight guard explicitly. Dropping the `Dispatch`
1457 /// has the same effect; this exists for call sites that want the
1458 /// release point named and to keep the guard field honest.
1459 pub fn release(mut self) {
1460 self.guard.take();
1461 }
1462
1463 /// REL-06 (#217): take the in-flight guard out of the dispatch
1464 /// WITHOUT decrementing the counter. Used when the caller will
1465 /// attach the guard to the response body so the counter is held
1466 /// until the body stream completes (body-completion in-flight
1467 /// accounting). After this call, `release()` and `Drop` are no-ops
1468 /// for the guard.
1469 pub fn take_guard(&mut self) -> Option<InflightGuard> {
1470 self.guard.take()
1471 }
1472}
1473
1474// White-box tests staying in src/ per AGENTS.md: these tests inspect
1475// private balancer internals (`build_state`, `LbState` fields,
1476// `effective_weight`) that are not reachable through the public API.
1477// The public-surface load-balancing tests live in tests/unit/balance.rs.
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 fn eps(specs: &[(&str, u16, u32)]) -> Vec<Endpoint> {
1482 specs
1483 .iter()
1484 .map(|&(a, p, w)| Endpoint {
1485 address: a.to_string(),
1486 port: p,
1487 weight: w,
1488 region: None,
1489 zone: None,
1490 })
1491 .collect()
1492 }
1493 // --- slow start ---------------------------------------------------------
1494
1495 #[test]
1496 fn endpoint_authority_brackets_ipv6_and_precomputes_parseable_forms() {
1497 // PERF precompute contract: the authority is built once per
1498 // endpoint (IPv6 literals bracketed — `::1:8080` is not a
1499 // parseable URI authority, `[::1]:8080` is) and its parsed
1500 // Authority/HeaderValue forms succeed for every address
1501 // validation accepts.
1502 let eps = eps(&[("::1", 8080, 1), ("10.0.0.7", 80, 1)]);
1503 let v6 = LbEndpoint::new(&eps[0]);
1504 assert_eq!(&*v6.authority, "[::1]:8080");
1505 assert!(v6.http_authority.is_some(), "bracketed v6 parses");
1506 assert!(v6.host_value.is_some());
1507 let v4 = LbEndpoint::new(&eps[1]);
1508 assert_eq!(&*v4.authority, "10.0.0.7:80");
1509 assert!(v4.http_authority.is_some());
1510 assert!(v4.host_value.is_some());
1511 // carried_from keeps the trio unchanged for a same-target
1512 // endpoint (no recompute drift across rebuilds).
1513 let carried = LbEndpoint::carried_from(&v6, &eps[0]);
1514 assert_eq!(carried.authority, v6.authority);
1515 }
1516
1517 #[test]
1518 fn slow_start_ramps_effective_weight_from_floor_to_full() {
1519 let spec = eps(&[("a", 1, 1), ("b", 2, 5)]);
1520 let lb = UpstreamLb::new(&spec, LoadBalancer::RoundRobin, Duration::from_secs(10));
1521 // All endpoints just entered: effective weights are the floor (1).
1522 // A full period therefore picks each endpoint exactly once.
1523 let picks: Vec<usize> = (0..2).map(|_| lb.pick(None).unwrap()).collect();
1524 assert_eq!(picks, vec![0, 1]);
1525
1526 // Direct state inspection for the ramp endpoints: freshly entered
1527 // (weight 5) is at the floor; aged past the window is at full.
1528 let fresh = build_state(
1529 &spec,
1530 LoadBalancer::RoundRobin,
1531 Duration::from_secs(10),
1532 None,
1533 None,
1534 None,
1535 None,
1536 None,
1537 );
1538 assert_eq!(fresh.effective_weight(&fresh.endpoints[1]), 1);
1539 let mut aged = build_state(
1540 &spec,
1541 LoadBalancer::RoundRobin,
1542 Duration::from_secs(10),
1543 None,
1544 None,
1545 None,
1546 None,
1547 None,
1548 );
1549 for e in &mut aged.endpoints {
1550 e.entered = Instant::now() - Duration::from_secs(20);
1551 }
1552 assert_eq!(aged.effective_weight(&aged.endpoints[1]), 5);
1553 assert_eq!(aged.effective_weight(&aged.endpoints[0]), 1);
1554
1555 // Slow start disabled (window 0): effective weight is the raw
1556 // configured weight from the first pick on.
1557 let off = build_state(
1558 &spec,
1559 LoadBalancer::RoundRobin,
1560 Duration::ZERO,
1561 None,
1562 None,
1563 None,
1564 None,
1565 None,
1566 );
1567 assert_eq!(off.effective_weight(&off.endpoints[1]), 5);
1568 }
1569
1570 // --- WRR phase across a rebuild (#128) ---------------------------------
1571 //
1572 // White-box (stays in src like the slow-start test): the lost-phase
1573 // race is a pick mutating the OLD state snapshot's `current_weight`
1574 // while a rebuild has already copied state into a new generation.
1575 // `pick` loads and mutates within one call, so the interleave cannot
1576 // be forced through the public API — pinning the shared cell needs
1577 // private access to the pinned `Arc<LbState>` and `choose`.
1578 #[test]
1579 fn wrr_phase_cell_is_shared_across_rebuild() {
1580 let spec = eps(&[("a", 1, 2), ("b", 2, 1)]);
1581 let lb = UpstreamLb::new(&spec, LoadBalancer::RoundRobin, Duration::ZERO);
1582 // A pick holds the OLD snapshot across the rebuild (the shape of
1583 // `pick`/`pick_for_dispatch` running concurrently with a reload).
1584 let old = lb.state.load_full();
1585 lb.rebuild(&spec, LoadBalancer::RoundRobin, Duration::ZERO);
1586 let new = lb.state.load_full();
1587 for i in 0..new.endpoints.len() {
1588 assert!(
1589 Arc::ptr_eq(
1590 &old.endpoints[i].current_weight,
1591 &new.endpoints[i].current_weight
1592 ),
1593 "endpoint {i}: the WRR phase cell must be the SAME allocation \
1594 across a rebuild, not a copied value"
1595 );
1596 }
1597 // The pick against the old snapshot mutates the shared cell: the
1598 // new generation observes the phase step (before #128 the old
1599 // snapshot's step was stranded and the copy went stale).
1600 let idx = lb.choose(&old, None).unwrap();
1601 assert_eq!(
1602 new.endpoints[idx].current_weight.load(Ordering::Relaxed),
1603 old.endpoints[idx].current_weight.load(Ordering::Relaxed),
1604 "a pick in flight against the old snapshot must advance the \
1605 phase the new generation reads"
1606 );
1607 }
1608}