1use std::collections::{BTreeMap, HashMap};
27use std::fmt;
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::{Arc, RwLock};
30
31use thiserror::Error;
32
33#[derive(Debug, Error)]
39pub enum ShardError {
40 #[error("shard {0} not found")]
42 ShardNotFound(u32),
43
44 #[error("shard {shard_id} is at capacity ({capacity} vectors)")]
46 ShardAtCapacity {
47 shard_id: u32,
49 capacity: u64,
51 },
52}
53
54#[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#[derive(Debug, Clone)]
83pub struct VectorShard {
84 pub shard_id: ShardId,
86 pub peer_id: String,
88 pub vector_count: u64,
90 pub capacity: u64,
92 pub dimensions: u32,
94}
95
96impl VectorShard {
97 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 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
119const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
125const FNV_PRIME: u64 = 1_099_511_628_211;
127
128#[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#[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#[derive(Debug, Clone)]
164pub struct ConsistentHashRing {
165 ring: BTreeMap<u64, ShardId>,
167 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 pub fn new(virtual_nodes: usize) -> Self {
183 Self {
184 ring: BTreeMap::new(),
185 virtual_nodes,
186 }
187 }
188
189 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 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 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 self.ring
219 .range(hash..)
220 .next()
221 .or_else(|| self.ring.iter().next())
222 .map(|(_, &shard)| shard)
223 }
224
225 pub fn shard_count(&self) -> usize {
227 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#[derive(Debug, Default)]
242pub struct ShardStats {
243 pub total_assignments: AtomicU64,
245 pub total_rebalances_triggered: AtomicU64,
247 pub total_shards_registered: AtomicU64,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct ShardStatsSnapshot {
254 pub total_assignments: u64,
256 pub total_rebalances_triggered: u64,
258 pub total_shards_registered: u64,
260}
261
262impl ShardStats {
263 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
273pub struct ShardCoordinator {
289 shards: RwLock<HashMap<u32, VectorShard>>,
291 ring: RwLock<ConsistentHashRing>,
293 rebalance_threshold: f64,
296 pub stats: Arc<ShardStats>,
298}
299
300impl ShardCoordinator {
301 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 pub fn with_defaults() -> Self {
316 Self::new(0.2)
317 }
318
319 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 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 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 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 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 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 pub fn shard_count(&self) -> usize {
469 self.shards
470 .read()
471 .expect("shard registry read lock poisoned")
472 .len()
473 }
474
475 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#[cfg(test)]
491mod tests {
492 use super::*;
493
494 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 #[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 #[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 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 #[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 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 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 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 ring.remove_shard(ShardId(1));
603 assert_eq!(ring.shard_count(), 1);
604
605 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 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 #[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 #[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 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 #[test]
722 fn test_needs_rebalance_balanced() {
723 let coord = make_coordinator_with_shards(3, 100_000);
724 assert!(!coord.needs_rebalance());
726 }
727
728 #[test]
729 fn test_needs_rebalance_detects_imbalance() {
730 let coord = ShardCoordinator::with_defaults();
731
732 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 coord.register_shard(s0);
740 coord.register_shard(s1);
741
742 assert!(coord.needs_rebalance());
745 }
746
747 #[test]
748 fn test_overloaded_and_underloaded_shards() {
749 let coord = ShardCoordinator::new(0.2);
750
751 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 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 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 let overloaded = coord.overloaded_shards();
772 let underloaded = coord.underloaded_shards();
773
774 assert!(
776 overloaded.contains(&ShardId(0)),
777 "shard 0 should be overloaded"
778 );
779 assert!(
781 underloaded.contains(&ShardId(2)),
782 "shard 2 should be underloaded"
783 );
784 assert!(!overloaded.contains(&ShardId(1)));
786 assert!(!underloaded.contains(&ShardId(1)));
787 }
788
789 #[test]
794 fn test_stats_accumulation() {
795 let coord = make_coordinator_with_shards(3, 100_000);
796
797 assert_eq!(coord.stats.snapshot().total_shards_registered, 3);
799
800 for i in 0..5 {
802 coord.assign_vector(&format!("v-{}", i));
803 }
804 assert_eq!(coord.stats.snapshot().total_assignments, 5);
805
806 {
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 #[test]
822 fn test_virtual_nodes_balance() {
823 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 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 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 #[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}