Skip to main content

amaters_net/
balancer.rs

1//! Load balancing strategies for distributing requests across endpoints
2//!
3//! Provides multiple strategies for selecting endpoints based on different criteria:
4//! - **RoundRobin**: Simple rotation through endpoints
5//! - **WeightedRoundRobin**: Smooth weighted round-robin (Nginx-style)
6//! - **LeastConnections**: Select endpoint with fewest active connections
7//! - **ConsistentHash**: Hash-ring based selection for key affinity
8//! - **PowerOfTwo**: Randomly pick two, choose the less loaded one
9//! - **Weighted**: Legacy weighted random selection
10
11use crate::error::{NetError, NetResult};
12use parking_lot::RwLock;
13use std::collections::{BTreeMap, HashMap};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
16
17/// Endpoint identifier
18pub type EndpointId = String;
19
20/// Endpoint weight for weighted load balancing
21pub type Weight = u32;
22
23/// Endpoint information
24#[derive(Debug, Clone)]
25pub struct Endpoint {
26    /// Unique endpoint identifier
27    pub id: EndpointId,
28    /// Endpoint address (e.g., "localhost:50051")
29    pub address: String,
30    /// Endpoint weight (for weighted balancing)
31    pub weight: Weight,
32    /// Number of active connections
33    pub active_connections: Arc<AtomicUsize>,
34    /// Total requests handled
35    pub total_requests: Arc<AtomicU64>,
36    /// Whether endpoint is healthy
37    pub healthy: Arc<parking_lot::RwLock<bool>>,
38}
39
40impl Endpoint {
41    /// Create a new endpoint
42    pub fn new(id: EndpointId, address: String) -> Self {
43        Self::with_weight(id, address, 1)
44    }
45
46    /// Create a new endpoint with weight
47    pub fn with_weight(id: EndpointId, address: String, weight: Weight) -> Self {
48        Self {
49            id,
50            address,
51            weight,
52            active_connections: Arc::new(AtomicUsize::new(0)),
53            total_requests: Arc::new(AtomicU64::new(0)),
54            healthy: Arc::new(parking_lot::RwLock::new(true)),
55        }
56    }
57
58    /// Check if endpoint is healthy
59    pub fn is_healthy(&self) -> bool {
60        *self.healthy.read()
61    }
62
63    /// Mark endpoint as healthy
64    pub fn mark_healthy(&self) {
65        *self.healthy.write() = true;
66    }
67
68    /// Mark endpoint as unhealthy
69    pub fn mark_unhealthy(&self) {
70        *self.healthy.write() = false;
71    }
72
73    /// Get active connection count
74    pub fn active_connections(&self) -> usize {
75        self.active_connections.load(Ordering::Relaxed)
76    }
77
78    /// Increment active connections
79    pub fn increment_connections(&self) {
80        self.active_connections.fetch_add(1, Ordering::Relaxed);
81        self.total_requests.fetch_add(1, Ordering::Relaxed);
82    }
83
84    /// Decrement active connections
85    pub fn decrement_connections(&self) {
86        self.active_connections.fetch_sub(1, Ordering::Relaxed);
87    }
88
89    /// Get total requests handled
90    pub fn total_requests(&self) -> u64 {
91        self.total_requests.load(Ordering::Relaxed)
92    }
93}
94
95/// Load balancing strategy
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum BalancingStrategy {
98    /// Round-robin: Rotate through endpoints in order
99    RoundRobin,
100    /// Weighted round-robin: Smooth weighted rotation (Nginx-style)
101    WeightedRoundRobin,
102    /// Least connections: Select endpoint with fewest active connections
103    LeastConnections,
104    /// Consistent hashing: Hash-ring based endpoint selection
105    ConsistentHash,
106    /// Power of two choices: Pick two random, choose less loaded
107    PowerOfTwo,
108    /// Weighted: Select based on endpoint weights (legacy weighted random)
109    Weighted,
110}
111
112/// Weighted round-robin state for a single endpoint (Nginx smooth algorithm)
113#[derive(Debug, Clone)]
114pub struct EndpointWeight {
115    /// Index of the endpoint in the endpoints list
116    pub endpoint_index: usize,
117    /// Configured weight
118    pub weight: i64,
119    /// Current weight (changes each selection round)
120    pub current_weight: i64,
121    /// Effective weight (may decrease on failures, recovers over time)
122    pub effective_weight: i64,
123}
124
125impl EndpointWeight {
126    /// Create a new endpoint weight entry
127    pub fn new(endpoint_index: usize, weight: u32) -> Self {
128        let w = i64::from(weight);
129        Self {
130            endpoint_index,
131            weight: w,
132            current_weight: 0,
133            effective_weight: w,
134        }
135    }
136}
137
138/// Consistent hash ring for deterministic endpoint selection
139#[derive(Debug)]
140pub struct HashRing {
141    /// Sorted map of hash values to endpoint indices
142    ring: BTreeMap<u64, usize>,
143    /// Number of virtual nodes per endpoint
144    virtual_nodes: usize,
145}
146
147impl HashRing {
148    /// Default number of virtual nodes per real endpoint
149    pub const DEFAULT_VIRTUAL_NODES: usize = 150;
150
151    /// Create a new empty hash ring
152    pub fn new(virtual_nodes: usize) -> Self {
153        Self {
154            ring: BTreeMap::new(),
155            virtual_nodes,
156        }
157    }
158
159    /// Rebuild the ring from the given endpoints (only healthy ones)
160    pub fn rebuild(&mut self, endpoints: &[Arc<Endpoint>]) {
161        self.ring.clear();
162        for (idx, ep) in endpoints.iter().enumerate() {
163            if !ep.is_healthy() {
164                continue;
165            }
166            for vn in 0..self.virtual_nodes {
167                let key = format!("{}:{}", ep.id, vn);
168                let hash = Self::hash_key(key.as_bytes());
169                self.ring.insert(hash, idx);
170            }
171        }
172    }
173
174    /// Add a single endpoint to the ring
175    pub fn add_endpoint(&mut self, index: usize, endpoint_id: &str) {
176        for vn in 0..self.virtual_nodes {
177            let key = format!("{endpoint_id}:{vn}");
178            let hash = Self::hash_key(key.as_bytes());
179            self.ring.insert(hash, index);
180        }
181    }
182
183    /// Remove a single endpoint from the ring
184    pub fn remove_endpoint(&mut self, endpoint_id: &str) {
185        for vn in 0..self.virtual_nodes {
186            let key = format!("{endpoint_id}:{vn}");
187            let hash = Self::hash_key(key.as_bytes());
188            self.ring.remove(&hash);
189        }
190    }
191
192    /// Find the endpoint index for a given key
193    pub fn get_endpoint(&self, key: &[u8]) -> Option<usize> {
194        if self.ring.is_empty() {
195            return None;
196        }
197        let hash = Self::hash_key(key);
198        // Find first node with hash >= key hash (clockwise on the ring)
199        if let Some((&_node_hash, &idx)) = self.ring.range(hash..).next() {
200            Some(idx)
201        } else {
202            // Wrap around to the first node
203            self.ring.values().next().copied()
204        }
205    }
206
207    /// Hash a key using blake3, returning a u64
208    fn hash_key(key: &[u8]) -> u64 {
209        let hash = blake3::hash(key);
210        let bytes = hash.as_bytes();
211        u64::from_le_bytes([
212            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
213        ])
214    }
215
216    /// Returns whether the ring is empty
217    pub fn is_empty(&self) -> bool {
218        self.ring.is_empty()
219    }
220}
221
222/// Connection affinity (sticky sessions)
223#[derive(Debug, Clone)]
224pub struct Affinity {
225    /// Session ID to endpoint mapping
226    sessions: Arc<RwLock<HashMap<String, EndpointId>>>,
227}
228
229impl Affinity {
230    /// Create new affinity tracker
231    pub fn new() -> Self {
232        Self {
233            sessions: Arc::new(RwLock::new(HashMap::new())),
234        }
235    }
236
237    /// Get endpoint for session
238    pub fn get(&self, session_id: &str) -> Option<EndpointId> {
239        self.sessions.read().get(session_id).cloned()
240    }
241
242    /// Set endpoint for session
243    pub fn set(&self, session_id: String, endpoint_id: EndpointId) {
244        self.sessions.write().insert(session_id, endpoint_id);
245    }
246
247    /// Remove session
248    pub fn remove(&self, session_id: &str) {
249        self.sessions.write().remove(session_id);
250    }
251
252    /// Clear all sessions
253    pub fn clear(&self) {
254        self.sessions.write().clear();
255    }
256}
257
258impl Default for Affinity {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264/// Load balancer for distributing requests across endpoints
265#[derive(Debug)]
266pub struct LoadBalancer {
267    /// Load balancing strategy
268    strategy: BalancingStrategy,
269    /// Available endpoints
270    endpoints: Arc<RwLock<Vec<Arc<Endpoint>>>>,
271    /// Current round-robin index
272    round_robin_index: AtomicUsize,
273    /// Connection affinity
274    affinity: Affinity,
275    /// Weighted round-robin state
276    wrr_state: RwLock<Vec<EndpointWeight>>,
277    /// Consistent hash ring
278    hash_ring: RwLock<HashRing>,
279    /// Simple counter used for pseudo-random in PowerOfTwo
280    power_counter: AtomicUsize,
281}
282
283impl LoadBalancer {
284    /// Create a new load balancer with the given strategy
285    pub fn new(strategy: BalancingStrategy) -> Self {
286        Self {
287            strategy,
288            endpoints: Arc::new(RwLock::new(Vec::new())),
289            round_robin_index: AtomicUsize::new(0),
290            affinity: Affinity::new(),
291            wrr_state: RwLock::new(Vec::new()),
292            hash_ring: RwLock::new(HashRing::new(HashRing::DEFAULT_VIRTUAL_NODES)),
293            power_counter: AtomicUsize::new(0),
294        }
295    }
296
297    /// Add an endpoint to the load balancer
298    pub fn add_endpoint(&self, endpoint: Endpoint) {
299        let ep_id = endpoint.id.clone();
300        let ep_weight = endpoint.weight;
301        let mut endpoints = self.endpoints.write();
302        let index = endpoints.len();
303        endpoints.push(Arc::new(endpoint));
304
305        // Update strategy-specific state
306        if self.strategy == BalancingStrategy::WeightedRoundRobin {
307            self.wrr_state
308                .write()
309                .push(EndpointWeight::new(index, ep_weight));
310        }
311        if self.strategy == BalancingStrategy::ConsistentHash {
312            self.hash_ring.write().add_endpoint(index, &ep_id);
313        }
314    }
315
316    /// Remove an endpoint from the load balancer
317    pub fn remove_endpoint(&self, endpoint_id: &str) -> bool {
318        let mut endpoints = self.endpoints.write();
319        if let Some(pos) = endpoints.iter().position(|e| e.id == endpoint_id) {
320            endpoints.remove(pos);
321
322            // Rebuild strategy-specific state
323            if self.strategy == BalancingStrategy::WeightedRoundRobin {
324                let mut wrr = self.wrr_state.write();
325                wrr.clear();
326                for (i, ep) in endpoints.iter().enumerate() {
327                    wrr.push(EndpointWeight::new(i, ep.weight));
328                }
329            }
330            if self.strategy == BalancingStrategy::ConsistentHash {
331                let mut ring = self.hash_ring.write();
332                ring.rebuild(&endpoints);
333            }
334            true
335        } else {
336            false
337        }
338    }
339
340    /// Get all endpoints
341    pub fn endpoints(&self) -> Vec<Arc<Endpoint>> {
342        self.endpoints.read().clone()
343    }
344
345    /// Get healthy endpoints
346    pub fn healthy_endpoints(&self) -> Vec<Arc<Endpoint>> {
347        self.endpoints
348            .read()
349            .iter()
350            .filter(|e| e.is_healthy())
351            .cloned()
352            .collect()
353    }
354
355    /// Get count of healthy endpoints
356    pub fn healthy_count(&self) -> usize {
357        self.endpoints
358            .read()
359            .iter()
360            .filter(|e| e.is_healthy())
361            .count()
362    }
363
364    /// Mark an endpoint as unhealthy by index
365    pub fn mark_unhealthy(&self, endpoint_index: usize) {
366        let endpoints = self.endpoints.read();
367        if let Some(ep) = endpoints.get(endpoint_index) {
368            ep.mark_unhealthy();
369        }
370    }
371
372    /// Mark an endpoint as healthy by index
373    pub fn mark_healthy(&self, endpoint_index: usize) {
374        let endpoints = self.endpoints.read();
375        if let Some(ep) = endpoints.get(endpoint_index) {
376            ep.mark_healthy();
377        }
378    }
379
380    /// Acquire a connection to an endpoint (increments active count)
381    pub fn acquire(&self, endpoint_index: usize) {
382        let endpoints = self.endpoints.read();
383        if let Some(ep) = endpoints.get(endpoint_index) {
384            ep.increment_connections();
385        }
386    }
387
388    /// Release a connection to an endpoint (decrements active count)
389    pub fn release(&self, endpoint_index: usize) {
390        let endpoints = self.endpoints.read();
391        if let Some(ep) = endpoints.get(endpoint_index) {
392            ep.decrement_connections();
393        }
394    }
395
396    /// Select an endpoint using the configured strategy
397    pub fn select_endpoint(&self) -> NetResult<Arc<Endpoint>> {
398        let healthy_endpoints = self.healthy_endpoints();
399
400        if healthy_endpoints.is_empty() {
401            return Err(NetError::ServerUnavailable(
402                "No healthy endpoints available".to_string(),
403            ));
404        }
405
406        match self.strategy {
407            BalancingStrategy::RoundRobin => self.select_round_robin(&healthy_endpoints),
408            BalancingStrategy::WeightedRoundRobin => self.select_weighted_round_robin(),
409            BalancingStrategy::LeastConnections => {
410                self.select_least_connections(&healthy_endpoints)
411            }
412            BalancingStrategy::ConsistentHash => {
413                // For non-key selection, use round-robin index as a pseudo key
414                let counter = self.round_robin_index.fetch_add(1, Ordering::Relaxed);
415                let key = counter.to_le_bytes();
416                self.select_for_key(&key)
417            }
418            BalancingStrategy::PowerOfTwo => self.select_power_of_two(&healthy_endpoints),
419            BalancingStrategy::Weighted => self.select_weighted(&healthy_endpoints),
420        }
421    }
422
423    /// Select an endpoint for a specific key (consistent hashing)
424    ///
425    /// Maps the given key deterministically to an endpoint. Adding or removing
426    /// endpoints only remaps approximately 1/N of keys.
427    pub fn select_for_key(&self, key: &[u8]) -> NetResult<Arc<Endpoint>> {
428        let ring = self.hash_ring.read();
429        let endpoints = self.endpoints.read();
430
431        if let Some(idx) = ring.get_endpoint(key) {
432            if let Some(ep) = endpoints.get(idx) {
433                if ep.is_healthy() {
434                    return Ok(Arc::clone(ep));
435                }
436            }
437            // If the mapped endpoint is unhealthy or missing, find next healthy
438            // by re-hashing with a suffix
439            drop(ring);
440            drop(endpoints);
441            let healthy = self.healthy_endpoints();
442            if healthy.is_empty() {
443                return Err(NetError::ServerUnavailable(
444                    "No healthy endpoints available".to_string(),
445                ));
446            }
447            // Fallback: hash with suffix to pick from healthy
448            let hash = blake3::hash(key);
449            let hash_bytes = hash.as_bytes();
450            let val = u64::from_le_bytes([
451                hash_bytes[0],
452                hash_bytes[1],
453                hash_bytes[2],
454                hash_bytes[3],
455                hash_bytes[4],
456                hash_bytes[5],
457                hash_bytes[6],
458                hash_bytes[7],
459            ]);
460            let idx = (val as usize) % healthy.len();
461            return Ok(Arc::clone(&healthy[idx]));
462        }
463
464        Err(NetError::ServerUnavailable(
465            "No endpoints in hash ring".to_string(),
466        ))
467    }
468
469    /// Select endpoint with affinity (sticky session)
470    pub fn select_with_affinity(&self, session_id: &str) -> NetResult<Arc<Endpoint>> {
471        // Check if session has an existing endpoint
472        if let Some(endpoint_id) = self.affinity.get(session_id) {
473            // Find the endpoint
474            if let Some(endpoint) = self
475                .healthy_endpoints()
476                .iter()
477                .find(|e| e.id == endpoint_id)
478            {
479                return Ok(Arc::clone(endpoint));
480            }
481        }
482
483        // No existing endpoint or unhealthy - select a new one
484        let endpoint = self.select_endpoint()?;
485        self.affinity
486            .set(session_id.to_string(), endpoint.id.clone());
487        Ok(endpoint)
488    }
489
490    /// Clear session affinity
491    pub fn clear_affinity(&self, session_id: &str) {
492        self.affinity.remove(session_id);
493    }
494
495    /// Get load balancing statistics
496    pub fn stats(&self) -> BalancerStats {
497        let endpoints = self.endpoints.read();
498        let total_endpoints = endpoints.len();
499        let healthy_endpoints = endpoints.iter().filter(|e| e.is_healthy()).count();
500        let total_connections: usize = endpoints.iter().map(|e| e.active_connections()).sum();
501        let total_requests: u64 = endpoints.iter().map(|e| e.total_requests()).sum();
502
503        BalancerStats {
504            total_endpoints,
505            healthy_endpoints,
506            total_connections,
507            total_requests,
508            strategy: self.strategy,
509        }
510    }
511
512    /// Round-robin selection
513    fn select_round_robin(&self, endpoints: &[Arc<Endpoint>]) -> NetResult<Arc<Endpoint>> {
514        if endpoints.is_empty() {
515            return Err(NetError::ServerUnavailable(
516                "No endpoints available".to_string(),
517            ));
518        }
519
520        let index = self.round_robin_index.fetch_add(1, Ordering::Relaxed);
521        let endpoint = &endpoints[index % endpoints.len()];
522        Ok(Arc::clone(endpoint))
523    }
524
525    /// Smooth weighted round-robin (Nginx-style)
526    ///
527    /// Algorithm: On each selection round:
528    ///   1. For each endpoint, current_weight += effective_weight
529    ///   2. Select the endpoint with highest current_weight
530    ///   3. selected.current_weight -= total_effective_weight
531    ///
532    /// This produces smooth distribution: weights 5,1,1 -> a]a,b,a,c,a,a,a pattern
533    fn select_weighted_round_robin(&self) -> NetResult<Arc<Endpoint>> {
534        let endpoints = self.endpoints.read();
535        let mut wrr = self.wrr_state.write();
536
537        if wrr.is_empty() {
538            return Err(NetError::ServerUnavailable(
539                "No endpoints available for weighted round-robin".to_string(),
540            ));
541        }
542
543        // Calculate total effective weight of healthy endpoints only
544        let mut total_effective: i64 = 0;
545        for ew in wrr.iter() {
546            if let Some(ep) = endpoints.get(ew.endpoint_index) {
547                if ep.is_healthy() && ew.effective_weight > 0 {
548                    total_effective += ew.effective_weight;
549                }
550            }
551        }
552
553        if total_effective == 0 {
554            return Err(NetError::ServerUnavailable(
555                "No healthy endpoints with positive weight".to_string(),
556            ));
557        }
558
559        // Step 1: Add effective_weight to current_weight for healthy endpoints
560        let mut best_idx: Option<usize> = None;
561        let mut best_current: i64 = i64::MIN;
562
563        for (i, ew) in wrr.iter_mut().enumerate() {
564            if let Some(ep) = endpoints.get(ew.endpoint_index) {
565                if !ep.is_healthy() || ew.effective_weight <= 0 {
566                    continue;
567                }
568            } else {
569                continue;
570            }
571            ew.current_weight += ew.effective_weight;
572            if ew.current_weight > best_current {
573                best_current = ew.current_weight;
574                best_idx = Some(i);
575            }
576        }
577
578        let selected_wrr_idx = best_idx.ok_or_else(|| {
579            NetError::ServerUnavailable("No endpoint selected in WRR".to_string())
580        })?;
581
582        // Step 2: Subtract total from the selected
583        wrr[selected_wrr_idx].current_weight -= total_effective;
584
585        let ep_index = wrr[selected_wrr_idx].endpoint_index;
586        let ep = endpoints.get(ep_index).ok_or_else(|| {
587            NetError::ServerUnavailable("Selected endpoint index out of range".to_string())
588        })?;
589
590        Ok(Arc::clone(ep))
591    }
592
593    /// Least connections selection
594    fn select_least_connections(&self, endpoints: &[Arc<Endpoint>]) -> NetResult<Arc<Endpoint>> {
595        endpoints
596            .iter()
597            .min_by_key(|e| e.active_connections())
598            .map(Arc::clone)
599            .ok_or_else(|| NetError::ServerUnavailable("No endpoints available".to_string()))
600    }
601
602    /// Power of two choices selection
603    ///
604    /// Randomly pick two endpoints, then choose the one with fewer active connections.
605    /// This simple approach provably reduces maximum load from O(log n / log log n) to
606    /// O(log log n) compared to pure random selection.
607    fn select_power_of_two(&self, endpoints: &[Arc<Endpoint>]) -> NetResult<Arc<Endpoint>> {
608        let len = endpoints.len();
609        if len == 0 {
610            return Err(NetError::ServerUnavailable(
611                "No endpoints available".to_string(),
612            ));
613        }
614        if len == 1 {
615            return Ok(Arc::clone(&endpoints[0]));
616        }
617
618        // Use atomic counter + blake3 for pseudo-random index generation
619        let counter = self.power_counter.fetch_add(1, Ordering::Relaxed);
620        let hash = blake3::hash(&counter.to_le_bytes());
621        let bytes = hash.as_bytes();
622
623        let idx_a = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize % len;
624        let mut idx_b = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize % len;
625
626        // Ensure idx_b != idx_a
627        if idx_b == idx_a {
628            idx_b = (idx_a + 1) % len;
629        }
630
631        let conn_a = endpoints[idx_a].active_connections();
632        let conn_b = endpoints[idx_b].active_connections();
633
634        if conn_a <= conn_b {
635            Ok(Arc::clone(&endpoints[idx_a]))
636        } else {
637            Ok(Arc::clone(&endpoints[idx_b]))
638        }
639    }
640
641    /// Weighted selection using weighted random (legacy)
642    fn select_weighted(&self, endpoints: &[Arc<Endpoint>]) -> NetResult<Arc<Endpoint>> {
643        if endpoints.is_empty() {
644            return Err(NetError::ServerUnavailable(
645                "No endpoints available".to_string(),
646            ));
647        }
648
649        // Calculate total weight
650        let total_weight: u32 = endpoints.iter().map(|e| e.weight).sum();
651
652        if total_weight == 0 {
653            // If all weights are zero, fall back to round-robin
654            return self.select_round_robin(endpoints);
655        }
656
657        // Use round-robin counter as pseudo-random selector
658        let selector = self.round_robin_index.fetch_add(1, Ordering::Relaxed) as u32;
659        let target = selector % total_weight;
660
661        // Find endpoint based on weighted selection
662        let mut cumulative = 0u32;
663        for endpoint in endpoints {
664            cumulative += endpoint.weight;
665            if target < cumulative {
666                return Ok(Arc::clone(endpoint));
667            }
668        }
669
670        // Fallback to last endpoint (shouldn't happen)
671        Ok(Arc::clone(&endpoints[endpoints.len() - 1]))
672    }
673}
674
675/// Load balancer statistics
676#[derive(Debug, Clone)]
677pub struct BalancerStats {
678    /// Total number of endpoints
679    pub total_endpoints: usize,
680    /// Number of healthy endpoints
681    pub healthy_endpoints: usize,
682    /// Total active connections across all endpoints
683    pub total_connections: usize,
684    /// Total requests handled
685    pub total_requests: u64,
686    /// Current balancing strategy
687    pub strategy: BalancingStrategy,
688}
689
690/// Connection guard that automatically decrements connection count
691pub struct ConnectionGuard {
692    endpoint: Arc<Endpoint>,
693}
694
695impl ConnectionGuard {
696    /// Create a new connection guard
697    pub fn new(endpoint: Arc<Endpoint>) -> Self {
698        endpoint.increment_connections();
699        Self { endpoint }
700    }
701
702    /// Get the endpoint
703    pub fn endpoint(&self) -> &Arc<Endpoint> {
704        &self.endpoint
705    }
706}
707
708impl Drop for ConnectionGuard {
709    fn drop(&mut self) {
710        self.endpoint.decrement_connections();
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_endpoint_creation() {
720        let endpoint = Endpoint::new("ep1".to_string(), "localhost:50051".to_string());
721        assert_eq!(endpoint.id, "ep1");
722        assert_eq!(endpoint.address, "localhost:50051");
723        assert_eq!(endpoint.weight, 1);
724        assert!(endpoint.is_healthy());
725    }
726
727    #[test]
728    fn test_endpoint_health() {
729        let endpoint = Endpoint::new("ep1".to_string(), "localhost:50051".to_string());
730        assert!(endpoint.is_healthy());
731
732        endpoint.mark_unhealthy();
733        assert!(!endpoint.is_healthy());
734
735        endpoint.mark_healthy();
736        assert!(endpoint.is_healthy());
737    }
738
739    #[test]
740    fn test_endpoint_connections() {
741        let endpoint = Endpoint::new("ep1".to_string(), "localhost:50051".to_string());
742        assert_eq!(endpoint.active_connections(), 0);
743
744        endpoint.increment_connections();
745        assert_eq!(endpoint.active_connections(), 1);
746
747        endpoint.increment_connections();
748        assert_eq!(endpoint.active_connections(), 2);
749
750        endpoint.decrement_connections();
751        assert_eq!(endpoint.active_connections(), 1);
752    }
753
754    #[test]
755    fn test_load_balancer_round_robin() {
756        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
757
758        lb.add_endpoint(Endpoint::new(
759            "ep1".to_string(),
760            "localhost:50051".to_string(),
761        ));
762        lb.add_endpoint(Endpoint::new(
763            "ep2".to_string(),
764            "localhost:50052".to_string(),
765        ));
766        lb.add_endpoint(Endpoint::new(
767            "ep3".to_string(),
768            "localhost:50053".to_string(),
769        ));
770
771        // Should rotate through endpoints
772        let ep1 = lb.select_endpoint().expect("should select endpoint");
773        let ep2 = lb.select_endpoint().expect("should select endpoint");
774        let ep3 = lb.select_endpoint().expect("should select endpoint");
775        let ep4 = lb.select_endpoint().expect("should select endpoint");
776
777        assert_eq!(ep1.id, "ep1");
778        assert_eq!(ep2.id, "ep2");
779        assert_eq!(ep3.id, "ep3");
780        assert_eq!(ep4.id, "ep1"); // Wraps around
781    }
782
783    #[test]
784    fn test_load_balancer_least_connections() {
785        let lb = LoadBalancer::new(BalancingStrategy::LeastConnections);
786
787        lb.add_endpoint(Endpoint::new(
788            "ep1".to_string(),
789            "localhost:50051".to_string(),
790        ));
791        lb.add_endpoint(Endpoint::new(
792            "ep2".to_string(),
793            "localhost:50052".to_string(),
794        ));
795
796        // First selection should be ep1 (both have 0, prefer lower index)
797        let ep1 = lb.select_endpoint().expect("should select endpoint");
798        assert_eq!(ep1.id, "ep1");
799        ep1.increment_connections();
800
801        // Should select ep2 (fewer connections)
802        let ep2 = lb.select_endpoint().expect("should select endpoint");
803        assert_eq!(ep2.id, "ep2");
804
805        ep2.increment_connections();
806        ep2.increment_connections(); // ep2 now has 2, ep1 has 1
807
808        // Should select ep1 (fewer connections)
809        let ep3 = lb.select_endpoint().expect("should select endpoint");
810        assert_eq!(ep3.id, "ep1");
811    }
812
813    #[test]
814    fn test_load_balancer_weighted() {
815        let lb = LoadBalancer::new(BalancingStrategy::Weighted);
816
817        lb.add_endpoint(Endpoint::with_weight(
818            "ep1".to_string(),
819            "localhost:50051".to_string(),
820            3,
821        ));
822        lb.add_endpoint(Endpoint::with_weight(
823            "ep2".to_string(),
824            "localhost:50052".to_string(),
825            1,
826        ));
827
828        // Collect selections
829        let mut counts = HashMap::new();
830        for _ in 0..40 {
831            let ep = lb.select_endpoint().expect("should select endpoint");
832            *counts.entry(ep.id.clone()).or_insert(0) += 1;
833        }
834
835        // ep1 should be selected ~3x more than ep2
836        let ep1_count = counts.get("ep1").copied().unwrap_or(0);
837        let ep2_count = counts.get("ep2").copied().unwrap_or(0);
838
839        // With 40 selections and 3:1 weight, expect ~30:10 distribution
840        assert!(ep1_count > ep2_count);
841        assert!(ep1_count >= 20); // At least 50% (should be ~75%)
842    }
843
844    #[test]
845    fn test_load_balancer_no_endpoints() {
846        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
847        let result = lb.select_endpoint();
848        assert!(result.is_err());
849    }
850
851    #[test]
852    fn test_load_balancer_unhealthy_endpoints() {
853        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
854
855        let ep1 = Endpoint::new("ep1".to_string(), "localhost:50051".to_string());
856        let ep2 = Endpoint::new("ep2".to_string(), "localhost:50052".to_string());
857
858        ep1.mark_unhealthy();
859
860        lb.add_endpoint(ep1);
861        lb.add_endpoint(ep2);
862
863        // Should only select ep2 (healthy)
864        for _ in 0..5 {
865            let ep = lb.select_endpoint().expect("should select endpoint");
866            assert_eq!(ep.id, "ep2");
867        }
868    }
869
870    #[test]
871    fn test_load_balancer_affinity() {
872        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
873
874        lb.add_endpoint(Endpoint::new(
875            "ep1".to_string(),
876            "localhost:50051".to_string(),
877        ));
878        lb.add_endpoint(Endpoint::new(
879            "ep2".to_string(),
880            "localhost:50052".to_string(),
881        ));
882
883        let session_id = "session123";
884
885        // First selection should assign endpoint
886        let ep1 = lb
887            .select_with_affinity(session_id)
888            .expect("should select endpoint");
889
890        // Subsequent selections should return same endpoint
891        let ep2 = lb
892            .select_with_affinity(session_id)
893            .expect("should select endpoint");
894        let ep3 = lb
895            .select_with_affinity(session_id)
896            .expect("should select endpoint");
897
898        assert_eq!(ep1.id, ep2.id);
899        assert_eq!(ep2.id, ep3.id);
900
901        // Clear affinity
902        lb.clear_affinity(session_id);
903
904        // Next selection may be different
905        let _ep4 = lb
906            .select_with_affinity(session_id)
907            .expect("should select endpoint");
908    }
909
910    #[test]
911    fn test_load_balancer_remove_endpoint() {
912        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
913
914        lb.add_endpoint(Endpoint::new(
915            "ep1".to_string(),
916            "localhost:50051".to_string(),
917        ));
918        lb.add_endpoint(Endpoint::new(
919            "ep2".to_string(),
920            "localhost:50052".to_string(),
921        ));
922
923        assert_eq!(lb.endpoints().len(), 2);
924
925        lb.remove_endpoint("ep1");
926        assert_eq!(lb.endpoints().len(), 1);
927
928        let ep = lb.select_endpoint().expect("should select endpoint");
929        assert_eq!(ep.id, "ep2");
930    }
931
932    #[test]
933    fn test_load_balancer_stats() {
934        let lb = LoadBalancer::new(BalancingStrategy::LeastConnections);
935
936        lb.add_endpoint(Endpoint::new(
937            "ep1".to_string(),
938            "localhost:50051".to_string(),
939        ));
940        lb.add_endpoint(Endpoint::new(
941            "ep2".to_string(),
942            "localhost:50052".to_string(),
943        ));
944
945        let stats = lb.stats();
946        assert_eq!(stats.total_endpoints, 2);
947        assert_eq!(stats.healthy_endpoints, 2);
948        assert_eq!(stats.total_connections, 0);
949        assert_eq!(stats.strategy, BalancingStrategy::LeastConnections);
950    }
951
952    #[test]
953    fn test_connection_guard() {
954        let endpoint = Arc::new(Endpoint::new(
955            "ep1".to_string(),
956            "localhost:50051".to_string(),
957        ));
958
959        assert_eq!(endpoint.active_connections(), 0);
960
961        {
962            let _guard = ConnectionGuard::new(Arc::clone(&endpoint));
963            assert_eq!(endpoint.active_connections(), 1);
964        }
965
966        // Guard dropped, connection should be decremented
967        assert_eq!(endpoint.active_connections(), 0);
968    }
969
970    #[test]
971    fn test_affinity() {
972        let affinity = Affinity::new();
973
974        affinity.set("session1".to_string(), "ep1".to_string());
975        affinity.set("session2".to_string(), "ep2".to_string());
976
977        assert_eq!(affinity.get("session1"), Some("ep1".to_string()));
978        assert_eq!(affinity.get("session2"), Some("ep2".to_string()));
979        assert_eq!(affinity.get("session3"), None);
980
981        affinity.remove("session1");
982        assert_eq!(affinity.get("session1"), None);
983
984        affinity.clear();
985        assert_eq!(affinity.get("session2"), None);
986    }
987
988    // --- Weighted Round Robin Tests ---
989
990    #[test]
991    fn test_weighted_round_robin_proportional_distribution() {
992        let lb = LoadBalancer::new(BalancingStrategy::WeightedRoundRobin);
993
994        lb.add_endpoint(Endpoint::with_weight(
995            "ep1".to_string(),
996            "localhost:50051".to_string(),
997            3,
998        ));
999        lb.add_endpoint(Endpoint::with_weight(
1000            "ep2".to_string(),
1001            "localhost:50052".to_string(),
1002            1,
1003        ));
1004
1005        let mut counts: HashMap<String, usize> = HashMap::new();
1006        for _ in 0..400 {
1007            let ep = lb.select_endpoint().expect("should select endpoint");
1008            *counts.entry(ep.id.clone()).or_insert(0) += 1;
1009        }
1010
1011        let ep1_count = counts.get("ep1").copied().unwrap_or(0);
1012        let ep2_count = counts.get("ep2").copied().unwrap_or(0);
1013
1014        // 3:1 ratio means 75% : 25%, i.e. 300:100 out of 400
1015        assert_eq!(
1016            ep1_count, 300,
1017            "ep1 should get exactly 300 out of 400 (75%)"
1018        );
1019        assert_eq!(
1020            ep2_count, 100,
1021            "ep2 should get exactly 100 out of 400 (25%)"
1022        );
1023    }
1024
1025    #[test]
1026    fn test_weighted_round_robin_smooth_distribution() {
1027        // Nginx-style WRR should not produce bursts like aaab,aaab
1028        // Instead it should produce patterns like a,a,b,a for 3:1
1029        let lb = LoadBalancer::new(BalancingStrategy::WeightedRoundRobin);
1030
1031        lb.add_endpoint(Endpoint::with_weight(
1032            "a".to_string(),
1033            "localhost:1".to_string(),
1034            3,
1035        ));
1036        lb.add_endpoint(Endpoint::with_weight(
1037            "b".to_string(),
1038            "localhost:2".to_string(),
1039            1,
1040        ));
1041
1042        // Collect one full cycle (4 selections for weights 3+1=4)
1043        let mut pattern = Vec::new();
1044        for _ in 0..4 {
1045            let ep = lb.select_endpoint().expect("should select");
1046            pattern.push(ep.id.clone());
1047        }
1048
1049        // In smooth WRR, 'b' should not be last; it should be distributed
1050        // Check that we never get 3 consecutive 'a's
1051        let mut consecutive_a = 0;
1052        let mut max_consecutive_a = 0;
1053        for id in &pattern {
1054            if id == "a" {
1055                consecutive_a += 1;
1056                if consecutive_a > max_consecutive_a {
1057                    max_consecutive_a = consecutive_a;
1058                }
1059            } else {
1060                consecutive_a = 0;
1061            }
1062        }
1063        assert!(
1064            max_consecutive_a <= 2,
1065            "Smooth WRR should not have more than 2 consecutive 'a' selections, got pattern: {pattern:?}"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_weighted_round_robin_zero_weight() {
1071        let lb = LoadBalancer::new(BalancingStrategy::WeightedRoundRobin);
1072
1073        lb.add_endpoint(Endpoint::with_weight(
1074            "ep1".to_string(),
1075            "localhost:50051".to_string(),
1076            0,
1077        ));
1078        lb.add_endpoint(Endpoint::with_weight(
1079            "ep2".to_string(),
1080            "localhost:50052".to_string(),
1081            5,
1082        ));
1083
1084        // Zero weight endpoint should never be selected
1085        for _ in 0..20 {
1086            let ep = lb.select_endpoint().expect("should select endpoint");
1087            assert_eq!(ep.id, "ep2", "Zero-weight endpoint should not be selected");
1088        }
1089    }
1090
1091    #[test]
1092    fn test_weighted_round_robin_all_zero_weight() {
1093        let lb = LoadBalancer::new(BalancingStrategy::WeightedRoundRobin);
1094
1095        lb.add_endpoint(Endpoint::with_weight(
1096            "ep1".to_string(),
1097            "localhost:50051".to_string(),
1098            0,
1099        ));
1100        lb.add_endpoint(Endpoint::with_weight(
1101            "ep2".to_string(),
1102            "localhost:50052".to_string(),
1103            0,
1104        ));
1105
1106        // Should error since all weights are zero
1107        let result = lb.select_endpoint();
1108        assert!(result.is_err());
1109    }
1110
1111    // --- Least Connections Tests ---
1112
1113    #[test]
1114    fn test_least_connections_selects_minimum() {
1115        let lb = LoadBalancer::new(BalancingStrategy::LeastConnections);
1116
1117        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1118        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1119        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1120
1121        // Set connections: a=5, b=2, c=8
1122        let eps = lb.endpoints();
1123        for _ in 0..5 {
1124            eps[0].increment_connections();
1125        }
1126        for _ in 0..2 {
1127            eps[1].increment_connections();
1128        }
1129        for _ in 0..8 {
1130            eps[2].increment_connections();
1131        }
1132
1133        // Should always select 'b' (fewest connections)
1134        let selected = lb.select_endpoint().expect("should select");
1135        assert_eq!(selected.id, "b");
1136    }
1137
1138    #[test]
1139    fn test_least_connections_tie_prefers_lower_index() {
1140        let lb = LoadBalancer::new(BalancingStrategy::LeastConnections);
1141
1142        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1143        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1144        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1145
1146        // All have 0 connections - should pick 'a' (first/lowest index)
1147        let selected = lb.select_endpoint().expect("should select");
1148        assert_eq!(selected.id, "a");
1149    }
1150
1151    #[test]
1152    fn test_least_connections_acquire_release() {
1153        let lb = LoadBalancer::new(BalancingStrategy::LeastConnections);
1154
1155        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1156        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1157
1158        // Acquire on endpoint 0 (a)
1159        lb.acquire(0);
1160        lb.acquire(0);
1161        lb.acquire(1);
1162
1163        // a=2, b=1, so b should be selected
1164        let selected = lb.select_endpoint().expect("should select");
1165        assert_eq!(selected.id, "b");
1166
1167        // Release one from a
1168        lb.release(0);
1169        // a=1, b=1, tie -> prefer lower index (a)
1170        let selected = lb.select_endpoint().expect("should select");
1171        assert_eq!(selected.id, "a");
1172    }
1173
1174    // --- Consistent Hashing Tests ---
1175
1176    #[test]
1177    fn test_consistent_hash_same_key_same_endpoint() {
1178        let lb = LoadBalancer::new(BalancingStrategy::ConsistentHash);
1179
1180        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1181        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1182        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1183
1184        let key = b"user:12345";
1185
1186        // Same key should always map to the same endpoint
1187        let first = lb.select_for_key(key).expect("should select");
1188        for _ in 0..100 {
1189            let ep = lb.select_for_key(key).expect("should select");
1190            assert_eq!(ep.id, first.id, "Same key must always map to same endpoint");
1191        }
1192    }
1193
1194    #[test]
1195    fn test_consistent_hash_different_keys_distribute() {
1196        let lb = LoadBalancer::new(BalancingStrategy::ConsistentHash);
1197
1198        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1199        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1200        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1201
1202        let mut counts: HashMap<String, usize> = HashMap::new();
1203        for i in 0..3000 {
1204            let key = format!("key:{i}");
1205            let ep = lb.select_for_key(key.as_bytes()).expect("should select");
1206            *counts.entry(ep.id.clone()).or_insert(0) += 1;
1207        }
1208
1209        // All endpoints should get at least some keys
1210        assert!(counts.len() == 3, "All 3 endpoints should receive keys");
1211        for count in counts.values() {
1212            assert!(
1213                *count > 100,
1214                "Each endpoint should get a reasonable share of keys"
1215            );
1216        }
1217    }
1218
1219    #[test]
1220    fn test_consistent_hash_add_endpoint_minimal_remap() {
1221        let lb = LoadBalancer::new(BalancingStrategy::ConsistentHash);
1222
1223        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1224        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1225        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1226
1227        // Record initial mapping for 1000 keys
1228        let num_keys = 1000;
1229        let mut initial_mapping: Vec<String> = Vec::with_capacity(num_keys);
1230        for i in 0..num_keys {
1231            let key = format!("key:{i}");
1232            let ep = lb.select_for_key(key.as_bytes()).expect("should select");
1233            initial_mapping.push(ep.id.clone());
1234        }
1235
1236        // Add a 4th endpoint
1237        lb.add_endpoint(Endpoint::new("d".to_string(), "localhost:4".to_string()));
1238
1239        // Count how many keys remapped
1240        let mut remapped = 0;
1241        for (i, prev_id) in initial_mapping.iter().enumerate() {
1242            let key = format!("key:{i}");
1243            let ep = lb.select_for_key(key.as_bytes()).expect("should select");
1244            if ep.id != *prev_id {
1245                remapped += 1;
1246            }
1247        }
1248
1249        // With consistent hashing, adding 1 endpoint to 4 should remap ~25% of keys
1250        // Allow generous margin: should be less than 50%
1251        let remap_pct = (remapped as f64 / num_keys as f64) * 100.0;
1252        assert!(
1253            remap_pct < 50.0,
1254            "Adding an endpoint should remap ~1/N keys, got {remap_pct:.1}% remapped"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_consistent_hash_ring_empty() {
1260        let ring = HashRing::new(150);
1261        assert!(ring.is_empty());
1262        assert!(ring.get_endpoint(b"any-key").is_none());
1263    }
1264
1265    // --- Power of Two Choices Tests ---
1266
1267    #[test]
1268    fn test_power_of_two_chooses_less_loaded() {
1269        let lb = LoadBalancer::new(BalancingStrategy::PowerOfTwo);
1270
1271        // With only 2 endpoints, power of two will always compare them
1272        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1273        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1274
1275        // Make 'a' heavily loaded
1276        let eps = lb.endpoints();
1277        for _ in 0..100 {
1278            eps[0].increment_connections();
1279        }
1280        // b has 0 connections
1281
1282        // Over many selections, 'b' should be selected much more often
1283        let mut b_count = 0;
1284        let total = 100;
1285        for _ in 0..total {
1286            let ep = lb.select_endpoint().expect("should select");
1287            if ep.id == "b" {
1288                b_count += 1;
1289            }
1290        }
1291
1292        // b should be selected in nearly all cases since a has 100 connections
1293        assert!(
1294            b_count > 80,
1295            "Power of two should strongly prefer less loaded endpoint, got b={b_count}/{total}"
1296        );
1297    }
1298
1299    #[test]
1300    fn test_power_of_two_single_endpoint_fallback() {
1301        let lb = LoadBalancer::new(BalancingStrategy::PowerOfTwo);
1302
1303        lb.add_endpoint(Endpoint::new("only".to_string(), "localhost:1".to_string()));
1304
1305        // Should always return the only endpoint
1306        for _ in 0..10 {
1307            let ep = lb.select_endpoint().expect("should select");
1308            assert_eq!(ep.id, "only");
1309        }
1310    }
1311
1312    #[test]
1313    fn test_power_of_two_distributes_with_equal_load() {
1314        let lb = LoadBalancer::new(BalancingStrategy::PowerOfTwo);
1315
1316        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1317        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1318        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1319
1320        let mut counts: HashMap<String, usize> = HashMap::new();
1321        for _ in 0..3000 {
1322            let ep = lb.select_endpoint().expect("should select");
1323            *counts.entry(ep.id.clone()).or_insert(0) += 1;
1324        }
1325
1326        // Each endpoint should get a reasonable share
1327        for count in counts.values() {
1328            assert!(
1329                *count > 200,
1330                "Each endpoint should get a significant share with equal load"
1331            );
1332        }
1333    }
1334
1335    // --- Health Integration Tests ---
1336
1337    #[test]
1338    fn test_mark_unhealthy_skips_in_all_strategies() {
1339        let strategies = [
1340            BalancingStrategy::RoundRobin,
1341            BalancingStrategy::LeastConnections,
1342            BalancingStrategy::PowerOfTwo,
1343            BalancingStrategy::Weighted,
1344        ];
1345
1346        for strategy in &strategies {
1347            let lb = LoadBalancer::new(*strategy);
1348
1349            lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1350            lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1351
1352            lb.mark_unhealthy(0); // Mark 'a' unhealthy
1353
1354            assert_eq!(lb.healthy_count(), 1);
1355
1356            for _ in 0..10 {
1357                let ep = lb.select_endpoint().expect("should select");
1358                assert_eq!(
1359                    ep.id, "b",
1360                    "Strategy {strategy:?} should skip unhealthy endpoint"
1361                );
1362            }
1363
1364            // Re-mark healthy
1365            lb.mark_healthy(0);
1366            assert_eq!(lb.healthy_count(), 2);
1367        }
1368    }
1369
1370    #[test]
1371    fn test_weighted_round_robin_skips_unhealthy() {
1372        let lb = LoadBalancer::new(BalancingStrategy::WeightedRoundRobin);
1373
1374        lb.add_endpoint(Endpoint::with_weight(
1375            "a".to_string(),
1376            "localhost:1".to_string(),
1377            5,
1378        ));
1379        lb.add_endpoint(Endpoint::with_weight(
1380            "b".to_string(),
1381            "localhost:2".to_string(),
1382            3,
1383        ));
1384
1385        lb.mark_unhealthy(0); // Mark 'a' unhealthy
1386
1387        // Only 'b' should be selected
1388        for _ in 0..20 {
1389            let ep = lb.select_endpoint().expect("should select");
1390            assert_eq!(ep.id, "b");
1391        }
1392    }
1393
1394    #[test]
1395    fn test_consistent_hash_skips_unhealthy() {
1396        let lb = LoadBalancer::new(BalancingStrategy::ConsistentHash);
1397
1398        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1399        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1400
1401        lb.mark_unhealthy(0); // Mark 'a' unhealthy
1402
1403        // Any key should resolve to healthy endpoint only
1404        for i in 0..50 {
1405            let key = format!("key:{i}");
1406            let ep = lb.select_for_key(key.as_bytes()).expect("should select");
1407            assert_ne!(
1408                ep.id, "a",
1409                "Should not select unhealthy endpoint for consistent hash"
1410            );
1411        }
1412    }
1413
1414    #[test]
1415    fn test_all_unhealthy_returns_error() {
1416        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
1417
1418        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1419        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1420
1421        lb.mark_unhealthy(0);
1422        lb.mark_unhealthy(1);
1423
1424        assert_eq!(lb.healthy_count(), 0);
1425
1426        let result = lb.select_endpoint();
1427        assert!(result.is_err());
1428    }
1429
1430    // --- Edge Case Tests ---
1431
1432    #[test]
1433    fn test_single_endpoint_all_strategies() {
1434        let strategies = [
1435            BalancingStrategy::RoundRobin,
1436            BalancingStrategy::WeightedRoundRobin,
1437            BalancingStrategy::LeastConnections,
1438            BalancingStrategy::PowerOfTwo,
1439            BalancingStrategy::Weighted,
1440        ];
1441
1442        for strategy in &strategies {
1443            let lb = LoadBalancer::new(*strategy);
1444
1445            lb.add_endpoint(Endpoint::with_weight(
1446                "only".to_string(),
1447                "localhost:1".to_string(),
1448                3,
1449            ));
1450
1451            let ep = lb.select_endpoint().unwrap_or_else(|_| {
1452                panic!("Strategy {strategy:?} should work with single endpoint")
1453            });
1454            assert_eq!(ep.id, "only");
1455        }
1456    }
1457
1458    #[test]
1459    fn test_consistent_hash_single_endpoint() {
1460        let lb = LoadBalancer::new(BalancingStrategy::ConsistentHash);
1461
1462        lb.add_endpoint(Endpoint::new("only".to_string(), "localhost:1".to_string()));
1463
1464        for i in 0..50 {
1465            let key = format!("key:{i}");
1466            let ep = lb.select_for_key(key.as_bytes()).expect("should select");
1467            assert_eq!(ep.id, "only");
1468        }
1469    }
1470
1471    #[test]
1472    fn test_healthy_count() {
1473        let lb = LoadBalancer::new(BalancingStrategy::RoundRobin);
1474
1475        lb.add_endpoint(Endpoint::new("a".to_string(), "localhost:1".to_string()));
1476        lb.add_endpoint(Endpoint::new("b".to_string(), "localhost:2".to_string()));
1477        lb.add_endpoint(Endpoint::new("c".to_string(), "localhost:3".to_string()));
1478
1479        assert_eq!(lb.healthy_count(), 3);
1480
1481        lb.mark_unhealthy(1);
1482        assert_eq!(lb.healthy_count(), 2);
1483
1484        lb.mark_unhealthy(0);
1485        assert_eq!(lb.healthy_count(), 1);
1486
1487        lb.mark_healthy(0);
1488        assert_eq!(lb.healthy_count(), 2);
1489    }
1490
1491    #[test]
1492    fn test_endpoint_weight_struct() {
1493        let ew = EndpointWeight::new(2, 5);
1494        assert_eq!(ew.endpoint_index, 2);
1495        assert_eq!(ew.weight, 5);
1496        assert_eq!(ew.current_weight, 0);
1497        assert_eq!(ew.effective_weight, 5);
1498    }
1499
1500    #[test]
1501    fn test_hash_ring_operations() {
1502        let mut ring = HashRing::new(10); // Fewer vnodes for test
1503        assert!(ring.is_empty());
1504
1505        ring.add_endpoint(0, "ep-a");
1506        assert!(!ring.is_empty());
1507
1508        // Should always return index 0 since it's the only endpoint
1509        let idx = ring.get_endpoint(b"test-key");
1510        assert_eq!(idx, Some(0));
1511
1512        ring.add_endpoint(1, "ep-b");
1513
1514        // Should still be deterministic
1515        let idx1 = ring.get_endpoint(b"test-key");
1516        let idx2 = ring.get_endpoint(b"test-key");
1517        assert_eq!(idx1, idx2);
1518
1519        ring.remove_endpoint("ep-a");
1520        // Now all keys should go to endpoint 1
1521        let idx = ring.get_endpoint(b"test-key");
1522        assert_eq!(idx, Some(1));
1523    }
1524}