Skip to main content

plecto_control/upstream/
lb.rs

1//! Per-instance load-balancing (ADR 000035): round-robin (default), weighted least-request
2//! (power-of-two-choices), and weighted Maglev consistent hashing.
3
4use std::net::IpAddr;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7
8use crate::maglev::MaglevTable;
9use crate::rng;
10
11use super::instance::UpstreamInstance;
12use super::{UpstreamGroup, now_millis};
13
14/// The request attribute a Maglev upstream hashes for affinity (ADR 000035), resolved from the
15/// manifest `[upstream.hash]`. The fast path turns this into a [`HashInput`] per request.
16#[derive(Debug, Clone)]
17pub enum HashKeySource {
18    /// Hash a named request header's value (the name is stored lower-cased for case-insensitive lookup).
19    Header(String),
20    /// Hash the connection peer's IP address.
21    SourceIp,
22}
23
24/// A request attribute to hash for Maglev affinity (ADR 000035), borrowed so the hot path allocates
25/// nothing: a header value's bytes (borrowed from the request) or the peer IP (hashed as its
26/// canonical octets, not a string). The fast path builds this from a group's [`HashKeySource`].
27/// `Copy` so it can be passed to the initial `pick` and each retry's `pick_excluding` unchanged.
28#[derive(Debug, Clone, Copy)]
29pub enum HashInput<'a> {
30    Bytes(&'a [u8]),
31    Ip(IpAddr),
32}
33
34impl HashInput<'_> {
35    /// The stable 64-bit hash of this key, fed to the Maglev table lookup.
36    fn hash(&self) -> u64 {
37        match self {
38            HashInput::Bytes(b) => crate::hash::hash64(b),
39            HashInput::Ip(IpAddr::V4(a)) => crate::hash::hash64(&a.octets()),
40            HashInput::Ip(IpAddr::V6(a)) => crate::hash::hash64(&a.octets()),
41        }
42    }
43}
44
45/// The compiled per-instance load-balancing state of a group (ADR 000035). `Maglev` carries its
46/// precomputed lookup table; the other two need no extra state (round-robin uses the group's `rr`
47/// cursor, least-request reads the per-instance `in_flight`).
48#[derive(Debug)]
49pub(super) enum LbState {
50    RoundRobin,
51    LeastRequest,
52    Maglev(MaglevTable),
53}
54
55/// A chosen instance plus a guard tracking its load (ADR 000035). For `least_request` the guard
56/// holds the selected instance's incremented active-request count and decrements it on drop — on
57/// EVERY forward return path (success, retry, transport error) and on each retry hand-off, because
58/// replacing the `Pick` drops the previous guard. For round-robin / maglev the guard is a no-op.
59/// `Deref`s to the instance so callers read `address()` / `is_healthy()` directly.
60pub struct Pick {
61    instance: Arc<UpstreamInstance>,
62    _load: InstanceLoad,
63}
64
65impl Pick {
66    /// The chosen instance (for `pick_excluding` / `record_outcome` / passive-failure book-keeping).
67    pub fn instance(&self) -> &Arc<UpstreamInstance> {
68        &self.instance
69    }
70}
71
72impl std::ops::Deref for Pick {
73    type Target = UpstreamInstance;
74    fn deref(&self) -> &UpstreamInstance {
75        &self.instance
76    }
77}
78
79/// RAII guard decrementing an instance's active-request count on drop (ADR 000035). `None` for
80/// algorithms that do not track per-instance load (round-robin / maglev): a zero-cost no-op, so the
81/// default RR hot path gains no atomic.
82pub struct InstanceLoad {
83    instance: Option<Arc<UpstreamInstance>>,
84}
85
86impl Drop for InstanceLoad {
87    fn drop(&mut self) {
88        if let Some(inst) = &self.instance {
89            inst.in_flight.fetch_sub(1, Ordering::Relaxed);
90        }
91    }
92}
93
94/// Whether instance `a` has the lower `(in_flight + 1) / weight` than `b` (ADR 000035) — the
95/// weighted least-request comparison, by integer cross-product so there is no float, and a tie keeps
96/// `a` (the first sampled). `u128` keeps the product overflow-free for any `in_flight` / `weight`.
97fn lower_load(a: &Arc<UpstreamInstance>, b: &Arc<UpstreamInstance>) -> bool {
98    let la = (a.in_flight() as u128 + 1) * b.weight() as u128;
99    let lb = (b.in_flight() as u128 + 1) * a.weight() as u128;
100    la <= lb
101}
102
103impl UpstreamGroup {
104    /// Pick an instance per the upstream's LB algorithm (ADR 000035), or `None` when nothing is
105    /// eligible (the fast path then fails closed 503 — ADR 000017). `key` is the request's hash key
106    /// for `maglev`; round-robin / least-request ignore it, and `None` (no key) falls back to
107    /// round-robin.
108    pub fn pick(&self, key: Option<HashInput<'_>>) -> Option<Pick> {
109        self.pick_dispatch(None, key)
110    }
111
112    /// Pick an instance OTHER than `exclude`, to retry a failed forward on a different instance (ADR
113    /// 000023). Same algorithm dispatch as `pick`; for `maglev` the affinity target is excluded, so
114    /// it falls back to round-robin over the remaining eligible set.
115    pub fn pick_excluding(
116        &self,
117        exclude: &Arc<UpstreamInstance>,
118        key: Option<HashInput<'_>>,
119    ) -> Option<Pick> {
120        self.pick_dispatch(Some(exclude), key)
121    }
122
123    fn pick_dispatch(
124        &self,
125        exclude: Option<&Arc<UpstreamInstance>>,
126        key: Option<HashInput<'_>>,
127    ) -> Option<Pick> {
128        // One endpoint-set snapshot per pick: a concurrent DNS re-resolution swap (ADR 000017 /
129        // periodic-DNS discovery) never desyncs the instance list from the Maglev table.
130        let ep = self.endpoints.load();
131        match &ep.lb {
132            LbState::RoundRobin => self.round_robin_pick(&ep.instances, exclude),
133            LbState::LeastRequest => self.least_request_pick(&ep.instances, exclude),
134            LbState::Maglev(table) => self.maglev_pick(table, &ep.instances, exclude, key),
135        }
136    }
137
138    /// The clock context for eligibility: whether outlier detection is on and, if so, `now` in ms.
139    /// Read once per pick, and the clock only when the policy is enabled, so a disabled policy keeps
140    /// the pre-000032 cost (a single lock-free `is_healthy` read).
141    fn eligibility_ctx(&self) -> (bool, u64) {
142        let check_outlier = self.outlier_enabled();
143        let now_ms = if check_outlier { now_millis() } else { 0 };
144        (check_outlier, now_ms)
145    }
146
147    /// Whether `inst` may serve this request: healthy, not outlier-ejected, and not the retry
148    /// `exclude`.
149    fn is_eligible(
150        &self,
151        inst: &Arc<UpstreamInstance>,
152        exclude: Option<&Arc<UpstreamInstance>>,
153        (check_outlier, now_ms): (bool, u64),
154    ) -> bool {
155        inst.is_healthy()
156            && (!check_outlier || !inst.is_outlier_ejected(now_ms))
157            && exclude.is_none_or(|ex| !Arc::ptr_eq(inst, ex))
158    }
159
160    /// Round-robin over the *eligible set* (ADR 000024): count the eligible, advance the cursor mod
161    /// that count, index into them. An ejected/excluded instance's slot is never absorbed by its
162    /// neighbour (degraded distribution stays even instead of skewing ~1:2). Two allocation-free
163    /// passes; if an instance flips between them we return the last eligible seen rather than a
164    /// spurious `None`. No per-instance load is metered — round-robin is the zero-overhead default.
165    pub(super) fn round_robin_pick(
166        &self,
167        instances: &[Arc<UpstreamInstance>],
168        exclude: Option<&Arc<UpstreamInstance>>,
169    ) -> Option<Pick> {
170        let ctx = self.eligibility_ctx();
171        let eligible = instances
172            .iter()
173            .filter(|i| self.is_eligible(i, exclude, ctx))
174            .count();
175        if eligible == 0 {
176            return None;
177        }
178        let target = self.rr.fetch_add(1, Ordering::Relaxed) % eligible;
179        let mut seen = 0;
180        let mut last = None;
181        for inst in instances {
182            if self.is_eligible(inst, exclude, ctx) {
183                last = Some(inst);
184                if seen == target {
185                    return Some(self.unmetered_pick(inst.clone()));
186                }
187                seen += 1;
188            }
189        }
190        last.cloned().map(|i| self.unmetered_pick(i))
191    }
192
193    /// Weighted least-request via power-of-two-choices (ADR 000035): sample two distinct eligible
194    /// instances and forward to the one with the smaller `(in_flight + 1) / weight` (compared by
195    /// integer cross-product, no float; `+1` lets weight bias even idle instances). Two passes over
196    /// the small instance list. The selected instance's load is metered (incremented now, decremented
197    /// when the returned `Pick` drops — across the retry hand-off too).
198    fn least_request_pick(
199        &self,
200        instances: &[Arc<UpstreamInstance>],
201        exclude: Option<&Arc<UpstreamInstance>>,
202    ) -> Option<Pick> {
203        let ctx = self.eligibility_ctx();
204        let n = instances
205            .iter()
206            .filter(|i| self.is_eligible(i, exclude, ctx))
207            .count();
208        if n == 0 {
209            return None;
210        }
211        if n == 1 {
212            let Some(only) = instances.iter().find(|i| self.is_eligible(i, exclude, ctx)) else {
213                // Flipped ineligible since the count pass — same race-fallback as below.
214                return self.round_robin_pick(instances, exclude);
215            };
216            return Some(self.metered_pick(only.clone()));
217        }
218        let (a, b) = rng::two_distinct_below(n as u32);
219        let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
220        // One pass capturing the lo-th and hi-th eligible instances by ordinal.
221        let mut cand_lo = None;
222        let mut cand_hi = None;
223        let mut seen = 0u32;
224        for inst in instances {
225            if self.is_eligible(inst, exclude, ctx) {
226                if seen == lo {
227                    cand_lo = Some(inst);
228                }
229                if seen == hi {
230                    cand_hi = Some(inst);
231                }
232                seen += 1;
233            }
234        }
235        // An instance can flip ineligible between the count pass and the ordinal capture; other
236        // eligible instances may remain, so a missed capture falls back to round-robin (which
237        // tolerates the same race with its `last` fallback) instead of a spurious fail-closed
238        // `None` → 503.
239        let (Some(cand_lo), Some(cand_hi)) = (cand_lo, cand_hi) else {
240            return self.round_robin_pick(instances, exclude);
241        };
242        // Restore draw order so a load tie keeps the FIRST sampled instance (uniform over the
243        // eligible set) — keeping min(a, b) would bias ties toward low ordinals and starve the last.
244        let (x, y) = if a <= b {
245            (cand_lo, cand_hi)
246        } else {
247            (cand_hi, cand_lo)
248        };
249        let chosen = if lower_load(x, y) { x } else { y };
250        Some(self.metered_pick(chosen.clone()))
251    }
252
253    /// Consistent hashing via the Maglev table (ADR 000035): map the request key to a stable instance
254    /// for affinity. When the primary is ineligible (unhealthy / outlier-ejected / the retry
255    /// `exclude`) or there is no key, fall back to the eligible-set round-robin (best-effort affinity,
256    /// fail-soft). No per-instance load is metered (selection is by hash, not load).
257    fn maglev_pick(
258        &self,
259        table: &MaglevTable,
260        instances: &[Arc<UpstreamInstance>],
261        exclude: Option<&Arc<UpstreamInstance>>,
262        key: Option<HashInput<'_>>,
263    ) -> Option<Pick> {
264        if let Some(k) = key {
265            let ctx = self.eligibility_ctx();
266            if let Some(idx) = table.lookup(k.hash())
267                && let Some(inst) = instances.get(idx)
268                && self.is_eligible(inst, exclude, ctx)
269            {
270                return Some(self.unmetered_pick(inst.clone()));
271            }
272        }
273        // No key, or the affinity target can't serve → fall back to round-robin over the eligible set.
274        self.round_robin_pick(instances, exclude)
275    }
276
277    /// Wrap an instance in a `Pick` with NO load metering (round-robin / maglev).
278    fn unmetered_pick(&self, instance: Arc<UpstreamInstance>) -> Pick {
279        Pick {
280            instance,
281            _load: InstanceLoad { instance: None },
282        }
283    }
284
285    /// Wrap an instance in a `Pick` that meters its load (least-request): increment its active-request
286    /// count now and hand back a guard that decrements it on drop.
287    fn metered_pick(&self, instance: Arc<UpstreamInstance>) -> Pick {
288        instance.in_flight.fetch_add(1, Ordering::Relaxed);
289        Pick {
290            instance: instance.clone(),
291            _load: InstanceLoad {
292                instance: Some(instance),
293            },
294        }
295    }
296
297    /// Whether this upstream has at least one eligible instance (healthy and not outlier-ejected).
298    /// A cursor-free, allocation-free probe the weighted traffic split (ADR 000034) uses to skip a
299    /// backend whose group can serve nothing (renormalize over healthy). Same eligibility as the
300    /// pick path minus the retry `exclude`; a `false` here means a `pick` would return `None`.
301    pub fn has_eligible(&self) -> bool {
302        let (check_outlier, now_ms) = self.eligibility_ctx();
303        self.endpoints
304            .load()
305            .instances
306            .iter()
307            .any(|inst| inst.is_healthy() && (!check_outlier || !inst.is_outlier_ejected(now_ms)))
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use std::collections::HashSet;
314
315    use super::*;
316    use crate::manifest::{
317        AddressSpec, CircuitBreaker, HashConfig, HashKeyKind, HealthConfig, LbAlgorithm,
318        OutlierDetection, Upstream, WeightedAddress,
319    };
320    use crate::upstream::UpstreamRegistry;
321
322    fn health(healthy_threshold: u32, unhealthy_threshold: u32) -> HealthConfig {
323        HealthConfig {
324            path: "/healthz".to_string(),
325            interval_ms: 100,
326            timeout_ms: 50,
327            healthy_threshold,
328            unhealthy_threshold,
329            port: None,
330        }
331    }
332
333    fn upstream(name: &str, addrs: &[&str], h: HealthConfig) -> Upstream {
334        Upstream {
335            name: name.to_string(),
336            addresses: addrs
337                .iter()
338                .map(|s| AddressSpec::Bare(s.to_string()))
339                .collect(),
340            lb_algorithm: LbAlgorithm::RoundRobin,
341            hash: None,
342            tls: None,
343            resolve_interval_ms: 0,
344            health: h,
345            request_timeout_ms: 30_000,
346            max_retries: 1,
347            overall_timeout_ms: 0,
348            circuit_breaker: CircuitBreaker::default(),
349            outlier_detection: OutlierDetection::default(),
350        }
351    }
352
353    /// Resolve a `Pick`'s address (the common assertion after the `Pick` return type, ADR 000035).
354    fn addr_of(p: &Pick) -> String {
355        p.address().to_string()
356    }
357
358    #[test]
359    fn pick_excluding_returns_a_different_healthy_instance_or_none() {
360        // ADR 000023: a retry must land on a DIFFERENT instance; when the failed one is the only
361        // healthy member there is nothing to retry onto.
362        let reg = UpstreamRegistry::new();
363        reg.reconcile(
364            &[upstream(
365                "pool",
366                &["127.0.0.1:9000", "127.0.0.1:9001"],
367                health(1, 3),
368            )],
369            std::path::Path::new("."),
370        )
371        .unwrap();
372        let group = reg.group("pool").unwrap();
373        // promote both (cold-start: one success each).
374        group.endpoints().instances[0].record_probe_success();
375        group.endpoints().instances[1].record_probe_success();
376
377        let a = group.endpoints().instances[0].clone();
378        let other = group
379            .pick_excluding(&a, None)
380            .expect("a different healthy instance exists");
381        assert!(
382            Arc::ptr_eq(other.instance(), &group.endpoints().instances[1]),
383            "pick_excluding skips the excluded instance"
384        );
385
386        // eject instance[1] (unhealthy_threshold = 3) → `a` is the only healthy one left.
387        for _ in 0..3 {
388            group.endpoints().instances[1].record_probe_failure();
389        }
390        assert!(
391            !group.endpoints().instances[1].is_healthy(),
392            "instance[1] is ejected"
393        );
394        assert!(
395            group.pick_excluding(&a, None).is_none(),
396            "the only healthy instance can't be retried around"
397        );
398    }
399
400    #[test]
401    fn round_robin_distributes_over_healthy_only() {
402        let reg = UpstreamRegistry::new();
403        reg.reconcile(
404            &[upstream("u", &["a:1", "b:2", "c:3"], health(1, 1))],
405            std::path::Path::new("."),
406        )
407        .unwrap();
408        let g = reg.group("u").unwrap();
409
410        assert!(
411            g.pick(None).is_none(),
412            "all pessimistic → no pick (fail-closed)"
413        );
414
415        // make a and c healthy, leave b unhealthy
416        g.endpoints().instances[0].record_probe_success();
417        g.endpoints().instances[2].record_probe_success();
418
419        let mut seen = HashSet::new();
420        for _ in 0..6 {
421            seen.insert(g.pick(None).unwrap().address().to_string());
422        }
423        assert_eq!(
424            seen,
425            HashSet::from(["a:1".to_string(), "c:3".to_string()]),
426            "round-robin only ever returns the healthy instances"
427        );
428    }
429
430    #[test]
431    fn round_robin_is_even_over_the_healthy_set_when_degraded() {
432        // With a MIDDLE instance ejected, the rotation must split evenly over whoever is left.
433        // The old forward-scan-from-cursor handed the dead instance's slot to its neighbour, so
434        // `[a, b(down), c]` skewed a:c to ~1:2. Rotating over the healthy SET removes that.
435        let reg = UpstreamRegistry::new();
436        reg.reconcile(
437            &[upstream("u", &["a:1", "b:2", "c:3"], health(1, 1))],
438            std::path::Path::new("."),
439        )
440        .unwrap();
441        let g = reg.group("u").unwrap();
442        g.endpoints().instances[0].record_probe_success(); // a:1 healthy
443        g.endpoints().instances[2].record_probe_success(); // c:3 healthy, b:2 stays ejected
444
445        let mut a = 0u32;
446        let mut c = 0u32;
447        for _ in 0..600 {
448            match g.pick(None).unwrap().address() {
449                "a:1" => a += 1,
450                "c:3" => c += 1,
451                other => panic!("picked a down/unknown instance: {other}"),
452            }
453        }
454        assert_eq!(
455            a, c,
456            "degraded round-robin must split evenly over the healthy set (was ~1:2)"
457        );
458    }
459
460    #[test]
461    fn reconcile_carries_the_round_robin_cursor() {
462        // A reload must not reset the cursor to 0, or the first post-reload pick always lands on
463        // the head of the rotation — an index-0 bias under frequent reloads.
464        let reg = UpstreamRegistry::new();
465        reg.reconcile(
466            &[upstream("u", &["a:1", "b:2", "c:3"], health(1, 3))],
467            std::path::Path::new("."),
468        )
469        .unwrap();
470        let g0 = reg.group("u").unwrap();
471        for i in 0..3 {
472            g0.endpoints().instances[i].record_probe_success(); // all three healthy
473        }
474        // advance the cursor two steps: a:1, b:2 (cursor now at 2)
475        assert_eq!(g0.pick(None).unwrap().address(), "a:1");
476        assert_eq!(g0.pick(None).unwrap().address(), "b:2");
477
478        // reload with the SAME upstream + health policy → instances and health are preserved
479        reg.reconcile(
480            &[upstream("u", &["a:1", "b:2", "c:3"], health(1, 3))],
481            std::path::Path::new("."),
482        )
483        .unwrap();
484        let g1 = reg.group("u").unwrap();
485        assert!(
486            g1.endpoints().instances.iter().all(|i| i.is_healthy()),
487            "health survives an unchanged-policy reload (ADR 000017)"
488        );
489        assert_eq!(
490            g1.pick(None).unwrap().address(),
491            "c:3",
492            "the cursor carried across reload (would be a:1 if reset to 0)"
493        );
494    }
495
496    /// A healthy upstream group with the given instances and LB config (ADR 000035).
497    fn lb_group(
498        addresses: Vec<AddressSpec>,
499        algo: LbAlgorithm,
500        hash: Option<HashConfig>,
501    ) -> Arc<UpstreamGroup> {
502        let reg = UpstreamRegistry::new();
503        reg.reconcile(
504            &[Upstream {
505                name: "u".to_string(),
506                addresses,
507                lb_algorithm: algo,
508                hash,
509                tls: None,
510                resolve_interval_ms: 0,
511                health: health(1, 1),
512                request_timeout_ms: 30_000,
513                max_retries: 0,
514                overall_timeout_ms: 0,
515                circuit_breaker: CircuitBreaker::default(),
516                outlier_detection: OutlierDetection::default(),
517            }],
518            std::path::Path::new("."),
519        )
520        .unwrap();
521        let g = reg.group("u").unwrap();
522        for inst in &g.endpoints().instances {
523            inst.record_probe_success(); // cold-start: all healthy
524        }
525        g
526    }
527
528    fn bare(addrs: &[&str]) -> Vec<AddressSpec> {
529        addrs
530            .iter()
531            .map(|s| AddressSpec::Bare(s.to_string()))
532            .collect()
533    }
534
535    #[test]
536    fn least_request_avoids_the_busier_instance() {
537        // With two instances, P2C compares both, so it deterministically routes to the one with the
538        // lower (in_flight + 1) / weight. Holding a pick inflates its in-flight; the next pick must
539        // then avoid it.
540        let g = lb_group(bare(&["a:1", "b:2"]), LbAlgorithm::LeastRequest, None);
541        let p1 = g.pick(None).unwrap();
542        let busy = addr_of(&p1);
543        let p2 = g.pick(None).unwrap();
544        assert_ne!(
545            addr_of(&p2),
546            busy,
547            "least-request must avoid the instance already carrying a request"
548        );
549    }
550
551    #[test]
552    fn least_request_weight_biases_toward_higher_capacity() {
553        // Both idle, so (0+1)/weight decides: the weight-3 instance (ratio 1/3) beats the weight-1
554        // (ratio 1/1). With two instances P2C compares both, so the idle pick is deterministic.
555        let g = lb_group(
556            vec![
557                AddressSpec::Bare("small".to_string()),
558                AddressSpec::Weighted(WeightedAddress {
559                    address: "big".to_string(),
560                    weight: 3,
561                }),
562            ],
563            LbAlgorithm::LeastRequest,
564            None,
565        );
566        assert_eq!(
567            addr_of(&g.pick(None).unwrap()),
568            "big",
569            "a higher-weight idle instance is preferred"
570        );
571    }
572
573    #[test]
574    fn least_request_meters_in_flight_and_releases_on_drop() {
575        let g = lb_group(bare(&["a:1", "b:2"]), LbAlgorithm::LeastRequest, None);
576        let total = |g: &UpstreamGroup| -> usize {
577            g.endpoints().instances.iter().map(|i| i.in_flight()).sum()
578        };
579        assert_eq!(total(&g), 0);
580        {
581            let _p = g.pick(None).unwrap();
582            assert_eq!(total(&g), 1, "one in-flight while the Pick is held");
583            let _q = g.pick(None).unwrap();
584            assert_eq!(total(&g), 2, "two in-flight while both Picks are held");
585        }
586        assert_eq!(
587            total(&g),
588            0,
589            "active-request counts released when the Picks drop"
590        );
591    }
592
593    #[test]
594    fn least_request_tie_break_covers_every_instance() {
595        // All instances idle → every P2C comparison is a load tie. The tie-break must keep the
596        // first *sampled* instance (uniform over the eligible set), not min(a, b): that bias
597        // would starve the last ordinal entirely (min of two distinct indices is never n-1).
598        let g = lb_group(
599            bare(&["a:1", "b:2", "c:3", "d:4"]),
600            LbAlgorithm::LeastRequest,
601            None,
602        );
603        let mut hits = [0u32; 4];
604        for _ in 0..4000 {
605            let p = g.pick(None).unwrap(); // dropped per iteration, so loads stay tied at zero
606            let idx = g
607                .endpoints()
608                .instances
609                .iter()
610                .position(|i| Arc::ptr_eq(i, p.instance()))
611                .unwrap();
612            hits[idx] += 1;
613        }
614        for (i, &h) in hits.iter().enumerate() {
615            assert!(h > 500, "instance {i} starved under idle ties: {hits:?}");
616        }
617    }
618
619    #[test]
620    fn least_request_fails_closed_when_all_unhealthy() {
621        let g = lb_group(bare(&["a:1", "b:2"]), LbAlgorithm::LeastRequest, None);
622        for inst in &g.endpoints().instances {
623            inst.record_probe_failure(); // unhealthy_threshold = 1 → ejected
624        }
625        assert!(g.pick(None).is_none(), "no eligible instance → None → 503");
626    }
627
628    fn header_hash(m: u32) -> Option<HashConfig> {
629        Some(HashConfig {
630            key: HashKeyKind::Header,
631            header: Some("x-user".to_string()),
632            table_size: m,
633        })
634    }
635
636    #[test]
637    fn maglev_pins_a_key_to_one_instance() {
638        let g = lb_group(
639            bare(&["a:1", "b:2", "c:3"]),
640            LbAlgorithm::Maglev,
641            header_hash(97),
642        );
643        let key = HashInput::Bytes(b"session-42");
644        let pinned = addr_of(&g.pick(Some(key)).unwrap());
645        for _ in 0..30 {
646            assert_eq!(
647                addr_of(&g.pick(Some(key)).unwrap()),
648                pinned,
649                "the same key always resolves to the same instance (affinity)"
650            );
651        }
652    }
653
654    #[test]
655    fn maglev_spreads_distinct_keys() {
656        let g = lb_group(
657            bare(&["a:1", "b:2", "c:3"]),
658            LbAlgorithm::Maglev,
659            header_hash(97),
660        );
661        let mut seen = HashSet::new();
662        for i in 0..300 {
663            let key = format!("user-{i}");
664            seen.insert(addr_of(
665                &g.pick(Some(HashInput::Bytes(key.as_bytes()))).unwrap(),
666            ));
667        }
668        assert_eq!(seen.len(), 3, "distinct keys reach every instance");
669    }
670
671    #[test]
672    fn maglev_falls_back_to_round_robin_without_a_key() {
673        // No key (e.g. the configured header is absent) → round-robin over the eligible set, never None
674        // while one is up.
675        let g = lb_group(bare(&["a:1", "b:2"]), LbAlgorithm::Maglev, header_hash(97));
676        let mut seen = HashSet::new();
677        for _ in 0..10 {
678            seen.insert(addr_of(&g.pick(None).unwrap()));
679        }
680        assert_eq!(
681            seen.len(),
682            2,
683            "keyless maglev round-robins across instances"
684        );
685    }
686
687    #[test]
688    fn maglev_falls_back_when_the_primary_is_unhealthy() {
689        let g = lb_group(
690            bare(&["a:1", "b:2", "c:3"]),
691            LbAlgorithm::Maglev,
692            header_hash(97),
693        );
694        let key = HashInput::Bytes(b"sticky-key");
695        let primary = addr_of(&g.pick(Some(key)).unwrap());
696        // eject the affinity target (unhealthy_threshold = 1).
697        g.endpoints()
698            .instances
699            .iter()
700            .find(|i| i.address() == primary)
701            .unwrap()
702            .record_probe_failure();
703        let alt = g.pick(Some(key)).unwrap();
704        assert_ne!(
705            addr_of(&alt),
706            primary,
707            "a down primary falls back to another healthy instance"
708        );
709        assert!(alt.is_healthy());
710    }
711
712    #[test]
713    fn maglev_excludes_on_retry() {
714        // A retry must land elsewhere even for the affinity target (ADR 000023 semantics preserved).
715        let g = lb_group(
716            bare(&["a:1", "b:2", "c:3"]),
717            LbAlgorithm::Maglev,
718            header_hash(97),
719        );
720        let key = HashInput::Bytes(b"retry-key");
721        let first = g.pick(Some(key)).unwrap();
722        let retried = g.pick_excluding(first.instance(), Some(key)).unwrap();
723        assert_ne!(
724            addr_of(&retried),
725            addr_of(&first),
726            "retry skips the just-tried instance"
727        );
728    }
729}