1use murmur3::murmur3_32;
2use std::collections::{BTreeMap, HashMap};
3use std::hash::{Hash, Hasher};
4use std::io::Cursor;
5use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
6use std::sync::Arc;
7use tokio::sync::RwLock;
8use xxhash_rust::xxh3::Xxh3;
9
10use super::{LoadBalancer, RequestContext, TargetSelection, UpstreamTarget};
11use async_trait::async_trait;
12use tracing::{debug, info, trace, warn};
13use zentinel_common::errors::{ZentinelError, ZentinelResult};
14
15const LOOKUP_CACHE_MAX: usize = 10_000;
19
20async fn bounded_cache_insert(cache: &RwLock<HashMap<u64, usize>>, key_hash: u64, idx: usize) {
22 let mut cache = cache.write().await;
23 if cache.len() >= LOOKUP_CACHE_MAX {
24 debug!(
25 size = cache.len(),
26 max = LOOKUP_CACHE_MAX,
27 "Consistent-hash lookup cache full; clearing"
28 );
29 cache.clear();
30 }
31 cache.insert(key_hash, idx);
32}
33
34#[derive(Debug, Clone, Copy)]
36pub enum HashFunction {
37 Xxh3,
38 Murmur3,
39 DefaultHasher,
40}
41
42#[derive(Debug, Clone)]
44pub struct ConsistentHashConfig {
45 pub virtual_nodes: usize,
47 pub hash_function: HashFunction,
49 pub bounded_loads: bool,
51 pub max_load_factor: f64,
53 pub hash_key_extractor: HashKeyExtractor,
55}
56
57impl Default for ConsistentHashConfig {
58 fn default() -> Self {
59 Self {
60 virtual_nodes: 150,
61 hash_function: HashFunction::Xxh3,
62 bounded_loads: true,
63 max_load_factor: 1.25,
64 hash_key_extractor: HashKeyExtractor::ClientIp,
65 }
66 }
67}
68
69#[derive(Clone)]
71pub enum HashKeyExtractor {
72 ClientIp,
73 Header(String),
74 Cookie(String),
75 Custom(Arc<dyn Fn(&RequestContext) -> Option<String> + Send + Sync>),
76}
77
78impl std::fmt::Debug for HashKeyExtractor {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::ClientIp => write!(f, "ClientIp"),
82 Self::Header(h) => write!(f, "Header({})", h),
83 Self::Cookie(c) => write!(f, "Cookie({})", c),
84 Self::Custom(_) => write!(f, "Custom"),
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91struct VirtualNode {
92 hash: u64,
94 target_index: usize,
96 virtual_index: usize,
98}
99
100pub struct ConsistentHashBalancer {
102 config: ConsistentHashConfig,
104 targets: Vec<UpstreamTarget>,
106 ring: Arc<RwLock<BTreeMap<u64, VirtualNode>>>,
108 health_status: Arc<RwLock<HashMap<String, bool>>>,
110 connection_counts: Vec<Arc<AtomicU64>>,
112 total_connections: Arc<AtomicU64>,
114 lookup_cache: Arc<RwLock<HashMap<u64, usize>>>,
118 generation: Arc<AtomicUsize>,
120}
121
122impl ConsistentHashBalancer {
123 pub fn new(targets: Vec<UpstreamTarget>, config: ConsistentHashConfig) -> Self {
124 trace!(
125 target_count = targets.len(),
126 virtual_nodes = config.virtual_nodes,
127 hash_function = ?config.hash_function,
128 bounded_loads = config.bounded_loads,
129 max_load_factor = config.max_load_factor,
130 hash_key_extractor = ?config.hash_key_extractor,
131 "Creating consistent hash balancer"
132 );
133
134 let connection_counts = targets
135 .iter()
136 .map(|_| Arc::new(AtomicU64::new(0)))
137 .collect();
138
139 let balancer = Self {
140 config,
141 targets: targets.clone(),
142 ring: Arc::new(RwLock::new(BTreeMap::new())),
143 health_status: Arc::new(RwLock::new(HashMap::new())),
144 connection_counts,
145 total_connections: Arc::new(AtomicU64::new(0)),
146 lookup_cache: Arc::new(RwLock::new(HashMap::with_capacity(1000))),
147 generation: Arc::new(AtomicUsize::new(0)),
148 };
149
150 tokio::task::block_in_place(|| {
152 tokio::runtime::Handle::current().block_on(balancer.rebuild_ring());
153 });
154
155 debug!(
156 target_count = targets.len(),
157 "Consistent hash balancer initialized"
158 );
159
160 balancer
161 }
162
163 async fn rebuild_ring(&self) {
165 trace!(
166 total_targets = self.targets.len(),
167 virtual_nodes_per_target = self.config.virtual_nodes,
168 "Starting hash ring rebuild"
169 );
170
171 let mut new_ring = BTreeMap::new();
172 let health = self.health_status.read().await;
173
174 for (index, target) in self.targets.iter().enumerate() {
175 let target_id = format!("{}:{}", target.address, target.port);
176 let is_healthy = health.get(&target_id).copied().unwrap_or(true);
177
178 if !is_healthy {
179 trace!(
180 target_id = %target_id,
181 target_index = index,
182 "Skipping unhealthy target in ring rebuild"
183 );
184 continue;
185 }
186
187 for vnode in 0..self.config.virtual_nodes {
189 let vnode_key = format!("{}-vnode-{}", target_id, vnode);
190 let hash = self.hash_key(&vnode_key);
191
192 new_ring.insert(
193 hash,
194 VirtualNode {
195 hash,
196 target_index: index,
197 virtual_index: vnode,
198 },
199 );
200 }
201
202 trace!(
203 target_id = %target_id,
204 target_index = index,
205 vnodes_added = self.config.virtual_nodes,
206 "Added virtual nodes for target"
207 );
208 }
209
210 let healthy_count = new_ring
211 .values()
212 .map(|n| n.target_index)
213 .collect::<std::collections::HashSet<_>>()
214 .len();
215
216 if new_ring.is_empty() {
217 warn!("No healthy targets available for consistent hash ring");
218 } else {
219 info!(
220 virtual_nodes = new_ring.len(),
221 healthy_targets = healthy_count,
222 "Rebuilt consistent hash ring"
223 );
224 }
225
226 *self.ring.write().await = new_ring;
227
228 let cache_size = self.lookup_cache.read().await.len();
230 self.lookup_cache.write().await.clear();
231 let new_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
232
233 trace!(
234 cache_entries_cleared = cache_size,
235 new_generation = new_generation,
236 "Ring rebuild complete, cache cleared"
237 );
238 }
239
240 fn hash_key(&self, key: &str) -> u64 {
242 match self.config.hash_function {
243 HashFunction::Xxh3 => {
244 let mut hasher = Xxh3::new();
245 hasher.update(key.as_bytes());
246 hasher.digest()
247 }
248 HashFunction::Murmur3 => {
249 let mut cursor = Cursor::new(key.as_bytes());
250 murmur3_32(&mut cursor, 0).unwrap_or(0) as u64
251 }
252 HashFunction::DefaultHasher => {
253 use std::collections::hash_map::DefaultHasher;
254 let mut hasher = DefaultHasher::new();
255 key.hash(&mut hasher);
256 hasher.finish()
257 }
258 }
259 }
260
261 async fn find_target(&self, hash_key: &str) -> Option<usize> {
263 let key_hash = self.hash_key(hash_key);
264
265 trace!(
266 hash_key = %hash_key,
267 key_hash = key_hash,
268 bounded_loads = self.config.bounded_loads,
269 "Finding target for hash key"
270 );
271
272 {
274 let cache = self.lookup_cache.read().await;
275 if let Some(&target_index) = cache.get(&key_hash) {
276 let health = self.health_status.read().await;
278 let target = &self.targets[target_index];
279 let target_id = format!("{}:{}", target.address, target.port);
280 if health.get(&target_id).copied().unwrap_or(true) {
281 trace!(
282 hash_key = %hash_key,
283 target_index = target_index,
284 "Cache hit for hash key"
285 );
286 return Some(target_index);
287 }
288 trace!(
289 hash_key = %hash_key,
290 target_index = target_index,
291 "Cache hit but target unhealthy"
292 );
293 }
294 }
295
296 let ring = self.ring.read().await;
297
298 if ring.is_empty() {
299 warn!("Hash ring is empty, no targets available");
300 return None;
301 }
302
303 let candidates = if let Some((&_node_hash, vnode)) = ring.range(key_hash..).next() {
305 vec![vnode.clone()]
306 } else {
307 ring.iter()
309 .next()
310 .map(|(_, vnode)| vec![vnode.clone()])
311 .unwrap_or_default()
312 };
313
314 trace!(
315 hash_key = %hash_key,
316 candidate_count = candidates.len(),
317 "Found candidates on hash ring"
318 );
319
320 if !self.config.bounded_loads {
322 let target_index = candidates.first().map(|n| n.target_index);
323
324 if let Some(idx) = target_index {
326 bounded_cache_insert(&self.lookup_cache, key_hash, idx).await;
327 trace!(
328 hash_key = %hash_key,
329 target_index = idx,
330 "Selected target (no bounded loads)"
331 );
332 }
333
334 return target_index;
335 }
336
337 let avg_load = self.calculate_average_load().await;
339 let max_load = (avg_load * self.config.max_load_factor) as u64;
340
341 trace!(
342 avg_load = avg_load,
343 max_load = max_load,
344 max_load_factor = self.config.max_load_factor,
345 "Checking bounded loads"
346 );
347
348 for vnode in candidates {
350 let current_load = self.connection_counts[vnode.target_index].load(Ordering::Relaxed);
351
352 trace!(
353 target_index = vnode.target_index,
354 current_load = current_load,
355 max_load = max_load,
356 "Evaluating candidate load"
357 );
358
359 if current_load <= max_load {
360 bounded_cache_insert(&self.lookup_cache, key_hash, vnode.target_index).await;
362 debug!(
363 hash_key = %hash_key,
364 target_index = vnode.target_index,
365 current_load = current_load,
366 "Selected target within load bounds"
367 );
368 return Some(vnode.target_index);
369 }
370 }
371
372 trace!(
373 hash_key = %hash_key,
374 "All candidates overloaded, falling back to least loaded"
375 );
376
377 self.find_least_loaded_target().await
379 }
380
381 async fn calculate_average_load(&self) -> f64 {
383 let health = self.health_status.read().await;
384 let healthy_count = self
385 .targets
386 .iter()
387 .filter(|t| {
388 let target_id = format!("{}:{}", t.address, t.port);
389 health.get(&target_id).copied().unwrap_or(true)
390 })
391 .count();
392
393 if healthy_count == 0 {
394 return 0.0;
395 }
396
397 let total = self.total_connections.load(Ordering::Relaxed);
398 total as f64 / healthy_count as f64
399 }
400
401 async fn find_least_loaded_target(&self) -> Option<usize> {
403 trace!("Finding least loaded target as fallback");
404
405 let health = self.health_status.read().await;
406
407 let mut min_load = u64::MAX;
408 let mut best_target = None;
409
410 for (index, target) in self.targets.iter().enumerate() {
411 let target_id = format!("{}:{}", target.address, target.port);
412 if !health.get(&target_id).copied().unwrap_or(true) {
413 trace!(
414 target_index = index,
415 target_id = %target_id,
416 "Skipping unhealthy target"
417 );
418 continue;
419 }
420
421 let load = self.connection_counts[index].load(Ordering::Relaxed);
422 trace!(
423 target_index = index,
424 target_id = %target_id,
425 load = load,
426 "Evaluating target load"
427 );
428
429 if load < min_load {
430 min_load = load;
431 best_target = Some(index);
432 }
433 }
434
435 if let Some(idx) = best_target {
436 debug!(
437 target_index = idx,
438 load = min_load,
439 "Selected least loaded target"
440 );
441 } else {
442 warn!("No healthy targets found for least loaded selection");
443 }
444
445 best_target
446 }
447
448 pub fn extract_hash_key(&self, context: &RequestContext) -> Option<String> {
450 let key = match &self.config.hash_key_extractor {
451 HashKeyExtractor::ClientIp => context.client_ip.map(|ip| ip.to_string()),
452 HashKeyExtractor::Header(name) => context.headers.get(name).cloned(),
453 HashKeyExtractor::Cookie(name) => {
454 context.headers.get("cookie").and_then(|cookies| {
456 cookies.split(';').find_map(|cookie| {
457 let parts: Vec<&str> = cookie.trim().splitn(2, '=').collect();
458 if parts.len() == 2 && parts[0] == name {
459 Some(parts[1].to_string())
460 } else {
461 None
462 }
463 })
464 })
465 }
466 HashKeyExtractor::Custom(extractor) => extractor(context),
467 };
468
469 trace!(
470 extractor = ?self.config.hash_key_extractor,
471 key_found = key.is_some(),
472 "Extracted hash key from request"
473 );
474
475 key
476 }
477
478 pub fn acquire_connection(&self, target_index: usize) {
480 let count = self.connection_counts[target_index].fetch_add(1, Ordering::Relaxed) + 1;
481 let total = self.total_connections.fetch_add(1, Ordering::Relaxed) + 1;
482 trace!(
483 target_index = target_index,
484 target_connections = count,
485 total_connections = total,
486 "Acquired connection"
487 );
488 }
489
490 pub fn release_connection(&self, target_index: usize) {
492 let prev_count = self.connection_counts[target_index].fetch_sub(1, Ordering::Relaxed);
493 if prev_count == 0 {
494 self.connection_counts[target_index].fetch_add(1, Ordering::Relaxed);
495 warn!(
496 "Attempted to decrement connection count below zero for target {}",
497 target_index
498 );
499 return;
500 }
501 let count = prev_count - 1;
502 let prev_total = self.total_connections.fetch_sub(1, Ordering::Relaxed);
503 let total = if prev_total == 0 {
504 self.total_connections.fetch_add(1, Ordering::Relaxed);
505 warn!("Attempted to decrement total connections below zero");
506 return;
507 } else {
508 prev_total - 1
509 };
510 trace!(
511 target_index = target_index,
512 target_connections = count,
513 total_connections = total,
514 "Released connection"
515 );
516 }
517}
518
519#[async_trait]
520impl LoadBalancer for ConsistentHashBalancer {
521 async fn select(&self, context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
522 trace!(
523 has_context = context.is_some(),
524 "Consistent hash select called"
525 );
526
527 let (hash_key, used_random) = context
529 .and_then(|ctx| self.extract_hash_key(ctx))
530 .map(|k| (k, false))
531 .unwrap_or_else(|| {
532 use rand::RngExt;
534 let mut rng = rand::rng();
535 let key = format!("random-{}", rng.random::<u64>());
536 trace!(random_key = %key, "Generated random hash key (no context key)");
537 (key, true)
538 });
539
540 let target_index = self.find_target(&hash_key).await.ok_or_else(|| {
541 warn!("No healthy upstream targets available");
542 ZentinelError::NoHealthyUpstream
543 })?;
544
545 let target = &self.targets[target_index];
546
547 if self.config.bounded_loads {
549 self.acquire_connection(target_index);
550 }
551
552 let current_load = self.connection_counts[target_index].load(Ordering::Relaxed);
553
554 debug!(
555 target = %format!("{}:{}", target.address, target.port),
556 hash_key = %hash_key,
557 target_index = target_index,
558 current_load = current_load,
559 used_random_key = used_random,
560 "Consistent hash selected target"
561 );
562
563 Ok(TargetSelection {
564 address: format!("{}:{}", target.address, target.port),
565 weight: target.weight,
566 metadata: {
567 let mut meta = HashMap::new();
568 meta.insert("hash_key".to_string(), hash_key);
569 meta.insert("target_index".to_string(), target_index.to_string());
570 meta.insert("load".to_string(), current_load.to_string());
571 meta.insert("algorithm".to_string(), "consistent_hash".to_string());
572 meta
573 },
574 })
575 }
576
577 async fn report_health(&self, address: &str, healthy: bool) {
578 trace!(
579 address = %address,
580 healthy = healthy,
581 "Reporting target health"
582 );
583
584 let mut health = self.health_status.write().await;
585 let previous = health.insert(address.to_string(), healthy);
586
587 if previous != Some(healthy) {
589 info!(
590 address = %address,
591 previous_status = ?previous,
592 new_status = healthy,
593 "Target health changed, rebuilding ring"
594 );
595 drop(health); self.rebuild_ring().await;
597 }
598 }
599
600 async fn healthy_targets(&self) -> Vec<String> {
601 let health = self.health_status.read().await;
602 let targets: Vec<String> = self
603 .targets
604 .iter()
605 .filter_map(|t| {
606 let target_id = format!("{}:{}", t.address, t.port);
607 if health.get(&target_id).copied().unwrap_or(true) {
608 Some(target_id)
609 } else {
610 None
611 }
612 })
613 .collect();
614
615 trace!(
616 total_targets = self.targets.len(),
617 healthy_count = targets.len(),
618 "Retrieved healthy targets"
619 );
620
621 targets
622 }
623
624 async fn release(&self, selection: &TargetSelection) {
626 if self.config.bounded_loads {
627 if let Some(index_str) = selection.metadata.get("target_index") {
628 if let Ok(index) = index_str.parse::<usize>() {
629 trace!(
630 target_index = index,
631 address = %selection.address,
632 "Releasing connection for bounded loads"
633 );
634 self.release_connection(index);
635 }
636 }
637 }
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[tokio::test]
646 async fn lookup_cache_never_exceeds_bound() {
647 let cache = RwLock::new(HashMap::new());
648 for i in 0..(LOOKUP_CACHE_MAX as u64 + 500) {
649 bounded_cache_insert(&cache, i, 0).await;
650 assert!(cache.read().await.len() <= LOOKUP_CACHE_MAX);
651 }
652 }
653
654 fn create_test_targets(count: usize) -> Vec<UpstreamTarget> {
655 (0..count)
656 .map(|i| UpstreamTarget {
657 address: format!("10.0.0.{}", i + 1),
658 port: 8080,
659 weight: 100,
660 })
661 .collect()
662 }
663
664 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
665 async fn test_consistent_distribution() {
666 let targets = create_test_targets(5);
667 let config = ConsistentHashConfig {
668 virtual_nodes: 100,
669 bounded_loads: false,
670 ..Default::default()
671 };
672
673 let balancer = ConsistentHashBalancer::new(targets.clone(), config);
674
675 let mut distribution = vec![0u64; targets.len()];
677
678 for i in 0..10000 {
679 let context = RequestContext {
680 client_ip: Some(format!("192.168.1.{}:1234", i % 256).parse().unwrap()),
681 headers: HashMap::new(),
682 path: "/".to_string(),
683 method: "GET".to_string(),
684 };
685
686 if let Ok(selection) = balancer.select(Some(&context)).await {
687 if let Some(index_str) = selection.metadata.get("target_index") {
688 if let Ok(index) = index_str.parse::<usize>() {
689 distribution[index] += 1;
690 }
691 }
692 }
693 }
694
695 let avg = 10000.0 / targets.len() as f64;
697 for count in distribution {
698 let ratio = count as f64 / avg;
699 assert!(
700 ratio > 0.5 && ratio < 1.5,
701 "Distribution too skewed: {}",
702 ratio
703 );
704 }
705 }
706
707 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
708 async fn test_bounded_loads() {
709 let targets = create_test_targets(3);
710 let config = ConsistentHashConfig {
711 virtual_nodes: 50,
712 bounded_loads: true,
713 max_load_factor: 1.2,
714 ..Default::default()
715 };
716
717 let balancer = ConsistentHashBalancer::new(targets.clone(), config);
718
719 balancer.connection_counts[0].store(100, Ordering::Relaxed);
721 balancer.total_connections.store(110, Ordering::Relaxed);
722
723 let context = RequestContext {
725 client_ip: Some("192.168.1.1:1234".parse().unwrap()),
726 headers: HashMap::new(),
727 path: "/".to_string(),
728 method: "GET".to_string(),
729 };
730
731 let selection = balancer.select(Some(&context)).await.unwrap();
732 let index = selection
733 .metadata
734 .get("target_index")
735 .and_then(|s| s.parse::<usize>().ok())
736 .unwrap();
737
738 assert_ne!(index, 0);
740 }
741
742 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
743 async fn test_ring_rebuild_on_health_change() {
744 let targets = create_test_targets(3);
745 let config = ConsistentHashConfig::default();
746
747 let balancer = ConsistentHashBalancer::new(targets.clone(), config);
748
749 let initial_generation = balancer.generation.load(Ordering::SeqCst);
750
751 balancer.report_health("10.0.0.1:8080", false).await;
753
754 let new_generation = balancer.generation.load(Ordering::SeqCst);
756 assert_eq!(new_generation, initial_generation + 1);
757
758 let healthy = balancer.healthy_targets().await;
760 assert_eq!(healthy.len(), 2);
761 assert!(!healthy.contains(&"10.0.0.1:8080".to_string()));
762 }
763}