Skip to main content

oximedia_distributed/
load_balancer.rs

1//! Distributed load balancing.
2//!
3//! Implements multiple load-balancing strategies for selecting worker nodes.
4
5/// Available load-balancing strategies.
6#[allow(dead_code)]
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum LoadBalanceStrategy {
9    /// Cycle through workers in order.
10    RoundRobin,
11    /// Prefer the worker with the fewest active connections.
12    LeastConnections,
13    /// Round-robin weighted by each worker's `weight` field.
14    WeightedRoundRobin,
15    /// Choose the worker with the lowest composite load score.
16    ResourceAware,
17    /// Deterministically map a key to a worker (minimises reassignment on
18    /// membership changes).
19    ConsistentHash,
20}
21
22/// Snapshot of a worker's current load.
23#[allow(dead_code)]
24#[derive(Debug, Clone)]
25pub struct WorkerLoad {
26    pub worker_id: u64,
27    pub connections: u32,
28    pub cpu_pct: f64,
29    pub memory_pct: f64,
30    pub weight: u32,
31}
32
33impl WorkerLoad {
34    /// Composite load score in `[0, 1]`. Higher means more loaded.
35    #[must_use]
36    pub fn load_score(&self) -> f64 {
37        // Simple weighted average: 50 % CPU, 30 % memory, 20 % connections
38        // (connections normalised to a 0-100 scale assuming 100 max).
39        let conn_pct = f64::from(self.connections).min(100.0);
40        (0.5 * self.cpu_pct + 0.3 * self.memory_pct + 0.2 * conn_pct) / 100.0
41    }
42
43    /// Returns `true` when the load score exceeds 0.9 (90 %).
44    #[must_use]
45    pub fn is_overloaded(&self) -> bool {
46        self.load_score() > 0.9
47    }
48}
49
50/// Routes incoming requests to registered worker nodes.
51#[allow(dead_code)]
52pub struct LoadBalancer {
53    pub strategy: LoadBalanceStrategy,
54    pub workers: Vec<WorkerLoad>,
55    pub round_robin_idx: usize,
56}
57
58impl LoadBalancer {
59    /// Create a load balancer with the chosen strategy and no workers.
60    #[must_use]
61    pub fn new(strategy: LoadBalanceStrategy) -> Self {
62        Self {
63            strategy,
64            workers: Vec::new(),
65            round_robin_idx: 0,
66        }
67    }
68
69    /// Register a worker node.
70    pub fn add_worker(&mut self, w: WorkerLoad) {
71        self.workers.push(w);
72    }
73
74    /// Deregister a worker by ID. Returns `true` if found and removed.
75    pub fn remove_worker(&mut self, id: u64) -> bool {
76        let before = self.workers.len();
77        self.workers.retain(|w| w.worker_id != id);
78        self.workers.len() < before
79    }
80
81    /// Select a worker according to the configured strategy.
82    ///
83    /// Returns `None` when no workers are registered.
84    pub fn select_worker(&mut self) -> Option<u64> {
85        if self.workers.is_empty() {
86            return None;
87        }
88        match &self.strategy {
89            LoadBalanceStrategy::RoundRobin => {
90                let idx = self.round_robin_idx % self.workers.len();
91                self.round_robin_idx = self.round_robin_idx.wrapping_add(1);
92                Some(self.workers[idx].worker_id)
93            }
94            LoadBalanceStrategy::LeastConnections => self
95                .workers
96                .iter()
97                .min_by_key(|w| w.connections)
98                .map(|w| w.worker_id),
99            LoadBalanceStrategy::WeightedRoundRobin => {
100                // Select based on accumulated weight; simple implementation.
101                let total_weight: u32 = self.workers.iter().map(|w| w.weight).sum();
102                if total_weight == 0 {
103                    return Some(self.workers[0].worker_id);
104                }
105                let idx = self.round_robin_idx % total_weight as usize;
106                self.round_robin_idx = self.round_robin_idx.wrapping_add(1);
107                let mut acc = 0usize;
108                for w in &self.workers {
109                    acc += w.weight as usize;
110                    if idx < acc {
111                        return Some(w.worker_id);
112                    }
113                }
114                self.workers.last().map(|w| w.worker_id)
115            }
116            LoadBalanceStrategy::ResourceAware => self
117                .workers
118                .iter()
119                .min_by(|a, b| {
120                    a.load_score()
121                        .partial_cmp(&b.load_score())
122                        .unwrap_or(std::cmp::Ordering::Equal)
123                })
124                .map(|w| w.worker_id),
125            LoadBalanceStrategy::ConsistentHash => {
126                // Use the current round-robin index as the routing key.
127                let key = self.round_robin_idx as u64;
128                self.round_robin_idx = self.round_robin_idx.wrapping_add(1);
129                consistent_hash(key, &self.workers)
130            }
131        }
132    }
133
134    /// Update the connection count and CPU usage for a worker.
135    pub fn update_load(&mut self, worker_id: u64, connections: u32, cpu_pct: f64) {
136        if let Some(w) = self.workers.iter_mut().find(|w| w.worker_id == worker_id) {
137            w.connections = connections;
138            w.cpu_pct = cpu_pct;
139        }
140    }
141}
142
143/// Map an arbitrary `key` to a worker using a simple consistent-hash ring.
144///
145/// Returns `None` when `workers` is empty.
146#[must_use]
147pub fn consistent_hash(key: u64, workers: &[WorkerLoad]) -> Option<u64> {
148    if workers.is_empty() {
149        return None;
150    }
151    // Hash the key with a simple mixing function, then pick a slot.
152    let mut h = key;
153    h ^= h >> 33;
154    h = h.wrapping_mul(0xff51afd7ed558ccd);
155    h ^= h >> 33;
156    h = h.wrapping_mul(0xc4ceb9fe1a85ec53);
157    h ^= h >> 33;
158    let idx = (h as usize) % workers.len();
159    Some(workers[idx].worker_id)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn worker(id: u64, conns: u32, cpu: f64, mem: f64, weight: u32) -> WorkerLoad {
167        WorkerLoad {
168            worker_id: id,
169            connections: conns,
170            cpu_pct: cpu,
171            memory_pct: mem,
172            weight,
173        }
174    }
175
176    #[test]
177    fn test_load_score_zero_load() {
178        let w = worker(1, 0, 0.0, 0.0, 1);
179        assert_eq!(w.load_score(), 0.0);
180    }
181
182    #[test]
183    fn test_load_score_full_load() {
184        let w = worker(1, 100, 100.0, 100.0, 1);
185        assert!((w.load_score() - 1.0).abs() < 1e-9);
186    }
187
188    #[test]
189    fn test_is_overloaded() {
190        let ok = worker(1, 10, 50.0, 50.0, 1);
191        assert!(!ok.is_overloaded());
192        let hot = worker(2, 100, 100.0, 100.0, 1);
193        assert!(hot.is_overloaded());
194    }
195
196    #[test]
197    fn test_no_workers_returns_none() {
198        let mut lb = LoadBalancer::new(LoadBalanceStrategy::RoundRobin);
199        assert!(lb.select_worker().is_none());
200    }
201
202    #[test]
203    fn test_round_robin_cycles() {
204        let mut lb = LoadBalancer::new(LoadBalanceStrategy::RoundRobin);
205        lb.add_worker(worker(1, 0, 0.0, 0.0, 1));
206        lb.add_worker(worker(2, 0, 0.0, 0.0, 1));
207        let first = lb.select_worker().expect("worker selection should succeed");
208        let second = lb.select_worker().expect("worker selection should succeed");
209        let third = lb.select_worker().expect("worker selection should succeed");
210        assert_ne!(first, second);
211        assert_eq!(first, third);
212    }
213
214    #[test]
215    fn test_least_connections() {
216        let mut lb = LoadBalancer::new(LoadBalanceStrategy::LeastConnections);
217        lb.add_worker(worker(1, 10, 0.0, 0.0, 1));
218        lb.add_worker(worker(2, 2, 0.0, 0.0, 1));
219        lb.add_worker(worker(3, 7, 0.0, 0.0, 1));
220        assert_eq!(
221            lb.select_worker().expect("worker selection should succeed"),
222            2
223        );
224    }
225
226    #[test]
227    fn test_resource_aware_picks_lowest_load() {
228        let mut lb = LoadBalancer::new(LoadBalanceStrategy::ResourceAware);
229        lb.add_worker(worker(1, 50, 80.0, 70.0, 1)); // high load
230        lb.add_worker(worker(2, 0, 5.0, 5.0, 1)); // low load
231        assert_eq!(
232            lb.select_worker().expect("worker selection should succeed"),
233            2
234        );
235    }
236
237    #[test]
238    fn test_remove_worker() {
239        let mut lb = LoadBalancer::new(LoadBalanceStrategy::RoundRobin);
240        lb.add_worker(worker(1, 0, 0.0, 0.0, 1));
241        lb.add_worker(worker(2, 0, 0.0, 0.0, 1));
242        assert!(lb.remove_worker(1));
243        assert!(!lb.remove_worker(99)); // not found
244        assert_eq!(lb.workers.len(), 1);
245        assert_eq!(lb.workers[0].worker_id, 2);
246    }
247
248    #[test]
249    fn test_update_load() {
250        let mut lb = LoadBalancer::new(LoadBalanceStrategy::RoundRobin);
251        lb.add_worker(worker(1, 0, 0.0, 0.0, 1));
252        lb.update_load(1, 42, 75.0);
253        assert_eq!(lb.workers[0].connections, 42);
254        assert!((lb.workers[0].cpu_pct - 75.0).abs() < f64::EPSILON);
255    }
256
257    #[test]
258    fn test_weighted_round_robin() {
259        let mut lb = LoadBalancer::new(LoadBalanceStrategy::WeightedRoundRobin);
260        lb.add_worker(worker(1, 0, 0.0, 0.0, 3));
261        lb.add_worker(worker(2, 0, 0.0, 0.0, 1));
262        // Over 4 selections we should see worker 1 selected 3 times.
263        let results: Vec<u64> = (0..4)
264            .map(|_| lb.select_worker().expect("worker selection should succeed"))
265            .collect();
266        let count_1 = results.iter().filter(|&&id| id == 1).count();
267        let count_2 = results.iter().filter(|&&id| id == 2).count();
268        assert_eq!(count_1, 3);
269        assert_eq!(count_2, 1);
270    }
271
272    #[test]
273    fn test_consistent_hash_stable() {
274        let workers = vec![worker(10, 0, 0.0, 0.0, 1), worker(20, 0, 0.0, 0.0, 1)];
275        let r1 = consistent_hash(42, &workers);
276        let r2 = consistent_hash(42, &workers);
277        assert_eq!(r1, r2);
278    }
279
280    #[test]
281    fn test_consistent_hash_empty() {
282        assert!(consistent_hash(0, &[]).is_none());
283    }
284
285    #[test]
286    fn test_consistent_hash_single_worker() {
287        let workers = vec![worker(99, 0, 0.0, 0.0, 1)];
288        assert_eq!(consistent_hash(12345, &workers), Some(99));
289    }
290
291    #[test]
292    fn test_add_multiple_workers() {
293        let mut lb = LoadBalancer::new(LoadBalanceStrategy::RoundRobin);
294        for i in 1..=5 {
295            lb.add_worker(worker(i, 0, 0.0, 0.0, 1));
296        }
297        assert_eq!(lb.workers.len(), 5);
298    }
299}