522 lines
18 KiB
Rust
522 lines
18 KiB
Rust
//! Consistent hashing routing policy with header-based routing support
|
|
//!
|
|
//! Supports two routing mechanisms via HTTP headers:
|
|
//! - `X-SMG-Target-Worker`: Direct routing by worker index (0-based), returns None if unavailable
|
|
//! - `X-SMG-Routing-Key`: Consistent hash routing for session affinity
|
|
//!
|
|
//! ## Consistent Hashing
|
|
//!
|
|
//! Uses a pre-computed hash ring from WorkerRegistry where:
|
|
//! 1. Each worker is placed at a fixed position based on hash(worker_url)
|
|
//! 2. Keys are hashed to the ring, then walk clockwise to find first healthy worker
|
|
//! 3. When workers scale up/down, only keys in the affected range redistribute (~1/N keys move)
|
|
//!
|
|
//! The ring is built once when workers are added/removed, not per-request.
|
|
//! This ensures O(log n) lookup performance.
|
|
//!
|
|
//! Complexity: O(log n) binary search + O(k) walk where k = consecutive unhealthy workers.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use rand::Rng as _;
|
|
|
|
use super::{LoadBalancingPolicy, SelectWorkerInfo};
|
|
use crate::{
|
|
core::Worker,
|
|
observability::metrics::Metrics,
|
|
routers::header_utils::{extract_routing_key, extract_target_worker},
|
|
};
|
|
|
|
/// Execution branch for metrics
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Branch {
|
|
NoHealthyWorkers,
|
|
TargetWorkerHit,
|
|
TargetWorkerMiss,
|
|
RoutingKeyHit,
|
|
RandomFallback,
|
|
}
|
|
|
|
impl Branch {
|
|
#[inline]
|
|
const fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::NoHealthyWorkers => "no_healthy_workers",
|
|
Self::TargetWorkerHit => "target_worker_hit",
|
|
Self::TargetWorkerMiss => "target_worker_miss",
|
|
Self::RoutingKeyHit => "routing_key_hit",
|
|
Self::RandomFallback => "random_fallback",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct ConsistentHashingPolicy;
|
|
|
|
impl ConsistentHashingPolicy {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
/// Use consistent hashing to find a worker for the given key.
|
|
/// Uses pre-computed ring from SelectWorkerInfo if available.
|
|
///
|
|
/// The ring returns a worker URL, which we then map to an index in the workers array.
|
|
/// This correctly handles filtered worker arrays since we match by URL, not by index.
|
|
///
|
|
/// Complexity: O(n) to build healthy URL map + O(log n) ring lookup + O(k) walk
|
|
fn find_by_consistent_hash(
|
|
workers: &[Arc<dyn Worker>],
|
|
info: &SelectWorkerInfo,
|
|
key: &str,
|
|
) -> Option<usize> {
|
|
// Build URL→index map for healthy workers: O(n) once, O(1) lookups
|
|
let healthy_url_to_idx: std::collections::HashMap<&str, usize> = workers
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, w)| w.is_healthy())
|
|
.map(|(i, w)| (w.url(), i))
|
|
.collect();
|
|
|
|
if healthy_url_to_idx.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Use pre-computed ring if available
|
|
if let Some(ref ring) = info.hash_ring {
|
|
// O(1) lookup per URL checked instead of O(n)
|
|
let url = ring.find_healthy_url(key, |url| healthy_url_to_idx.contains_key(url))?;
|
|
return healthy_url_to_idx.get(url).copied();
|
|
}
|
|
|
|
// Fallback: no ring provided, use simple modulo (less optimal but functional)
|
|
// This shouldn't happen in normal operation as WorkerSelectionStage provides the ring
|
|
let mut healthy_indices: Vec<usize> = healthy_url_to_idx.values().copied().collect();
|
|
healthy_indices.sort_unstable(); // Ensure deterministic order
|
|
|
|
// Use blake3 for consistent hashing in fallback too
|
|
let hash = blake3::hash(key.as_bytes());
|
|
let hash_val = u64::from_le_bytes(hash.as_bytes()[..8].try_into().unwrap());
|
|
let idx = (hash_val as usize) % healthy_indices.len();
|
|
Some(healthy_indices[idx])
|
|
}
|
|
|
|
fn select_worker_impl(
|
|
&self,
|
|
workers: &[Arc<dyn Worker>],
|
|
info: &SelectWorkerInfo,
|
|
) -> (Option<usize>, Branch) {
|
|
if workers.is_empty() {
|
|
return (None, Branch::NoHealthyWorkers);
|
|
}
|
|
|
|
let target_worker = extract_target_worker(info.headers);
|
|
let routing_key = extract_routing_key(info.headers);
|
|
|
|
// Priority 1: X-SMG-Target-Worker - direct routing by worker index
|
|
// O(1) parse + O(1) bounds check + O(1) health check
|
|
if let Some(idx_str) = target_worker {
|
|
if let Ok(idx) = idx_str.parse::<usize>() {
|
|
if idx < workers.len() && workers[idx].is_healthy() {
|
|
return (Some(idx), Branch::TargetWorkerHit);
|
|
}
|
|
}
|
|
return (None, Branch::TargetWorkerMiss);
|
|
}
|
|
|
|
// Priority 2: X-SMG-Routing-Key - consistent hash routing (O(log n))
|
|
if let Some(key) = routing_key {
|
|
return match Self::find_by_consistent_hash(workers, info, key) {
|
|
Some(idx) => (Some(idx), Branch::RoutingKeyHit),
|
|
None => (None, Branch::NoHealthyWorkers),
|
|
};
|
|
}
|
|
|
|
// Priority 3: Implicit routing key from stable headers (session affinity)
|
|
let implicit_key = info.headers.and_then(|h| {
|
|
h.get("authorization")
|
|
.or_else(|| h.get("x-forwarded-for"))
|
|
.or_else(|| h.get("cookie"))
|
|
.and_then(|v| v.to_str().ok())
|
|
.filter(|s| !s.is_empty())
|
|
});
|
|
|
|
if let Some(key) = implicit_key {
|
|
return match Self::find_by_consistent_hash(workers, info, key) {
|
|
Some(idx) => (Some(idx), Branch::RoutingKeyHit),
|
|
None => (None, Branch::NoHealthyWorkers),
|
|
};
|
|
}
|
|
|
|
// Fallback: random selection (truly anonymous client)
|
|
let healthy_count = workers.iter().filter(|w| w.is_healthy()).count();
|
|
if healthy_count == 0 {
|
|
return (None, Branch::NoHealthyWorkers);
|
|
}
|
|
|
|
let random_healthy_idx = rand::rng().random_range(0..healthy_count);
|
|
let idx = workers
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, w)| w.is_healthy())
|
|
.nth(random_healthy_idx)
|
|
.map(|(i, _)| i)
|
|
.unwrap();
|
|
|
|
(Some(idx), Branch::RandomFallback)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LoadBalancingPolicy for ConsistentHashingPolicy {
|
|
async fn select_worker(
|
|
&self,
|
|
workers: &[Arc<dyn Worker>],
|
|
info: &SelectWorkerInfo<'_>,
|
|
) -> Option<usize> {
|
|
let (result, branch) = self.select_worker_impl(workers, info);
|
|
Metrics::record_worker_consistent_hashing_policy_branch(branch.as_str());
|
|
result
|
|
}
|
|
|
|
fn name(&self) -> &'static str {
|
|
"consistent_hashing"
|
|
}
|
|
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use super::*;
|
|
use crate::core::{BasicWorkerBuilder, HashRing, WorkerType};
|
|
|
|
fn headers_with_routing_key(key: &str) -> http::HeaderMap {
|
|
let mut headers = http::HeaderMap::new();
|
|
headers.insert("x-smg-routing-key", key.parse().unwrap());
|
|
headers
|
|
}
|
|
|
|
fn headers_with_target_worker(idx: usize) -> http::HeaderMap {
|
|
let mut headers = http::HeaderMap::new();
|
|
headers.insert("x-smg-target-worker", idx.to_string().parse().unwrap());
|
|
headers
|
|
}
|
|
|
|
fn create_workers(urls: &[&str]) -> Vec<Arc<dyn Worker>> {
|
|
urls.iter()
|
|
.map(|url| {
|
|
Arc::new(
|
|
BasicWorkerBuilder::new(*url)
|
|
.worker_type(WorkerType::Regular)
|
|
.build(),
|
|
) as Arc<dyn Worker>
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_consistent_routing() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000", "http://w3:8000"]);
|
|
|
|
let headers = headers_with_routing_key("user-123");
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (first_result, _) = policy.select_worker_impl(&workers, &info);
|
|
let first_idx = first_result.unwrap();
|
|
|
|
// Same key should always route to same worker
|
|
for _ in 0..10 {
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, Some(first_idx));
|
|
assert_eq!(branch, Branch::RoutingKeyHit);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_different_keys_distribute() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000", "http://w3:8000"]);
|
|
|
|
let mut distribution = HashMap::new();
|
|
for i in 0..100 {
|
|
let headers = headers_with_routing_key(&format!("user-{}", i));
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
let (result, _) = policy.select_worker_impl(&workers, &info);
|
|
*distribution.entry(result.unwrap()).or_insert(0) += 1;
|
|
}
|
|
|
|
assert!(distribution.len() > 1, "Should distribute across workers");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_target_worker_hit() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000"]);
|
|
|
|
let headers = headers_with_target_worker(1);
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, Some(1));
|
|
assert_eq!(branch, Branch::TargetWorkerHit);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_target_worker_miss_out_of_bounds() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000"]);
|
|
|
|
let headers = headers_with_target_worker(5); // Out of bounds
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, None);
|
|
assert_eq!(branch, Branch::TargetWorkerMiss);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_target_worker_miss_unhealthy() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000"]);
|
|
workers[1].set_healthy(false);
|
|
|
|
let headers = headers_with_target_worker(1);
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, None);
|
|
assert_eq!(branch, Branch::TargetWorkerMiss);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_target_worker_priority_over_routing_key() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000"]);
|
|
|
|
let mut headers = http::HeaderMap::new();
|
|
headers.insert("x-smg-target-worker", "1".parse().unwrap());
|
|
headers.insert("x-smg-routing-key", "some-key".parse().unwrap());
|
|
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, Some(1));
|
|
assert_eq!(branch, Branch::TargetWorkerHit);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fallback_random_distribution() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000", "http://w3:8000"]);
|
|
|
|
// Without routing headers, should distribute randomly across workers
|
|
let mut distribution = HashMap::new();
|
|
for _ in 0..100 {
|
|
let info = SelectWorkerInfo::default();
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert!(result.is_some());
|
|
assert_eq!(branch, Branch::RandomFallback);
|
|
*distribution.entry(result.unwrap()).or_insert(0) += 1;
|
|
}
|
|
|
|
// Should distribute across multiple workers (not always same one)
|
|
assert!(
|
|
distribution.len() > 1,
|
|
"Random fallback should distribute across workers"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_healthy_workers() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000"]);
|
|
workers[0].set_healthy(false);
|
|
|
|
let headers = headers_with_routing_key("test");
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, None);
|
|
assert_eq!(branch, Branch::NoHealthyWorkers);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_workers() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers: Vec<Arc<dyn Worker>> = vec![];
|
|
|
|
let info = SelectWorkerInfo::default();
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, None);
|
|
assert_eq!(branch, Branch::NoHealthyWorkers);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_consistent_hash_minimal_redistribution() {
|
|
// Test that consistent hashing moves fewer keys than random redistribution
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&[
|
|
"http://w0:8000",
|
|
"http://w1:8000",
|
|
"http://w2:8000",
|
|
"http://w3:8000",
|
|
]);
|
|
let ring = Arc::new(HashRing::new(&workers));
|
|
|
|
// Record which worker each key routes to with all workers healthy
|
|
let mut key_to_worker_before: HashMap<String, usize> = HashMap::new();
|
|
for i in 0..100 {
|
|
let key = format!("user-{}", i);
|
|
let headers = headers_with_routing_key(&key);
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
hash_ring: Some(ring.clone()),
|
|
..Default::default()
|
|
};
|
|
let (result, _) = policy.select_worker_impl(&workers, &info);
|
|
key_to_worker_before.insert(key, result.unwrap());
|
|
}
|
|
|
|
// Mark worker 1 as unhealthy
|
|
workers[1].set_healthy(false);
|
|
|
|
// Record new routing and count how many keys moved
|
|
let mut moved_count = 0;
|
|
for i in 0..100 {
|
|
let key = format!("user-{}", i);
|
|
let headers = headers_with_routing_key(&key);
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
hash_ring: Some(ring.clone()),
|
|
..Default::default()
|
|
};
|
|
let (result, _) = policy.select_worker_impl(&workers, &info);
|
|
let new_worker = result.unwrap();
|
|
let old_worker = key_to_worker_before[&key];
|
|
|
|
if new_worker != old_worker {
|
|
moved_count += 1;
|
|
}
|
|
}
|
|
|
|
// With consistent hashing, approximately 1/N keys should move (N = worker count)
|
|
// Random redistribution would move approximately (N-1)/N = 75% of keys
|
|
// Verify we're significantly better than random (< 50% moved)
|
|
let keys_on_failed_worker = key_to_worker_before.values().filter(|&&w| w == 1).count();
|
|
assert!(
|
|
moved_count <= keys_on_failed_worker + 5,
|
|
"Consistent hashing should only move keys from failed worker (+small variance). \
|
|
Expected ~{}, got {}",
|
|
keys_on_failed_worker,
|
|
moved_count
|
|
);
|
|
assert!(
|
|
moved_count < 50,
|
|
"Consistent hashing should move fewer than 50% of keys (random would move ~75%), got {}%",
|
|
moved_count
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_routing_key_failover_and_recovery() {
|
|
// Test that when a worker fails, keys move to another worker,
|
|
// and when it recovers, keys return to the original worker
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w0:8000", "http://w1:8000", "http://w2:8000"]);
|
|
let ring = Arc::new(HashRing::new(&workers));
|
|
|
|
// Find which worker a key routes to when all are healthy
|
|
let test_key = "session-abc-123";
|
|
let headers = headers_with_routing_key(test_key);
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
hash_ring: Some(ring.clone()),
|
|
..Default::default()
|
|
};
|
|
let (result, _) = policy.select_worker_impl(&workers, &info);
|
|
let original_idx = result.unwrap();
|
|
|
|
// Mark that worker unhealthy
|
|
workers[original_idx].set_healthy(false);
|
|
|
|
// Key should now route to a different healthy worker
|
|
let (failover_result, _) = policy.select_worker_impl(&workers, &info);
|
|
let failover_idx = failover_result.unwrap();
|
|
assert_ne!(
|
|
failover_idx, original_idx,
|
|
"Should failover to different worker"
|
|
);
|
|
assert!(
|
|
workers[failover_idx].is_healthy(),
|
|
"Failover target should be healthy"
|
|
);
|
|
|
|
// Failover should be consistent
|
|
for _ in 0..5 {
|
|
let (result, _) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(result, Some(failover_idx), "Failover should be consistent");
|
|
}
|
|
|
|
// Recover the original worker
|
|
workers[original_idx].set_healthy(true);
|
|
|
|
// Key should route back to original worker
|
|
let (recovered_result, _) = policy.select_worker_impl(&workers, &info);
|
|
assert_eq!(
|
|
recovered_result,
|
|
Some(original_idx),
|
|
"Should return to original worker after recovery"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_routing_key_uses_fallback() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
let workers = create_workers(&["http://w1:8000", "http://w2:8000"]);
|
|
|
|
let headers = headers_with_routing_key("");
|
|
let info = SelectWorkerInfo {
|
|
headers: Some(&headers),
|
|
..Default::default()
|
|
};
|
|
|
|
let (result, branch) = policy.select_worker_impl(&workers, &info);
|
|
assert!(result.is_some());
|
|
assert_eq!(branch, Branch::RandomFallback);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_policy_name() {
|
|
let policy = ConsistentHashingPolicy::new();
|
|
assert_eq!(policy.name(), "consistent_hashing");
|
|
}
|
|
}
|