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.
33pub trait LoadBalanceWithPointer: Send + Sync {
34    /// Get pointer to the next upstream backend.
35    fn get_ptr(&self, ctx: Option<&LoadBalanceContext>) -> PointerToUpstream;
36}
37
38/// Round-robin load balancer (from rpxy load_balance_main.rs).
39pub struct LoadBalanceRoundRobin {
40    ptr: Arc<AtomicUsize>,
41    num_upstreams: usize,
42}
43
44impl LoadBalanceRoundRobin {
45    /// A round-robin balancer over `num_upstreams` backends.
46    pub fn new(num_upstreams: usize) -> Self {
47        Self {
48            ptr: Arc::new(AtomicUsize::new(0)),
49            num_upstreams,
50        }
51    }
52}
53
54impl LoadBalanceWithPointer for LoadBalanceRoundRobin {
55    fn get_ptr(&self, _ctx: Option<&LoadBalanceContext>) -> PointerToUpstream {
56        let current = self.ptr.load(Ordering::Relaxed);
57        let next = (current + 1) % self.num_upstreams;
58        self.ptr.store(next, Ordering::Relaxed);
59
60        PointerToUpstream {
61            ptr: current,
62            context: None,
63        }
64    }
65}
66
67/// Random load balancer (from rpxy load_balance_main.rs).
68pub struct LoadBalanceRandom {
69    num_upstreams: usize,
70}
71
72impl LoadBalanceRandom {
73    /// A uniformly random balancer over `num_upstreams` backends.
74    pub fn new(num_upstreams: usize) -> Self {
75        Self { num_upstreams }
76    }
77}
78
79impl LoadBalanceWithPointer for LoadBalanceRandom {
80    fn get_ptr(&self, _ctx: Option<&LoadBalanceContext>) -> PointerToUpstream {
81        let ptr = rand::rng().random_range(0..self.num_upstreams);
82        PointerToUpstream { ptr, context: None }
83    }
84}
85
86/// Least connections load balancer (our addition).
87pub struct LoadBalanceLeastConn {
88    connections: Arc<DashMap<usize, AtomicUsize>>,
89    num_upstreams: usize,
90}
91
92impl LoadBalanceLeastConn {
93    pub fn new(num_upstreams: usize) -> Self {
94        let connections = Arc::new(DashMap::new());
95        for i in 0..num_upstreams {
96            connections.insert(i, AtomicUsize::new(0));
97        }
98        Self {
99            connections,
100            num_upstreams,
101        }
102    }
103
104    /// Increment connection count for a backend.
105    pub fn increment(&self, idx: usize) {
106        if let Some(count) = self.connections.get(&idx) {
107            count.fetch_add(1, Ordering::Relaxed);
108        }
109    }
110
111    /// Decrement connection count for a backend.
112    pub fn decrement(&self, idx: usize) {
113        if let Some(count) = self.connections.get(&idx) {
114            count.fetch_sub(1, Ordering::Relaxed);
115        }
116    }
117}
118
119impl LoadBalanceWithPointer for LoadBalanceLeastConn {
120    fn get_ptr(&self, _ctx: Option<&LoadBalanceContext>) -> PointerToUpstream {
121        let mut min_idx = 0;
122        let mut min_conns = usize::MAX;
123
124        for i in 0..self.num_upstreams {
125            if let Some(count) = self.connections.get(&i) {
126                let conns = count.load(Ordering::Relaxed);
127                if conns < min_conns {
128                    min_conns = conns;
129                    min_idx = i;
130                }
131            }
132        }
133
134        PointerToUpstream {
135            ptr: min_idx,
136            context: None,
137        }
138    }
139}
140
141/// Weighted load balancer (our addition).
142pub struct LoadBalanceWeighted {
143    weights: Vec<u32>,
144    total_weight: u32,
145}
146
147impl LoadBalanceWeighted {
148    pub fn new(weights: Vec<u32>) -> Self {
149        let total_weight = weights.iter().sum();
150        Self {
151            weights,
152            total_weight,
153        }
154    }
155}
156
157impl LoadBalanceWithPointer for LoadBalanceWeighted {
158    fn get_ptr(&self, _ctx: Option<&LoadBalanceContext>) -> PointerToUpstream {
159        let mut rng = rand::rng();
160        let random = rng.random_range(0..self.total_weight);
161
162        let mut cumulative = 0;
163        for (idx, weight) in self.weights.iter().enumerate() {
164            cumulative += weight;
165            if random < cumulative {
166                return PointerToUpstream {
167                    ptr: idx,
168                    context: None,
169                };
170            }
171        }
172
173        // Fallback to last backend
174        PointerToUpstream {
175            ptr: self.weights.len() - 1,
176            context: None,
177        }
178    }
179}
180
181/// Load balancer enum (adapted from rpxy).
182pub enum LoadBalance {
183    /// Each backend in turn.
184    RoundRobin(LoadBalanceRoundRobin),
185    /// Uniformly at random.
186    Random(LoadBalanceRandom),
187    /// Whichever backend currently has the fewest connections.
188    LeastConnections(LoadBalanceLeastConn),
189    /// In proportion to each backend's weight.
190    Weighted(LoadBalanceWeighted),
191}
192
193impl LoadBalance {
194    /// Build the balancer a strategy names, sized to `backends`.
195    ///
196    /// Sticky falls back to round-robin: cookie affinity is behind the
197    /// `sticky-cookie` feature and there is nothing to be sticky on without it.
198    pub fn from_strategy(strategy: &LoadBalanceStrategy, backends: &[Backend]) -> Self {
199        let num = backends.len();
200        match strategy {
201            LoadBalanceStrategy::RoundRobin => {
202                LoadBalance::RoundRobin(LoadBalanceRoundRobin::new(num))
203            }
204            LoadBalanceStrategy::Random => LoadBalance::Random(LoadBalanceRandom::new(num)),
205            LoadBalanceStrategy::LeastConnections => {
206                LoadBalance::LeastConnections(LoadBalanceLeastConn::new(num))
207            }
208            LoadBalanceStrategy::Weighted => {
209                let weights: Vec<u32> = backends.iter().map(|b| b.weight).collect();
210                LoadBalance::Weighted(LoadBalanceWeighted::new(weights))
211            }
212            LoadBalanceStrategy::Sticky => {
213                // For now, fall back to round-robin. Sticky requires cookie handling.
214                LoadBalance::RoundRobin(LoadBalanceRoundRobin::new(num))
215            }
216        }
217    }
218
219    /// Choose the next backend, as an index into the upstream's list.
220    pub fn get_ptr(&self, ctx: Option<&LoadBalanceContext>) -> PointerToUpstream {
221        match self {
222            LoadBalance::RoundRobin(lb) => lb.get_ptr(ctx),
223            LoadBalance::Random(lb) => lb.get_ptr(ctx),
224            LoadBalance::LeastConnections(lb) => lb.get_ptr(ctx),
225            LoadBalance::Weighted(lb) => lb.get_ptr(ctx),
226        }
227    }
228}
229
230/// Backend application manager (adapted from rpxy backend_main.rs).
231pub struct BackendAppManager {
232    /// Upstreams by ID
233    upstreams: DashMap<Uuid, UpstreamEntry>,
234    /// Route matcher: (host, path_prefix) -> upstream_id
235    routes: DashMap<(ServerName, PathName), Uuid>,
236    /// Health checker reference
237    health_checker: Option<Arc<HealthChecker>>,
238}
239
240struct UpstreamEntry {
241    upstream: Upstream,
242    load_balance: LoadBalance,
243}
244
245impl BackendAppManager {
246    /// An empty manager, with no health checking.
247    pub fn new() -> Self {
248        Self {
249            upstreams: DashMap::new(),
250            routes: DashMap::new(),
251            health_checker: None,
252        }
253    }
254
255    /// Attach a health checker, so unhealthy backends are skipped.
256    pub fn with_health_checker(mut self, checker: Arc<HealthChecker>) -> Self {
257        self.health_checker = Some(checker);
258        self
259    }
260
261    /// Register an upstream.
262    pub fn register_upstream(&self, upstream: Upstream) {
263        let id = upstream.resolved_id();
264        let load_balance = LoadBalance::from_strategy(&upstream.lb_strategy, &upstream.backends);
265        self.upstreams.insert(
266            id,
267            UpstreamEntry {
268                upstream,
269                load_balance,
270            },
271        );
272    }
273
274    /// Register a route.
275    pub fn register_route(&self, host: ServerName, path: PathName, upstream_id: Uuid) {
276        self.routes.insert((host, path), upstream_id);
277    }
278
279    /// Select a backend from an upstream, considering health status.
280    pub fn select_backend(
281        &self,
282        upstream_id: Uuid,
283        ctx: Option<&LoadBalanceContext>,
284    ) -> Option<Backend> {
285        let entry = self.upstreams.get(&upstream_id)?;
286        let upstream = &entry.upstream;
287
288        if upstream.backends.is_empty() {
289            return None;
290        }
291
292        // Get healthy backends
293        let healthy_backends: Vec<(usize, &Backend)> = upstream
294            .backends
295            .iter()
296            .enumerate()
297            .filter(|(_, b)| {
298                if let (Some(checker), Some(id)) = (&self.health_checker, b.id) {
299                    checker.is_healthy(id)
300                } else {
301                    true // No health checker or no ID means assume healthy
302                }
303            })
304            .collect();
305
306        if healthy_backends.is_empty() {
307            // All backends unhealthy, fall back to first backend
308            return Some(upstream.backends[0].clone());
309        }
310
311        // Use load balancer to select
312        let ptr = entry.load_balance.get_ptr(ctx);
313        let idx = ptr.ptr % healthy_backends.len();
314        Some(healthy_backends[idx].1.clone())
315    }
316
317    /// Get upstream by ID.
318    pub fn get_upstream(&self, id: Uuid) -> Option<Upstream> {
319        self.upstreams.get(&id).map(|e| e.upstream.clone())
320    }
321}
322
323impl Default for BackendAppManager {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_round_robin() {
335        let lb = LoadBalanceRoundRobin::new(3);
336
337        assert_eq!(lb.get_ptr(None).ptr, 0);
338        assert_eq!(lb.get_ptr(None).ptr, 1);
339        assert_eq!(lb.get_ptr(None).ptr, 2);
340        assert_eq!(lb.get_ptr(None).ptr, 0); // Wraps around
341    }
342
343    #[test]
344    fn test_random() {
345        let lb = LoadBalanceRandom::new(10);
346
347        // Just verify it returns valid indices
348        for _ in 0..100 {
349            let ptr = lb.get_ptr(None).ptr;
350            assert!(ptr < 10);
351        }
352    }
353
354    #[test]
355    fn test_weighted() {
356        let lb = LoadBalanceWeighted::new(vec![1, 2, 7]);
357
358        // Run many iterations and check distribution
359        let mut counts = [0u32; 3];
360        for _ in 0..1000 {
361            let ptr = lb.get_ptr(None).ptr;
362            counts[ptr] += 1;
363        }
364
365        // Backend 2 should get roughly 70% of traffic
366        assert!(counts[2] > counts[0] && counts[2] > counts[1]);
367    }
368}