Skip to main content

postrust_proxy/vendored/
backend.rs

1//! Vendored backend management from rpxy-lib: backend/*.rs
2//!
3//! This module provides load balancing and upstream management.
4
5use crate::config::{Backend, LoadBalanceStrategy, Upstream};
6use crate::health::HealthChecker;
7use crate::vendored::types::{PathName, ServerName};
8use dashmap::DashMap;
9use rand::Rng;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::Arc;
12use uuid::Uuid;
13
14/// Load balance context for sticky sessions and weighted selection.
15#[derive(Clone, Debug)]
16pub struct LoadBalanceContext {
17    /// Client IP for sticky sessions
18    pub client_ip: Option<std::net::IpAddr>,
19    /// Cookie value for sticky sessions
20    pub sticky_cookie: Option<String>,
21}
22
23/// Pointer to a selected upstream backend.
24#[derive(Clone, Debug)]
25pub struct PointerToUpstream {
26    /// Index in the backends array
27    pub ptr: usize,
28    /// Context for sticky sessions
29    pub context: Option<String>,
30}
31
32/// Load balancer trait.
33///
34/// A balancer chooses among the backends it is *offered*, not among all of
35/// them. It used to pick an index over the whole list, which the caller then
36/// reduced modulo the number of healthy backends -- so once any backend was
37/// down, weights applied to the wrong backends, and a least-connections pick
38/// named a backend that was not the one it had counted.
39pub trait LoadBalanceWithPointer: Send + Sync {
40    /// Choose one of `eligible`, which are indices into the upstream's
41    /// backends. `None` only when `eligible` is empty.
42    fn get_ptr(
43        &self,
44        ctx: Option<&LoadBalanceContext>,
45        eligible: &[usize],
46    ) -> Option<PointerToUpstream>;
47}
48
49fn pointer(ptr: usize) -> Option<PointerToUpstream> {
50    Some(PointerToUpstream { ptr, context: None })
51}
52
53/// Round-robin load balancer (from rpxy load_balance_main.rs).
54#[derive(Default)]
55pub struct LoadBalanceRoundRobin {
56    ptr: AtomicUsize,
57}
58
59impl LoadBalanceRoundRobin {
60    /// A round-robin balancer.
61    pub fn new() -> Self {
62        Self::default()
63    }
64}
65
66impl LoadBalanceWithPointer for LoadBalanceRoundRobin {
67    fn get_ptr(
68        &self,
69        _ctx: Option<&LoadBalanceContext>,
70        eligible: &[usize],
71    ) -> Option<PointerToUpstream> {
72        if eligible.is_empty() {
73            return None;
74        }
75        // One atomic step, so two concurrent requests cannot both read the
76        // same position and go to the same backend.
77        let turn = self.ptr.fetch_add(1, Ordering::Relaxed);
78        pointer(eligible[turn % eligible.len()])
79    }
80}
81
82/// Random load balancer (from rpxy load_balance_main.rs).
83#[derive(Default)]
84pub struct LoadBalanceRandom;
85
86impl LoadBalanceRandom {
87    /// A uniformly random balancer.
88    pub fn new() -> Self {
89        Self
90    }
91}
92
93impl LoadBalanceWithPointer for LoadBalanceRandom {
94    fn get_ptr(
95        &self,
96        _ctx: Option<&LoadBalanceContext>,
97        eligible: &[usize],
98    ) -> Option<PointerToUpstream> {
99        if eligible.is_empty() {
100            return None;
101        }
102        pointer(eligible[rand::rng().random_range(0..eligible.len())])
103    }
104}
105
106/// Least connections load balancer (our addition).
107pub struct LoadBalanceLeastConn {
108    connections: Arc<DashMap<usize, AtomicUsize>>,
109    /// Where to start looking, so that ties -- which is every pick until
110    /// something counts connections -- rotate rather than all landing on the
111    /// first backend.
112    start: AtomicUsize,
113}
114
115impl LoadBalanceLeastConn {
116    /// A least-connections balancer over `num_upstreams` backends.
117    pub fn new(num_upstreams: usize) -> Self {
118        let connections = Arc::new(DashMap::new());
119        for i in 0..num_upstreams {
120            connections.insert(i, AtomicUsize::new(0));
121        }
122        Self {
123            connections,
124            start: AtomicUsize::new(0),
125        }
126    }
127
128    /// Increment connection count for a backend.
129    pub fn increment(&self, idx: usize) {
130        if let Some(count) = self.connections.get(&idx) {
131            count.fetch_add(1, Ordering::Relaxed);
132        }
133    }
134
135    /// Decrement connection count for a backend.
136    pub fn decrement(&self, idx: usize) {
137        if let Some(count) = self.connections.get(&idx) {
138            count.fetch_sub(1, Ordering::Relaxed);
139        }
140    }
141}
142
143impl LoadBalanceWithPointer for LoadBalanceLeastConn {
144    fn get_ptr(
145        &self,
146        _ctx: Option<&LoadBalanceContext>,
147        eligible: &[usize],
148    ) -> Option<PointerToUpstream> {
149        if eligible.is_empty() {
150            return None;
151        }
152        let start = self.start.fetch_add(1, Ordering::Relaxed);
153        let mut best: Option<(usize, usize)> = None;
154        for offset in 0..eligible.len() {
155            let idx = eligible[(start + offset) % eligible.len()];
156            let conns = self
157                .connections
158                .get(&idx)
159                .map(|c| c.load(Ordering::Relaxed))
160                .unwrap_or(0);
161            if best.is_none_or(|(_, fewest)| conns < fewest) {
162                best = Some((idx, conns));
163            }
164        }
165        best.and_then(|(idx, _)| pointer(idx))
166    }
167}
168
169/// Weighted load balancer (our addition).
170pub struct LoadBalanceWeighted {
171    /// By backend index.
172    weights: Vec<u32>,
173}
174
175impl LoadBalanceWeighted {
176    /// A balancer that picks each backend in proportion to its weight, where
177    /// `weights[i]` belongs to backend `i`.
178    pub fn new(weights: Vec<u32>) -> Self {
179        Self { weights }
180    }
181}
182
183impl LoadBalanceWithPointer for LoadBalanceWeighted {
184    fn get_ptr(
185        &self,
186        _ctx: Option<&LoadBalanceContext>,
187        eligible: &[usize],
188    ) -> Option<PointerToUpstream> {
189        if eligible.is_empty() {
190            return None;
191        }
192        let weight = |idx: usize| u64::from(self.weights.get(idx).copied().unwrap_or(0));
193
194        // The total is over the eligible backends only: a backend that is
195        // down must not keep its share and hand it to whichever neighbour the
196        // arithmetic lands on.
197        let total: u64 = eligible.iter().map(|&idx| weight(idx)).sum();
198
199        // Every eligible weight is zero. `random_range(0..0)` panicked here;
200        // with nothing to tell the backends apart, treat them as equal. A zero
201        // weight alongside non-zero ones still means "no traffic", which is
202        // how one drains a backend.
203        if total == 0 {
204            return pointer(eligible[rand::rng().random_range(0..eligible.len())]);
205        }
206
207        let mut point = rand::rng().random_range(0..total);
208        for &idx in eligible {
209            let w = weight(idx);
210            if point < w {
211                return pointer(idx);
212            }
213            point -= w;
214        }
215        // Unreachable while `point < total`; the last eligible is the honest
216        // answer if it ever is.
217        eligible.last().copied().and_then(pointer)
218    }
219}
220
221/// Load balancer enum (adapted from rpxy).
222pub enum LoadBalance {
223    /// Each backend in turn.
224    RoundRobin(LoadBalanceRoundRobin),
225    /// Uniformly at random.
226    Random(LoadBalanceRandom),
227    /// Whichever backend currently has the fewest connections.
228    LeastConnections(LoadBalanceLeastConn),
229    /// In proportion to each backend's weight.
230    Weighted(LoadBalanceWeighted),
231}
232
233impl LoadBalance {
234    /// Build the balancer a strategy names, sized to `backends`.
235    ///
236    /// Sticky falls back to round-robin: cookie affinity is behind the
237    /// `sticky-cookie` feature and there is nothing to be sticky on without it.
238    pub fn from_strategy(strategy: &LoadBalanceStrategy, backends: &[Backend]) -> Self {
239        let num = backends.len();
240        match strategy {
241            LoadBalanceStrategy::RoundRobin => {
242                LoadBalance::RoundRobin(LoadBalanceRoundRobin::new())
243            }
244            LoadBalanceStrategy::Random => LoadBalance::Random(LoadBalanceRandom::new()),
245            LoadBalanceStrategy::LeastConnections => {
246                LoadBalance::LeastConnections(LoadBalanceLeastConn::new(num))
247            }
248            LoadBalanceStrategy::Weighted => {
249                let weights: Vec<u32> = backends.iter().map(|b| b.weight).collect();
250                LoadBalance::Weighted(LoadBalanceWeighted::new(weights))
251            }
252            LoadBalanceStrategy::Sticky => {
253                // For now, fall back to round-robin. Sticky requires cookie handling.
254                LoadBalance::RoundRobin(LoadBalanceRoundRobin::new())
255            }
256        }
257    }
258
259    /// Choose the next backend among `eligible`, as an index into the
260    /// upstream's list. `None` only when `eligible` is empty.
261    pub fn get_ptr(
262        &self,
263        ctx: Option<&LoadBalanceContext>,
264        eligible: &[usize],
265    ) -> Option<PointerToUpstream> {
266        match self {
267            LoadBalance::RoundRobin(lb) => lb.get_ptr(ctx, eligible),
268            LoadBalance::Random(lb) => lb.get_ptr(ctx, eligible),
269            LoadBalance::LeastConnections(lb) => lb.get_ptr(ctx, eligible),
270            LoadBalance::Weighted(lb) => lb.get_ptr(ctx, eligible),
271        }
272    }
273}
274
275/// Backend application manager (adapted from rpxy backend_main.rs).
276pub struct BackendAppManager {
277    /// Upstreams by ID
278    upstreams: DashMap<Uuid, UpstreamEntry>,
279    /// Route matcher: (host, path_prefix) -> upstream_id
280    routes: DashMap<(ServerName, PathName), Uuid>,
281    /// Health checker reference
282    health_checker: Option<Arc<HealthChecker>>,
283}
284
285struct UpstreamEntry {
286    upstream: Upstream,
287    load_balance: LoadBalance,
288}
289
290impl BackendAppManager {
291    /// An empty manager, with no health checking.
292    pub fn new() -> Self {
293        Self {
294            upstreams: DashMap::new(),
295            routes: DashMap::new(),
296            health_checker: None,
297        }
298    }
299
300    /// Attach a health checker, so unhealthy backends are skipped.
301    pub fn with_health_checker(mut self, checker: Arc<HealthChecker>) -> Self {
302        self.health_checker = Some(checker);
303        self
304    }
305
306    /// Register an upstream.
307    pub fn register_upstream(&self, upstream: Upstream) {
308        let id = upstream.resolved_id();
309        let load_balance = LoadBalance::from_strategy(&upstream.lb_strategy, &upstream.backends);
310        self.upstreams.insert(
311            id,
312            UpstreamEntry {
313                upstream,
314                load_balance,
315            },
316        );
317    }
318
319    /// Register a route.
320    pub fn register_route(&self, host: ServerName, path: PathName, upstream_id: Uuid) {
321        self.routes.insert((host, path), upstream_id);
322    }
323
324    /// Select a backend from an upstream, considering health status.
325    ///
326    /// `None` -- which the caller answers with 503 -- when the upstream is
327    /// disabled or has no enabled backend. `enabled = false` is how an
328    /// operator takes something out of service, and it used to be ignored
329    /// here: a disabled backend kept its share of traffic, and was even the
330    /// one chosen when every backend was unhealthy.
331    pub fn select_backend(
332        &self,
333        upstream_id: Uuid,
334        ctx: Option<&LoadBalanceContext>,
335    ) -> Option<Backend> {
336        let entry = self.upstreams.get(&upstream_id)?;
337        let upstream = &entry.upstream;
338
339        if !upstream.enabled {
340            return None;
341        }
342
343        let enabled: Vec<usize> = upstream
344            .backends
345            .iter()
346            .enumerate()
347            .filter(|(_, b)| b.enabled)
348            .map(|(idx, _)| idx)
349            .collect();
350
351        let healthy: Vec<usize> = enabled
352            .iter()
353            .copied()
354            .filter(
355                |&idx| match (&self.health_checker, upstream.backends[idx].id) {
356                    (Some(checker), Some(id)) => checker.is_healthy(id),
357                    // No health checker or no ID means assume healthy
358                    _ => true,
359                },
360            )
361            .collect();
362
363        // When every enabled backend is unhealthy, keep balancing across all
364        // of them rather than refusing. A health check can be wrong in a way
365        // the traffic is not -- a misconfigured check path, or a dependency
366        // the check exercises and most requests do not -- and when it is,
367        // serving 503 for everything turns a monitoring mistake into an
368        // outage. If the backends really are down, the request fails at the
369        // backend with a 502, which is no worse. Disabled backends stay out
370        // even here: that was a decision, not a measurement.
371        let candidates = if healthy.is_empty() {
372            &enabled
373        } else {
374            &healthy
375        };
376
377        let ptr = entry.load_balance.get_ptr(ctx, candidates)?;
378        upstream.backends.get(ptr.ptr).cloned()
379    }
380
381    /// Get upstream by ID.
382    pub fn get_upstream(&self, id: Uuid) -> Option<Upstream> {
383        self.upstreams.get(&id).map(|e| e.upstream.clone())
384    }
385}
386
387impl Default for BackendAppManager {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::config::{HealthCheckConfig, UpstreamHttpVersion};
397
398    #[test]
399    fn test_round_robin() {
400        let lb = LoadBalanceRoundRobin::new();
401        let all = [0, 1, 2];
402
403        assert_eq!(lb.get_ptr(None, &all).unwrap().ptr, 0);
404        assert_eq!(lb.get_ptr(None, &all).unwrap().ptr, 1);
405        assert_eq!(lb.get_ptr(None, &all).unwrap().ptr, 2);
406        assert_eq!(lb.get_ptr(None, &all).unwrap().ptr, 0); // Wraps around
407    }
408
409    #[test]
410    fn test_random() {
411        let lb = LoadBalanceRandom::new();
412        let eligible = [1, 4, 7];
413
414        // Only ever one of the offered backends.
415        for _ in 0..100 {
416            let ptr = lb.get_ptr(None, &eligible).unwrap().ptr;
417            assert!(eligible.contains(&ptr));
418        }
419    }
420
421    #[test]
422    fn test_weighted() {
423        let lb = LoadBalanceWeighted::new(vec![1, 2, 7]);
424
425        // Run many iterations and check distribution
426        let mut counts = [0u32; 3];
427        for _ in 0..1000 {
428            let ptr = lb.get_ptr(None, &[0, 1, 2]).unwrap().ptr;
429            counts[ptr] += 1;
430        }
431
432        // Backend 2 should get roughly 70% of traffic
433        assert!(counts[2] > counts[0] && counts[2] > counts[1]);
434    }
435
436    #[test]
437    fn all_zero_weights_are_equal_weights_not_a_panic() {
438        // `random_range(0..0)` panicked on every request to such an upstream.
439        let lb = LoadBalanceWeighted::new(vec![0, 0, 0]);
440        let mut seen = [false; 3];
441        for _ in 0..300 {
442            seen[lb.get_ptr(None, &[0, 1, 2]).unwrap().ptr] = true;
443        }
444        assert_eq!(seen, [true, true, true]);
445    }
446
447    #[test]
448    fn weights_are_over_the_eligible_backends() {
449        // Backend 2 carries nearly all the weight but is down. The pick used
450        // to be made over all three and then reduced modulo the healthy
451        // count, which sent the heavy share to whichever survivor it landed
452        // on. Now the survivors split by their own weights.
453        let lb = LoadBalanceWeighted::new(vec![1, 3, 1000]);
454        let mut counts = [0u32; 3];
455        for _ in 0..4000 {
456            counts[lb.get_ptr(None, &[0, 1]).unwrap().ptr] += 1;
457        }
458        assert_eq!(counts[2], 0);
459        // 1:3, with a wide margin for randomness.
460        assert!(counts[1] > counts[0] * 2, "{counts:?}");
461    }
462
463    #[test]
464    fn a_zero_weight_beside_others_gets_nothing() {
465        let lb = LoadBalanceWeighted::new(vec![0, 5]);
466        for _ in 0..200 {
467            assert_eq!(lb.get_ptr(None, &[0, 1]).unwrap().ptr, 1);
468        }
469    }
470
471    #[test]
472    fn no_eligible_backend_is_no_pick() {
473        assert!(LoadBalanceRoundRobin::new().get_ptr(None, &[]).is_none());
474        assert!(LoadBalanceRandom::new().get_ptr(None, &[]).is_none());
475        assert!(LoadBalanceLeastConn::new(2).get_ptr(None, &[]).is_none());
476        assert!(LoadBalanceWeighted::new(vec![1])
477            .get_ptr(None, &[])
478            .is_none());
479    }
480
481    #[test]
482    fn least_connections_rotates_through_ties() {
483        let lb = LoadBalanceLeastConn::new(3);
484        let mut seen = [false; 3];
485        for _ in 0..3 {
486            seen[lb.get_ptr(None, &[0, 1, 2]).unwrap().ptr] = true;
487        }
488        assert_eq!(seen, [true, true, true]);
489
490        lb.increment(0);
491        lb.increment(1);
492        for _ in 0..5 {
493            assert_eq!(lb.get_ptr(None, &[0, 1, 2]).unwrap().ptr, 2);
494        }
495    }
496
497    fn backend(address: &str, enabled: bool) -> Backend {
498        Backend {
499            id: Some(Uuid::new_v4()),
500            address: address.into(),
501            scheme: "http".into(),
502            weight: 1,
503            enabled,
504            http_version: UpstreamHttpVersion::Http11,
505        }
506    }
507
508    fn upstream(backends: Vec<Backend>, enabled: bool) -> Upstream {
509        Upstream {
510            id: Some(Uuid::new_v4()),
511            name: "u".into(),
512            description: None,
513            lb_strategy: LoadBalanceStrategy::RoundRobin,
514            backends,
515            health_check: HealthCheckConfig::default(),
516            enabled,
517        }
518    }
519
520    #[test]
521    fn a_disabled_backend_is_never_selected() {
522        let manager = BackendAppManager::new();
523        let up = upstream(vec![backend("off:1", false), backend("on:1", true)], true);
524        let id = up.resolved_id();
525        manager.register_upstream(up);
526        for _ in 0..10 {
527            assert_eq!(manager.select_backend(id, None).unwrap().address, "on:1");
528        }
529    }
530
531    #[test]
532    fn no_enabled_backend_is_no_backend() {
533        let manager = BackendAppManager::new();
534        let up = upstream(vec![backend("off:1", false), backend("off:2", false)], true);
535        let id = up.resolved_id();
536        manager.register_upstream(up);
537        assert!(manager.select_backend(id, None).is_none());
538    }
539
540    #[test]
541    fn a_disabled_upstream_has_no_backend() {
542        let manager = BackendAppManager::new();
543        let up = upstream(vec![backend("on:1", true)], false);
544        let id = up.resolved_id();
545        manager.register_upstream(up);
546        assert!(manager.select_backend(id, None).is_none());
547    }
548
549    #[test]
550    fn all_unhealthy_falls_back_to_enabled_backends_only() {
551        let checker = Arc::new(HealthChecker::new());
552        let disabled = backend("off:1", false);
553        let a = backend("sick:1", true);
554        let b = backend("sick:2", true);
555        for b in [&disabled, &a, &b] {
556            checker.mark_unhealthy_for_test(b.id.unwrap());
557        }
558
559        let manager = BackendAppManager::new().with_health_checker(checker);
560        let up = upstream(vec![disabled, a, b], true);
561        let id = up.resolved_id();
562        manager.register_upstream(up);
563
564        let mut seen = std::collections::HashSet::new();
565        for _ in 0..10 {
566            seen.insert(manager.select_backend(id, None).unwrap().address);
567        }
568        // Both enabled backends, balanced -- not `backends[0]`, which was the
569        // disabled one.
570        assert_eq!(
571            seen,
572            ["sick:1".to_string(), "sick:2".to_string()]
573                .into_iter()
574                .collect()
575        );
576    }
577
578    #[test]
579    fn a_healthy_backend_is_preferred() {
580        let checker = Arc::new(HealthChecker::new());
581        let sick = backend("sick:1", true);
582        let well = backend("well:1", true);
583        checker.mark_unhealthy_for_test(sick.id.unwrap());
584
585        let manager = BackendAppManager::new().with_health_checker(checker);
586        let up = upstream(vec![sick, well], true);
587        let id = up.resolved_id();
588        manager.register_upstream(up);
589        for _ in 0..10 {
590            assert_eq!(manager.select_backend(id, None).unwrap().address, "well:1");
591        }
592    }
593}