Skip to main content

zentinel_proxy/upstream/
consistent_hash.rs

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
15/// Hard bound for the lookup cache. Key hashes come from request data, so the
16/// cache must not grow with attacker-controlled cardinality. When full it is
17/// cleared (cheap: entries are rebuilt from the ring on demand).
18const LOOKUP_CACHE_MAX: usize = 10_000;
19
20/// Insert into the bounded lookup cache, clearing it first if at capacity.
21async 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/// Hash function types supported by the consistent hash balancer
35#[derive(Debug, Clone, Copy)]
36pub enum HashFunction {
37    Xxh3,
38    Murmur3,
39    DefaultHasher,
40}
41
42/// Configuration for consistent hashing
43#[derive(Debug, Clone)]
44pub struct ConsistentHashConfig {
45    /// Number of virtual nodes per real target
46    pub virtual_nodes: usize,
47    /// Hash function to use
48    pub hash_function: HashFunction,
49    /// Enable bounded loads to prevent overload
50    pub bounded_loads: bool,
51    /// Maximum load factor (1.0 = average load, 1.25 = 25% above average)
52    pub max_load_factor: f64,
53    /// Key extraction function (e.g., from headers, cookies)
54    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/// Defines how to extract the hash key from a request
70#[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/// Virtual node in the consistent hash ring
90#[derive(Debug, Clone)]
91struct VirtualNode {
92    /// Hash value of this virtual node
93    hash: u64,
94    /// Index of the real target this virtual node represents
95    target_index: usize,
96    /// Virtual node number for this target
97    virtual_index: usize,
98}
99
100/// Consistent hash load balancer with virtual nodes and bounded loads
101pub struct ConsistentHashBalancer {
102    /// Configuration
103    config: ConsistentHashConfig,
104    /// All upstream targets
105    targets: Vec<UpstreamTarget>,
106    /// Hash ring (sorted by hash value)
107    ring: Arc<RwLock<BTreeMap<u64, VirtualNode>>>,
108    /// Target health status
109    health_status: Arc<RwLock<HashMap<String, bool>>>,
110    /// Active connection count per target (for bounded loads)
111    connection_counts: Vec<Arc<AtomicU64>>,
112    /// Total active connections
113    total_connections: Arc<AtomicU64>,
114    /// Cache for recent hash lookups (hash -> target_index).
115    /// Bounded at [`LOOKUP_CACHE_MAX`]: key hashes are request-derived
116    /// (client IP, headers), so unbounded growth would be attacker-steerable.
117    lookup_cache: Arc<RwLock<HashMap<u64, usize>>>,
118    /// Generation counter for detecting ring changes
119    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        // Build initial ring
151        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    /// Rebuild the hash ring based on current targets and health
164    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            // Add virtual nodes for this target
188            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        // Clear cache on ring change
229        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    /// Hash a key using the configured hash function
241    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    /// Find target using consistent hashing with optional bounded loads
262    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        // Check cache first
273        {
274            let cache = self.lookup_cache.read().await;
275            if let Some(&target_index) = cache.get(&key_hash) {
276                // Verify target is still healthy
277                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        // Find the first virtual node with hash >= key_hash
304        let candidates = if let Some((&_node_hash, vnode)) = ring.range(key_hash..).next() {
305            vec![vnode.clone()]
306        } else {
307            // Wrap around to the first node
308            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 bounded loads is disabled, return the first candidate
321        if !self.config.bounded_loads {
322            let target_index = candidates.first().map(|n| n.target_index);
323
324            // Update cache
325            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        // Bounded loads: check if target is overloaded
338        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        // Try candidates in order until we find one that's not overloaded
349        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                // Update cache
361                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        // If all candidates are overloaded, find least loaded target
378        self.find_least_loaded_target().await
379    }
380
381    /// Calculate average load across all healthy targets
382    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    /// Find the least loaded target when all consistent hash candidates are overloaded
402    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    /// Extract hash key from request context
449    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                // Parse cookie header and extract specific cookie
455                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    /// Track connection acquisition
479    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    /// Track connection release
491    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        // Extract hash key from context or use random fallback
528        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                // Generate random key for requests without proper hash key
533                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        // Track connection for bounded loads
548        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        // Rebuild ring if health status changed
588        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); // Release lock before rebuild
596            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    /// Release connection when request completes
625    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        // Test distribution of 10000 keys
676        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        // Check that distribution is relatively even (within 50% of average)
696        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        // Simulate high load on first target
720        balancer.connection_counts[0].store(100, Ordering::Relaxed);
721        balancer.total_connections.store(110, Ordering::Relaxed);
722
723        // New request should avoid overloaded target
724        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        // Should not select the overloaded target (index 0)
739        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        // Mark a target as unhealthy
752        balancer.report_health("10.0.0.1:8080", false).await;
753
754        // Generation should have incremented
755        let new_generation = balancer.generation.load(Ordering::SeqCst);
756        assert_eq!(new_generation, initial_generation + 1);
757
758        // Unhealthy target should not be selected
759        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}