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}