1use 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
17pub type EndpointId = String;
19
20pub type Weight = u32;
22
23#[derive(Debug, Clone)]
25pub struct Endpoint {
26 pub id: EndpointId,
28 pub address: String,
30 pub weight: Weight,
32 pub active_connections: Arc<AtomicUsize>,
34 pub total_requests: Arc<AtomicU64>,
36 pub healthy: Arc<parking_lot::RwLock<bool>>,
38}
39
40impl Endpoint {
41 pub fn new(id: EndpointId, address: String) -> Self {
43 Self::with_weight(id, address, 1)
44 }
45
46 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 pub fn is_healthy(&self) -> bool {
60 *self.healthy.read()
61 }
62
63 pub fn mark_healthy(&self) {
65 *self.healthy.write() = true;
66 }
67
68 pub fn mark_unhealthy(&self) {
70 *self.healthy.write() = false;
71 }
72
73 pub fn active_connections(&self) -> usize {
75 self.active_connections.load(Ordering::Relaxed)
76 }
77
78 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 pub fn decrement_connections(&self) {
86 self.active_connections.fetch_sub(1, Ordering::Relaxed);
87 }
88
89 pub fn total_requests(&self) -> u64 {
91 self.total_requests.load(Ordering::Relaxed)
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum BalancingStrategy {
98 RoundRobin,
100 WeightedRoundRobin,
102 LeastConnections,
104 ConsistentHash,
106 PowerOfTwo,
108 Weighted,
110}
111
112#[derive(Debug, Clone)]
114pub struct EndpointWeight {
115 pub endpoint_index: usize,
117 pub weight: i64,
119 pub current_weight: i64,
121 pub effective_weight: i64,
123}
124
125impl EndpointWeight {
126 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#[derive(Debug)]
140pub struct HashRing {
141 ring: BTreeMap<u64, usize>,
143 virtual_nodes: usize,
145}
146
147impl HashRing {
148 pub const DEFAULT_VIRTUAL_NODES: usize = 150;
150
151 pub fn new(virtual_nodes: usize) -> Self {
153 Self {
154 ring: BTreeMap::new(),
155 virtual_nodes,
156 }
157 }
158
159 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 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 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 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 if let Some((&_node_hash, &idx)) = self.ring.range(hash..).next() {
200 Some(idx)
201 } else {
202 self.ring.values().next().copied()
204 }
205 }
206
207 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 pub fn is_empty(&self) -> bool {
218 self.ring.is_empty()
219 }
220}
221
222#[derive(Debug, Clone)]
224pub struct Affinity {
225 sessions: Arc<RwLock<HashMap<String, EndpointId>>>,
227}
228
229impl Affinity {
230 pub fn new() -> Self {
232 Self {
233 sessions: Arc::new(RwLock::new(HashMap::new())),
234 }
235 }
236
237 pub fn get(&self, session_id: &str) -> Option<EndpointId> {
239 self.sessions.read().get(session_id).cloned()
240 }
241
242 pub fn set(&self, session_id: String, endpoint_id: EndpointId) {
244 self.sessions.write().insert(session_id, endpoint_id);
245 }
246
247 pub fn remove(&self, session_id: &str) {
249 self.sessions.write().remove(session_id);
250 }
251
252 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#[derive(Debug)]
266pub struct LoadBalancer {
267 strategy: BalancingStrategy,
269 endpoints: Arc<RwLock<Vec<Arc<Endpoint>>>>,
271 round_robin_index: AtomicUsize,
273 affinity: Affinity,
275 wrr_state: RwLock<Vec<EndpointWeight>>,
277 hash_ring: RwLock<HashRing>,
279 power_counter: AtomicUsize,
281}
282
283impl LoadBalancer {
284 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 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 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 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 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 pub fn endpoints(&self) -> Vec<Arc<Endpoint>> {
342 self.endpoints.read().clone()
343 }
344
345 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 pub fn healthy_count(&self) -> usize {
357 self.endpoints
358 .read()
359 .iter()
360 .filter(|e| e.is_healthy())
361 .count()
362 }
363
364 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 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 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 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 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 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 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 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 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 pub fn select_with_affinity(&self, session_id: &str) -> NetResult<Arc<Endpoint>> {
471 if let Some(endpoint_id) = self.affinity.get(session_id) {
473 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 let endpoint = self.select_endpoint()?;
485 self.affinity
486 .set(session_id.to_string(), endpoint.id.clone());
487 Ok(endpoint)
488 }
489
490 pub fn clear_affinity(&self, session_id: &str) {
492 self.affinity.remove(session_id);
493 }
494
495 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 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 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 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 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 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 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 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 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 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 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 let total_weight: u32 = endpoints.iter().map(|e| e.weight).sum();
651
652 if total_weight == 0 {
653 return self.select_round_robin(endpoints);
655 }
656
657 let selector = self.round_robin_index.fetch_add(1, Ordering::Relaxed) as u32;
659 let target = selector % total_weight;
660
661 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 Ok(Arc::clone(&endpoints[endpoints.len() - 1]))
672 }
673}
674
675#[derive(Debug, Clone)]
677pub struct BalancerStats {
678 pub total_endpoints: usize,
680 pub healthy_endpoints: usize,
682 pub total_connections: usize,
684 pub total_requests: u64,
686 pub strategy: BalancingStrategy,
688}
689
690pub struct ConnectionGuard {
692 endpoint: Arc<Endpoint>,
693}
694
695impl ConnectionGuard {
696 pub fn new(endpoint: Arc<Endpoint>) -> Self {
698 endpoint.increment_connections();
699 Self { endpoint }
700 }
701
702 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 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"); }
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 let ep1 = lb.select_endpoint().expect("should select endpoint");
798 assert_eq!(ep1.id, "ep1");
799 ep1.increment_connections();
800
801 let ep2 = lb.select_endpoint().expect("should select endpoint");
803 assert_eq!(ep2.id, "ep2");
804
805 ep2.increment_connections();
806 ep2.increment_connections(); 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 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 let ep1_count = counts.get("ep1").copied().unwrap_or(0);
837 let ep2_count = counts.get("ep2").copied().unwrap_or(0);
838
839 assert!(ep1_count > ep2_count);
841 assert!(ep1_count >= 20); }
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 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 let ep1 = lb
887 .select_with_affinity(session_id)
888 .expect("should select endpoint");
889
890 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 lb.clear_affinity(session_id);
903
904 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 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 #[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 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 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 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 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 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 let result = lb.select_endpoint();
1108 assert!(result.is_err());
1109 }
1110
1111 #[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 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 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 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 lb.acquire(0);
1160 lb.acquire(0);
1161 lb.acquire(1);
1162
1163 let selected = lb.select_endpoint().expect("should select");
1165 assert_eq!(selected.id, "b");
1166
1167 lb.release(0);
1169 let selected = lb.select_endpoint().expect("should select");
1171 assert_eq!(selected.id, "a");
1172 }
1173
1174 #[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 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 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 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 lb.add_endpoint(Endpoint::new("d".to_string(), "localhost:4".to_string()));
1238
1239 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 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 #[test]
1268 fn test_power_of_two_chooses_less_loaded() {
1269 let lb = LoadBalancer::new(BalancingStrategy::PowerOfTwo);
1270
1271 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 let eps = lb.endpoints();
1277 for _ in 0..100 {
1278 eps[0].increment_connections();
1279 }
1280 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 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 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 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 #[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); 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 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); 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); 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 #[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); assert!(ring.is_empty());
1504
1505 ring.add_endpoint(0, "ep-a");
1506 assert!(!ring.is_empty());
1507
1508 let idx = ring.get_endpoint(b"test-key");
1510 assert_eq!(idx, Some(0));
1511
1512 ring.add_endpoint(1, "ep-b");
1513
1514 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 let idx = ring.get_endpoint(b"test-key");
1522 assert_eq!(idx, Some(1));
1523 }
1524}