1use 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#[derive(Debug, Clone)]
17pub enum HashKeySource {
18 Header(String),
20 SourceIp,
22}
23
24#[derive(Debug, Clone, Copy)]
29pub enum HashInput<'a> {
30 Bytes(&'a [u8]),
31 Ip(IpAddr),
32}
33
34impl HashInput<'_> {
35 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#[derive(Debug)]
49pub(super) enum LbState {
50 RoundRobin,
51 LeastRequest,
52 Maglev(MaglevTable),
53}
54
55pub struct Pick {
61 instance: Arc<UpstreamInstance>,
62 _load: InstanceLoad,
63}
64
65impl Pick {
66 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
79pub 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
94fn 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 pub fn pick(&self, key: Option<HashInput<'_>>) -> Option<Pick> {
109 self.pick_dispatch(None, key)
110 }
111
112 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 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 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 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 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 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 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 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 let (Some(cand_lo), Some(cand_hi)) = (cand_lo, cand_hi) else {
240 return self.round_robin_pick(instances, exclude);
241 };
242 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 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 self.round_robin_pick(instances, exclude)
275 }
276
277 fn unmetered_pick(&self, instance: Arc<UpstreamInstance>) -> Pick {
279 Pick {
280 instance,
281 _load: InstanceLoad { instance: None },
282 }
283 }
284
285 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 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 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 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 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 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 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 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(); g.endpoints().instances[2].record_probe_success(); 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 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(); }
474 assert_eq!(g0.pick(None).unwrap().address(), "a:1");
476 assert_eq!(g0.pick(None).unwrap().address(), "b:2");
477
478 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 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(); }
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 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 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 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(); 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(); }
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 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 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 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}