Skip to main content

ipfrs_semantic/
shard_coordinator.rs

1//! Shard Coordinator — Consistent-Hash Distribution of Vectors Across Nodes
2//!
3//! At 1M+ vectors a single HNSW index is too large to fit on one node.
4//! This module distributes vectors across logical *shards* using a consistent
5//! hash ring so that:
6//!
7//! * Every `vector_id` maps deterministically to the same shard across the
8//!   cluster without any central lookup table.
9//! * Adding or removing a shard only re-hashes a minimal fraction of the
10//!   keyspace (the usual consistent-hashing guarantee).
11//! * The coordinator detects when shards are imbalanced and surfaces which
12//!   shards are over- or under-loaded so the operator (or an auto-scaler) can
13//!   trigger a rebalance.
14//!
15//! # Design Notes
16//!
17//! The hash ring uses **FNV-1a** (64-bit) because it is fast, has no
18//! dependencies, and distributes keys uniformly for short byte strings.
19//! Each physical shard is given `virtual_nodes` (default 150) positions on
20//! the ring, which provides the load balance guarantee of consistent hashing.
21//!
22//! All mutable state inside [`ShardCoordinator`] is protected by
23//! `std::sync::RwLock` so the struct is `Send + Sync` and can be shared
24//! freely across async tasks.
25
26use std::collections::{BTreeMap, HashMap};
27use std::fmt;
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::{Arc, RwLock};
30
31use thiserror::Error;
32
33// ---------------------------------------------------------------------------
34// Public Error type
35// ---------------------------------------------------------------------------
36
37/// Errors produced by the shard coordinator.
38#[derive(Debug, Error)]
39pub enum ShardError {
40    /// No shard with the given numeric ID is registered.
41    #[error("shard {0} not found")]
42    ShardNotFound(u32),
43
44    /// The target shard has reached its maximum capacity.
45    #[error("shard {shard_id} is at capacity ({capacity} vectors)")]
46    ShardAtCapacity {
47        /// The numeric shard ID that is full.
48        shard_id: u32,
49        /// The capacity limit that was reached.
50        capacity: u64,
51    },
52}
53
54// ---------------------------------------------------------------------------
55// ShardId newtype
56// ---------------------------------------------------------------------------
57
58/// Opaque identifier for a logical shard.
59///
60/// Internally a `u32`, but exposed as a newtype so callers cannot accidentally
61/// mix raw integers with shard IDs.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63pub struct ShardId(pub u32);
64
65impl fmt::Display for ShardId {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "shard-{}", self.0)
68    }
69}
70
71impl From<u32> for ShardId {
72    fn from(v: u32) -> Self {
73        ShardId(v)
74    }
75}
76
77// ---------------------------------------------------------------------------
78// VectorShard — per-shard metadata
79// ---------------------------------------------------------------------------
80
81/// Metadata and load information for one logical shard.
82#[derive(Debug, Clone)]
83pub struct VectorShard {
84    /// Logical shard identifier.
85    pub shard_id: ShardId,
86    /// Network address / peer ID of the node that owns this shard.
87    pub peer_id: String,
88    /// Current number of vectors stored in this shard.
89    pub vector_count: u64,
90    /// Maximum number of vectors before this shard is considered full.
91    pub capacity: u64,
92    /// Embedding dimensionality stored in this shard.
93    pub dimensions: u32,
94}
95
96impl VectorShard {
97    /// Create a new shard with default capacity (`100_000`).
98    pub fn new(shard_id: ShardId, peer_id: impl Into<String>, dimensions: u32) -> Self {
99        Self {
100            shard_id,
101            peer_id: peer_id.into(),
102            vector_count: 0,
103            capacity: 100_000,
104            dimensions,
105        }
106    }
107
108    /// Fraction of capacity currently in use, in the range `[0.0, ∞)`.
109    ///
110    /// Values above `1.0` mean the shard has exceeded its configured capacity.
111    pub fn utilization(&self) -> f64 {
112        if self.capacity == 0 {
113            return 0.0;
114        }
115        self.vector_count as f64 / self.capacity as f64
116    }
117}
118
119// ---------------------------------------------------------------------------
120// FNV-1a hash helpers
121// ---------------------------------------------------------------------------
122
123/// 64-bit FNV-1a offset basis.
124const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
125/// FNV-1a prime.
126const FNV_PRIME: u64 = 1_099_511_628_211;
127
128/// Compute the FNV-1a 64-bit hash of a byte slice.
129#[inline]
130fn fnv1a_64(data: &[u8]) -> u64 {
131    let mut hash = FNV_OFFSET_BASIS;
132    for &byte in data {
133        hash ^= u64::from(byte);
134        hash = hash.wrapping_mul(FNV_PRIME);
135    }
136    hash
137}
138
139/// Derive a deterministic virtual-node key from a shard ID and a replica index.
140///
141/// The key is `"shard-{shard_id}#{replica}"` encoded as UTF-8 bytes.
142#[inline]
143fn virtual_node_key(shard_id: ShardId, replica: usize) -> u64 {
144    let label = format!("shard-{}#{}", shard_id.0, replica);
145    fnv1a_64(label.as_bytes())
146}
147
148// ---------------------------------------------------------------------------
149// ConsistentHashRing
150// ---------------------------------------------------------------------------
151
152/// A consistent-hash ring mapping arbitrary byte keys to [`ShardId`]s.
153///
154/// Each shard occupies `virtual_nodes` (default 150) positions on the ring,
155/// providing excellent key distribution even with a small number of shards.
156///
157/// ## Lookup algorithm
158///
159/// 1. Hash the key with FNV-1a.
160/// 2. Find the first ring position ≥ the hash (wrap-around to the minimum
161///    position if none exists — standard consistent-hashing).
162/// 3. Return the [`ShardId`] at that ring position.
163#[derive(Debug, Clone)]
164pub struct ConsistentHashRing {
165    /// Sorted map: ring position (FNV-1a hash) → shard ID.
166    ring: BTreeMap<u64, ShardId>,
167    /// Number of virtual-node positions per physical shard.
168    virtual_nodes: usize,
169}
170
171impl Default for ConsistentHashRing {
172    fn default() -> Self {
173        Self {
174            ring: BTreeMap::new(),
175            virtual_nodes: 150,
176        }
177    }
178}
179
180impl ConsistentHashRing {
181    /// Create a new ring with the given number of virtual nodes per shard.
182    pub fn new(virtual_nodes: usize) -> Self {
183        Self {
184            ring: BTreeMap::new(),
185            virtual_nodes,
186        }
187    }
188
189    /// Add `shard_id` to the ring, placing `virtual_nodes` replicas.
190    ///
191    /// `peer_id` is accepted for future extensibility (e.g., zone-aware
192    /// placement) but is not stored inside the ring itself — it lives in
193    /// [`VectorShard`].
194    pub fn add_shard(&mut self, shard_id: ShardId, _peer_id: &str) {
195        for replica in 0..self.virtual_nodes {
196            let position = virtual_node_key(shard_id, replica);
197            self.ring.insert(position, shard_id);
198        }
199    }
200
201    /// Remove `shard_id` from the ring, deleting all its virtual nodes.
202    pub fn remove_shard(&mut self, shard_id: ShardId) {
203        for replica in 0..self.virtual_nodes {
204            let position = virtual_node_key(shard_id, replica);
205            self.ring.remove(&position);
206        }
207    }
208
209    /// Find the shard responsible for `key`.
210    ///
211    /// Returns `None` only when the ring is empty.
212    pub fn get_shard(&self, key: &[u8]) -> Option<ShardId> {
213        if self.ring.is_empty() {
214            return None;
215        }
216        let hash = fnv1a_64(key);
217        // Walk clockwise from `hash` — wrap around to the minimum if needed.
218        self.ring
219            .range(hash..)
220            .next()
221            .or_else(|| self.ring.iter().next())
222            .map(|(_, &shard)| shard)
223    }
224
225    /// Number of *distinct* shards currently on the ring.
226    pub fn shard_count(&self) -> usize {
227        // Collect the set of unique shard IDs.
228        let mut seen = std::collections::HashSet::new();
229        for shard_id in self.ring.values() {
230            seen.insert(*shard_id);
231        }
232        seen.len()
233    }
234}
235
236// ---------------------------------------------------------------------------
237// ShardStats — lock-free counters
238// ---------------------------------------------------------------------------
239
240/// Atomic counters tracking lifetime activity of the coordinator.
241#[derive(Debug, Default)]
242pub struct ShardStats {
243    /// Total number of vector → shard assignments performed.
244    pub total_assignments: AtomicU64,
245    /// Number of times [`ShardCoordinator::needs_rebalance`] returned `true`.
246    pub total_rebalances_triggered: AtomicU64,
247    /// Total number of shards registered via [`ShardCoordinator::register_shard`].
248    pub total_shards_registered: AtomicU64,
249}
250
251/// A point-in-time snapshot of [`ShardStats`], for easy display / serialisation.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct ShardStatsSnapshot {
254    /// Total assignments at snapshot time.
255    pub total_assignments: u64,
256    /// Total rebalances triggered at snapshot time.
257    pub total_rebalances_triggered: u64,
258    /// Total shards registered at snapshot time.
259    pub total_shards_registered: u64,
260}
261
262impl ShardStats {
263    /// Take a consistent snapshot (using `SeqCst` loads).
264    pub fn snapshot(&self) -> ShardStatsSnapshot {
265        ShardStatsSnapshot {
266            total_assignments: self.total_assignments.load(Ordering::SeqCst),
267            total_rebalances_triggered: self.total_rebalances_triggered.load(Ordering::SeqCst),
268            total_shards_registered: self.total_shards_registered.load(Ordering::SeqCst),
269        }
270    }
271}
272
273// ---------------------------------------------------------------------------
274// ShardCoordinator — main entry-point
275// ---------------------------------------------------------------------------
276
277/// Coordinates the distribution of vectors across a cluster of shards.
278///
279/// # Thread safety
280///
281/// [`ShardCoordinator`] wraps its mutable state in `std::sync::RwLock` and
282/// exposes only shared references (`&self`) from every public method.  It can
283/// therefore be placed in an `Arc` and shared freely across async tasks:
284///
285/// ```rust,ignore
286/// let coord = Arc::new(ShardCoordinator::new(0.2));
287/// ```
288pub struct ShardCoordinator {
289    /// Map of numeric shard ID → shard metadata.
290    shards: RwLock<HashMap<u32, VectorShard>>,
291    /// Consistent hash ring.
292    ring: RwLock<ConsistentHashRing>,
293    /// Maximum allowed deviation from the mean utilization before a rebalance
294    /// is flagged.  For example `0.2` means 20%.
295    rebalance_threshold: f64,
296    /// Lifetime statistics (lock-free).
297    pub stats: Arc<ShardStats>,
298}
299
300impl ShardCoordinator {
301    /// Create a coordinator with a custom rebalance threshold.
302    ///
303    /// `rebalance_threshold` is a fraction in `(0, 1)`.  A typical value is
304    /// `0.2` (flag rebalance when any shard deviates > 20% from the mean).
305    pub fn new(rebalance_threshold: f64) -> Self {
306        Self {
307            shards: RwLock::new(HashMap::new()),
308            ring: RwLock::new(ConsistentHashRing::default()),
309            rebalance_threshold,
310            stats: Arc::new(ShardStats::default()),
311        }
312    }
313
314    /// Create a coordinator with the default rebalance threshold of `0.2`.
315    pub fn with_defaults() -> Self {
316        Self::new(0.2)
317    }
318
319    // -----------------------------------------------------------------------
320    // Shard lifecycle
321    // -----------------------------------------------------------------------
322
323    /// Register a new shard with the coordinator.
324    ///
325    /// This adds the shard to both the metadata map and the consistent hash
326    /// ring.  If a shard with the same ID is already registered it is replaced.
327    pub fn register_shard(&self, shard: VectorShard) {
328        let shard_id = shard.shard_id;
329        let peer_id = shard.peer_id.clone();
330        {
331            let mut shards = self
332                .shards
333                .write()
334                .expect("shard registry write lock poisoned");
335            shards.insert(shard_id.0, shard);
336        }
337        {
338            let mut ring = self.ring.write().expect("ring write lock poisoned");
339            ring.add_shard(shard_id, &peer_id);
340        }
341        self.stats
342            .total_shards_registered
343            .fetch_add(1, Ordering::Relaxed);
344    }
345
346    // -----------------------------------------------------------------------
347    // Assignment
348    // -----------------------------------------------------------------------
349
350    /// Assign a vector to a shard using consistent hashing on `vector_id`.
351    ///
352    /// Returns `None` only when no shards have been registered yet.
353    pub fn assign_vector(&self, vector_id: &str) -> Option<ShardId> {
354        let ring = self.ring.read().expect("ring read lock poisoned");
355        let result = ring.get_shard(vector_id.as_bytes());
356        drop(ring);
357        if result.is_some() {
358            self.stats.total_assignments.fetch_add(1, Ordering::Relaxed);
359        }
360        result
361    }
362
363    /// Increment the vector counter for the given shard.
364    ///
365    /// # Errors
366    ///
367    /// * [`ShardError::ShardNotFound`] — the shard ID is not registered.
368    /// * [`ShardError::ShardAtCapacity`] — the shard is already at its capacity
369    ///   limit.
370    pub fn increment_shard_count(&self, shard_id: ShardId) -> Result<(), ShardError> {
371        let mut shards = self
372            .shards
373            .write()
374            .expect("shard registry write lock poisoned");
375        match shards.get_mut(&shard_id.0) {
376            None => Err(ShardError::ShardNotFound(shard_id.0)),
377            Some(shard) => {
378                if shard.vector_count >= shard.capacity {
379                    Err(ShardError::ShardAtCapacity {
380                        shard_id: shard_id.0,
381                        capacity: shard.capacity,
382                    })
383                } else {
384                    shard.vector_count += 1;
385                    Ok(())
386                }
387            }
388        }
389    }
390
391    // -----------------------------------------------------------------------
392    // Rebalance detection
393    // -----------------------------------------------------------------------
394
395    /// Return `true` when at least one shard's utilization deviates from the
396    /// mean by more than `rebalance_threshold`.
397    ///
398    /// When this returns `true` the `total_rebalances_triggered` counter is
399    /// incremented.
400    pub fn needs_rebalance(&self) -> bool {
401        let shards = self
402            .shards
403            .read()
404            .expect("shard registry read lock poisoned");
405        if shards.len() < 2 {
406            return false;
407        }
408        let utils: Vec<f64> = shards.values().map(|s| s.utilization()).collect();
409        let mean = utils.iter().sum::<f64>() / utils.len() as f64;
410        let diverges = utils
411            .iter()
412            .any(|&u| (u - mean).abs() > self.rebalance_threshold);
413        if diverges {
414            self.stats
415                .total_rebalances_triggered
416                .fetch_add(1, Ordering::Relaxed);
417        }
418        diverges
419    }
420
421    /// Return the IDs of shards whose utilization exceeds `mean + threshold`.
422    pub fn overloaded_shards(&self) -> Vec<ShardId> {
423        let shards = self
424            .shards
425            .read()
426            .expect("shard registry read lock poisoned");
427        if shards.is_empty() {
428            return Vec::new();
429        }
430        let utils: Vec<(ShardId, f64)> = shards
431            .values()
432            .map(|s| (s.shard_id, s.utilization()))
433            .collect();
434        let mean = utils.iter().map(|(_, u)| u).sum::<f64>() / utils.len() as f64;
435        utils
436            .into_iter()
437            .filter(|(_, u)| *u > mean + self.rebalance_threshold)
438            .map(|(id, _)| id)
439            .collect()
440    }
441
442    /// Return the IDs of shards whose utilization is below `mean - threshold`.
443    pub fn underloaded_shards(&self) -> Vec<ShardId> {
444        let shards = self
445            .shards
446            .read()
447            .expect("shard registry read lock poisoned");
448        if shards.is_empty() {
449            return Vec::new();
450        }
451        let utils: Vec<(ShardId, f64)> = shards
452            .values()
453            .map(|s| (s.shard_id, s.utilization()))
454            .collect();
455        let mean = utils.iter().map(|(_, u)| u).sum::<f64>() / utils.len() as f64;
456        utils
457            .into_iter()
458            .filter(|(_, u)| *u < mean - self.rebalance_threshold)
459            .map(|(id, _)| id)
460            .collect()
461    }
462
463    // -----------------------------------------------------------------------
464    // Accessors
465    // -----------------------------------------------------------------------
466
467    /// Number of shards currently registered.
468    pub fn shard_count(&self) -> usize {
469        self.shards
470            .read()
471            .expect("shard registry read lock poisoned")
472            .len()
473    }
474
475    /// Total vectors stored across all shards.
476    pub fn total_vectors(&self) -> u64 {
477        self.shards
478            .read()
479            .expect("shard registry read lock poisoned")
480            .values()
481            .map(|s| s.vector_count)
482            .sum()
483    }
484}
485
486// ---------------------------------------------------------------------------
487// Tests
488// ---------------------------------------------------------------------------
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    // Helper: build a coordinator pre-populated with `n` identical shards.
495    fn make_coordinator_with_shards(n: u32, capacity: u64) -> ShardCoordinator {
496        let coord = ShardCoordinator::with_defaults();
497        for i in 0..n {
498            let mut shard = VectorShard::new(ShardId(i), format!("peer-{}", i), 128);
499            shard.capacity = capacity;
500            coord.register_shard(shard);
501        }
502        coord
503    }
504
505    // -----------------------------------------------------------------------
506    // ShardId
507    // -----------------------------------------------------------------------
508
509    #[test]
510    fn test_shard_id_display() {
511        let id = ShardId(42);
512        assert_eq!(id.to_string(), "shard-42");
513    }
514
515    #[test]
516    fn test_shard_id_from_u32() {
517        let id: ShardId = 7_u32.into();
518        assert_eq!(id.0, 7);
519    }
520
521    // -----------------------------------------------------------------------
522    // VectorShard
523    // -----------------------------------------------------------------------
524
525    #[test]
526    fn test_vector_shard_utilization() {
527        let mut shard = VectorShard::new(ShardId(0), "peer-0", 128);
528        shard.vector_count = 50_000;
529        // 50_000 / 100_000 = 0.5
530        let util = shard.utilization();
531        assert!((util - 0.5).abs() < f64::EPSILON);
532    }
533
534    #[test]
535    fn test_vector_shard_utilization_zero_capacity() {
536        let mut shard = VectorShard::new(ShardId(0), "peer-0", 128);
537        shard.capacity = 0;
538        assert_eq!(shard.utilization(), 0.0);
539    }
540
541    // -----------------------------------------------------------------------
542    // ConsistentHashRing — basic operations
543    // -----------------------------------------------------------------------
544
545    #[test]
546    fn test_ring_empty_returns_none() {
547        let ring = ConsistentHashRing::default();
548        assert!(ring.get_shard(b"anything").is_none());
549    }
550
551    #[test]
552    fn test_ring_deterministic_assignment() {
553        let mut ring = ConsistentHashRing::default();
554        ring.add_shard(ShardId(0), "peer-0");
555        ring.add_shard(ShardId(1), "peer-1");
556        ring.add_shard(ShardId(2), "peer-2");
557
558        let key = b"vector-12345";
559        let first = ring.get_shard(key).expect("ring is not empty");
560        // Calling get_shard again must return the exact same shard.
561        for _ in 0..50 {
562            assert_eq!(ring.get_shard(key), Some(first));
563        }
564    }
565
566    #[test]
567    fn test_ring_same_key_same_shard_after_rebuild() {
568        let mut ring1 = ConsistentHashRing::new(150);
569        ring1.add_shard(ShardId(10), "peer-10");
570        ring1.add_shard(ShardId(20), "peer-20");
571        let key = b"stable-key";
572        let shard1 = ring1.get_shard(key);
573
574        // Build an identical ring independently.
575        let mut ring2 = ConsistentHashRing::new(150);
576        ring2.add_shard(ShardId(10), "peer-10");
577        ring2.add_shard(ShardId(20), "peer-20");
578        let shard2 = ring2.get_shard(key);
579
580        assert_eq!(shard1, shard2);
581    }
582
583    #[test]
584    fn test_ring_remove_shard_redistributes() {
585        let mut ring = ConsistentHashRing::default();
586        ring.add_shard(ShardId(0), "peer-0");
587        ring.add_shard(ShardId(1), "peer-1");
588
589        // Collect 200 keys and their assignments before removal.
590        let keys: Vec<Vec<u8>> = (0_u64..200)
591            .map(|i| format!("key-{}", i).into_bytes())
592            .collect();
593        let before: Vec<ShardId> = keys
594            .iter()
595            .map(|k| {
596                ring.get_shard(k)
597                    .expect("test: ring is non-empty before removal")
598            })
599            .collect();
600
601        // Remove shard 1.
602        ring.remove_shard(ShardId(1));
603        assert_eq!(ring.shard_count(), 1);
604
605        // Every key should now map to shard 0.
606        let after: Vec<ShardId> = keys
607            .iter()
608            .map(|k| {
609                ring.get_shard(k)
610                    .expect("test: ring still has shard 0 after removing shard 1")
611            })
612            .collect();
613
614        for a in &after {
615            assert_eq!(*a, ShardId(0));
616        }
617
618        // At least some keys must have changed shard.
619        let changed = before
620            .iter()
621            .zip(after.iter())
622            .filter(|(b, a)| b != a)
623            .count();
624        assert!(changed > 0, "expected some keys to be redistributed");
625    }
626
627    #[test]
628    fn test_ring_shard_count() {
629        let mut ring = ConsistentHashRing::default();
630        assert_eq!(ring.shard_count(), 0);
631        ring.add_shard(ShardId(0), "peer-0");
632        assert_eq!(ring.shard_count(), 1);
633        ring.add_shard(ShardId(1), "peer-1");
634        assert_eq!(ring.shard_count(), 2);
635        ring.remove_shard(ShardId(0));
636        assert_eq!(ring.shard_count(), 1);
637    }
638
639    // -----------------------------------------------------------------------
640    // ShardCoordinator — registration and assignment
641    // -----------------------------------------------------------------------
642
643    #[test]
644    fn test_register_shard_and_assign_vector() {
645        let coord = make_coordinator_with_shards(3, 100_000);
646        assert_eq!(coord.shard_count(), 3);
647
648        let shard = coord.assign_vector("my-vector-id");
649        assert!(shard.is_some());
650    }
651
652    #[test]
653    fn test_assign_vector_deterministic() {
654        let coord = make_coordinator_with_shards(4, 100_000);
655        let id = "deterministic-vector";
656        let first = coord
657            .assign_vector(id)
658            .expect("test: coordinator has 4 shards so assignment cannot be None");
659        for _ in 0..20 {
660            assert_eq!(coord.assign_vector(id), Some(first));
661        }
662    }
663
664    #[test]
665    fn test_assign_vector_no_shards_returns_none() {
666        let coord = ShardCoordinator::with_defaults();
667        assert!(coord.assign_vector("v").is_none());
668    }
669
670    // -----------------------------------------------------------------------
671    // increment_shard_count
672    // -----------------------------------------------------------------------
673
674    #[test]
675    fn test_increment_shard_count_success() {
676        let coord = make_coordinator_with_shards(2, 100_000);
677        let result = coord.increment_shard_count(ShardId(0));
678        assert!(result.is_ok());
679        assert_eq!(coord.total_vectors(), 1);
680    }
681
682    #[test]
683    fn test_increment_shard_count_not_found() {
684        let coord = make_coordinator_with_shards(1, 100_000);
685        let err = coord
686            .increment_shard_count(ShardId(99))
687            .expect_err("test: ShardId(99) is not registered so error is expected");
688        assert!(matches!(err, ShardError::ShardNotFound(99)));
689    }
690
691    #[test]
692    fn test_increment_shard_count_at_capacity() {
693        // capacity = 2 so we can add exactly 2 vectors.
694        let coord = make_coordinator_with_shards(1, 2);
695        coord
696            .increment_shard_count(ShardId(0))
697            .expect("test: first increment is within capacity of 2");
698        coord
699            .increment_shard_count(ShardId(0))
700            .expect("test: second increment is within capacity of 2");
701        let err = coord
702            .increment_shard_count(ShardId(0))
703            .expect_err("test: third increment exceeds capacity of 2 so error is expected");
704        assert!(
705            matches!(
706                err,
707                ShardError::ShardAtCapacity {
708                    shard_id: 0,
709                    capacity: 2,
710                }
711            ),
712            "expected ShardAtCapacity, got {:?}",
713            err
714        );
715    }
716
717    // -----------------------------------------------------------------------
718    // needs_rebalance / overloaded / underloaded
719    // -----------------------------------------------------------------------
720
721    #[test]
722    fn test_needs_rebalance_balanced() {
723        let coord = make_coordinator_with_shards(3, 100_000);
724        // All shards at 0 → perfectly balanced.
725        assert!(!coord.needs_rebalance());
726    }
727
728    #[test]
729    fn test_needs_rebalance_detects_imbalance() {
730        let coord = ShardCoordinator::with_defaults();
731
732        // Shard 0 heavily loaded, shard 1 empty.
733        let mut s0 = VectorShard::new(ShardId(0), "peer-0", 128);
734        s0.vector_count = 90_000;
735        s0.capacity = 100_000;
736        let s1 = VectorShard::new(ShardId(1), "peer-1", 128);
737        // s1.vector_count = 0
738
739        coord.register_shard(s0);
740        coord.register_shard(s1);
741
742        // Mean utilization ≈ (0.9 + 0.0) / 2 = 0.45
743        // Shard 0 deviation: 0.9 - 0.45 = 0.45 > 0.2 threshold.
744        assert!(coord.needs_rebalance());
745    }
746
747    #[test]
748    fn test_overloaded_and_underloaded_shards() {
749        let coord = ShardCoordinator::new(0.2);
750
751        // Shard A: very full (0.9 utilization).
752        let mut s_a = VectorShard::new(ShardId(0), "peer-0", 128);
753        s_a.vector_count = 90_000;
754        s_a.capacity = 100_000;
755
756        // Shard B: medium (0.5 utilization) — will be the mean.
757        let mut s_b = VectorShard::new(ShardId(1), "peer-1", 128);
758        s_b.vector_count = 50_000;
759        s_b.capacity = 100_000;
760
761        // Shard C: almost empty (0.1 utilization).
762        let mut s_c = VectorShard::new(ShardId(2), "peer-2", 128);
763        s_c.vector_count = 10_000;
764        s_c.capacity = 100_000;
765
766        coord.register_shard(s_a);
767        coord.register_shard(s_b);
768        coord.register_shard(s_c);
769
770        // mean ≈ (0.9 + 0.5 + 0.1) / 3 ≈ 0.5
771        let overloaded = coord.overloaded_shards();
772        let underloaded = coord.underloaded_shards();
773
774        // Shard 0 is 0.4 above mean → overloaded.
775        assert!(
776            overloaded.contains(&ShardId(0)),
777            "shard 0 should be overloaded"
778        );
779        // Shard 2 is 0.4 below mean → underloaded.
780        assert!(
781            underloaded.contains(&ShardId(2)),
782            "shard 2 should be underloaded"
783        );
784        // Shard 1 is exactly at the mean — neither.
785        assert!(!overloaded.contains(&ShardId(1)));
786        assert!(!underloaded.contains(&ShardId(1)));
787    }
788
789    // -----------------------------------------------------------------------
790    // Stats accumulation
791    // -----------------------------------------------------------------------
792
793    #[test]
794    fn test_stats_accumulation() {
795        let coord = make_coordinator_with_shards(3, 100_000);
796
797        // 3 shards registered.
798        assert_eq!(coord.stats.snapshot().total_shards_registered, 3);
799
800        // 5 assignments.
801        for i in 0..5 {
802            coord.assign_vector(&format!("v-{}", i));
803        }
804        assert_eq!(coord.stats.snapshot().total_assignments, 5);
805
806        // Trigger a rebalance by making shard 0 heavily loaded.
807        {
808            let mut shards = coord.shards.write().unwrap_or_else(|e| e.into_inner());
809            if let Some(s) = shards.get_mut(&0) {
810                s.vector_count = 90_000;
811            }
812        }
813        coord.needs_rebalance();
814        assert!(coord.stats.snapshot().total_rebalances_triggered >= 1);
815    }
816
817    // -----------------------------------------------------------------------
818    // Virtual nodes provide balance
819    // -----------------------------------------------------------------------
820
821    #[test]
822    fn test_virtual_nodes_balance() {
823        // With 5 shards and 300 virtual nodes (1500 ring positions), a large
824        // and diverse workload of 50_000 keys should give each shard roughly
825        // 10_000 ± 50% keys.  Consistent hashing is *not* perfectly uniform
826        // at small scale, but should be significantly better than putting all
827        // keys on one shard.  We verify:
828        //  1. Every shard receives at least one key.
829        //  2. No single shard receives more than 60% of all keys (no runaway hot-spot).
830        //  3. No single shard receives fewer than 5% of all keys (no starved cold-spot).
831        let n_shards = 5_u32;
832        let mut ring = ConsistentHashRing::new(300);
833        for i in 0..n_shards {
834            ring.add_shard(ShardId(i), &format!("peer-{}", i));
835        }
836
837        let n_keys = 50_000_usize;
838        let mut counts: HashMap<ShardId, usize> = HashMap::new();
839        for i in 0..n_keys {
840            // Mix of numeric and string-like keys for good coverage.
841            let key = format!("balance-test-key-{:08}", i);
842            let shard = ring
843                .get_shard(key.as_bytes())
844                .expect("test: ring has 5 shards so lookup always returns Some");
845            *counts.entry(shard).or_insert(0) += 1;
846        }
847
848        // All shards must be reached.
849        assert_eq!(
850            counts.len(),
851            n_shards as usize,
852            "every shard must receive at least one key"
853        );
854
855        let upper_bound = (n_keys as f64 * 0.60) as usize;
856        let lower_bound = (n_keys as f64 * 0.05) as usize;
857
858        for (shard_id, count) in &counts {
859            assert!(
860                *count <= upper_bound,
861                "shard {:?} received {} keys — hot-spot detected (> 60% of {})",
862                shard_id,
863                count,
864                n_keys
865            );
866            assert!(
867                *count >= lower_bound,
868                "shard {:?} received {} keys — starved (< 5% of {})",
869                shard_id,
870                count,
871                n_keys
872            );
873        }
874    }
875
876    // -----------------------------------------------------------------------
877    // total_vectors
878    // -----------------------------------------------------------------------
879
880    #[test]
881    fn test_total_vectors() {
882        let coord = make_coordinator_with_shards(3, 100_000);
883        assert_eq!(coord.total_vectors(), 0);
884        coord
885            .increment_shard_count(ShardId(0))
886            .expect("test: shard 0 exists and has capacity");
887        coord
888            .increment_shard_count(ShardId(1))
889            .expect("test: shard 1 exists and has capacity");
890        coord
891            .increment_shard_count(ShardId(1))
892            .expect("test: shard 1 still has capacity for a second vector");
893        assert_eq!(coord.total_vectors(), 3);
894    }
895}