1use std::collections::{BTreeMap, HashMap};
47
48pub fn fnv1a_64(data: &[u8]) -> u64 {
54 let mut h: u64 = 14_695_981_039_346_656_037;
55 for &b in data {
56 h ^= b as u64;
57 h = h.wrapping_mul(1_099_511_628_211);
58 }
59 h
60}
61
62fn virtual_node_key(shard_id: &str, replica: usize) -> u64 {
63 let key = format!("{}-{}", shard_id, replica);
64 fnv1a_64(key.as_bytes())
65}
66
67fn content_key(cid: &str) -> u64 {
68 fnv1a_64(cid.as_bytes())
69}
70
71pub fn xorshift64(state: &mut u64) -> u64 {
73 let mut x = *state;
74 x ^= x << 13;
75 x ^= x >> 7;
76 x ^= x << 17;
77 *state = x;
78 x
79}
80
81#[derive(Debug, Clone)]
87pub struct ShardNode {
88 pub id: String,
90 pub capacity_bytes: u64,
92 pub used_bytes: u64,
94 pub virtual_nodes: usize,
96 pub is_healthy: bool,
98 pub region: String,
100 pub weight: f64,
102}
103
104impl Default for ShardNode {
105 fn default() -> Self {
106 Self {
107 id: String::new(),
108 capacity_bytes: 0,
109 used_bytes: 0,
110 virtual_nodes: 150,
111 is_healthy: true,
112 region: String::new(),
113 weight: 1.0,
114 }
115 }
116}
117
118impl ShardNode {
119 pub fn utilization(&self) -> f64 {
121 if self.capacity_bytes == 0 {
122 return 1.0;
123 }
124 self.used_bytes as f64 / self.capacity_bytes as f64
125 }
126
127 pub fn free_bytes(&self) -> u64 {
129 self.capacity_bytes.saturating_sub(self.used_bytes)
130 }
131
132 pub fn capacity_weight(&self) -> f64 {
134 if self.capacity_bytes == 0 {
135 return 0.0;
136 }
137 let free_ratio = self.free_bytes() as f64 / self.capacity_bytes as f64;
138 free_ratio * self.weight
139 }
140}
141
142#[derive(Debug, Clone)]
144pub struct ShardAssignment {
145 pub cid: String,
147 pub shard_id: String,
149 pub replica_shards: Vec<String>,
151 pub assigned_at: u64,
153}
154
155#[derive(Debug, Clone, PartialEq)]
157pub enum RebalanceOp {
158 MoveContent {
160 cid: String,
161 from_shard: String,
162 to_shard: String,
163 },
164 AddVirtualNode { shard_id: String, position: u64 },
166 RemoveVirtualNode { shard_id: String, position: u64 },
168 UpdateWeight { shard_id: String, new_weight: f64 },
170}
171
172#[derive(Debug, Clone, PartialEq)]
174pub enum RebalancePolicy {
175 LeastLoaded,
177 ConsistentHash,
179 RegionAware(String),
181 CapacityWeighted,
183 MinimalMovement,
185}
186
187#[derive(Debug, Clone)]
189pub struct BalancerConfig {
190 pub replication_factor: usize,
192 pub virtual_nodes_per_shard: usize,
194 pub rebalance_threshold: f64,
196 pub policy: RebalancePolicy,
198}
199
200impl Default for BalancerConfig {
201 fn default() -> Self {
202 Self {
203 replication_factor: 3,
204 virtual_nodes_per_shard: 150,
205 rebalance_threshold: 1.5,
206 policy: RebalancePolicy::LeastLoaded,
207 }
208 }
209}
210
211#[derive(Debug, Clone)]
213pub struct SsbBalancerStats {
214 pub shard_count: usize,
216 pub total_capacity_bytes: u64,
218 pub total_used_bytes: u64,
220 pub utilization_pct: f64,
222 pub imbalance_ratio: f64,
224 pub rebalance_ops_pending: usize,
226}
227
228#[derive(Debug, Clone, thiserror::Error)]
230pub enum BalancerError {
231 #[error("shard not found: {0}")]
232 ShardNotFound(String),
233
234 #[error("content not found: {0}")]
235 ContentNotFound(String),
236
237 #[error("insufficient shards: need {need}, have {have}")]
238 InsufficientShards { need: usize, have: usize },
239
240 #[error("replication failed: {0}")]
241 ReplicationFailed(String),
242
243 #[error("invalid configuration: {0}")]
244 InvalidConfiguration(String),
245}
246
247pub struct StorageShardBalancer {
258 config: BalancerConfig,
259 ring: BTreeMap<u64, String>,
261 shards: HashMap<String, ShardNode>,
263 assignments: HashMap<String, ShardAssignment>,
265 pending_ops: Vec<RebalanceOp>,
267 clock: u64,
269}
270
271impl StorageShardBalancer {
272 pub fn new(config: BalancerConfig) -> Self {
274 Self {
275 config,
276 ring: BTreeMap::new(),
277 shards: HashMap::new(),
278 assignments: HashMap::new(),
279 pending_ops: Vec::new(),
280 clock: 0,
281 }
282 }
283
284 pub fn with_defaults() -> Self {
286 Self::new(BalancerConfig::default())
287 }
288
289 pub fn add_shard(&mut self, shard: ShardNode) -> Result<(), BalancerError> {
295 if shard.id.is_empty() {
296 return Err(BalancerError::InvalidConfiguration(
297 "shard id must not be empty".to_string(),
298 ));
299 }
300 let vn = if shard.virtual_nodes > 0 {
301 shard.virtual_nodes
302 } else {
303 self.config.virtual_nodes_per_shard
304 };
305 let id = shard.id.clone();
306 self.shards.insert(id.clone(), shard);
307 for replica in 0..vn {
308 let pos = virtual_node_key(&id, replica);
309 self.ring.insert(pos, id.clone());
310 }
311 Ok(())
312 }
313
314 pub fn remove_shard(&mut self, shard_id: &str) -> Result<Vec<RebalanceOp>, BalancerError> {
317 if !self.shards.contains_key(shard_id) {
318 return Err(BalancerError::ShardNotFound(shard_id.to_string()));
319 }
320
321 let positions: Vec<u64> = self
323 .ring
324 .iter()
325 .filter_map(|(&pos, sid)| if sid == shard_id { Some(pos) } else { None })
326 .collect();
327 let mut ops: Vec<RebalanceOp> = positions
328 .iter()
329 .map(|&pos| RebalanceOp::RemoveVirtualNode {
330 shard_id: shard_id.to_string(),
331 position: pos,
332 })
333 .collect();
334 for pos in &positions {
335 self.ring.remove(pos);
336 }
337 self.shards.remove(shard_id);
338
339 let affected: Vec<String> = self
341 .assignments
342 .iter()
343 .filter_map(|(cid, a)| {
344 if a.shard_id == shard_id || a.replica_shards.iter().any(|r| r == shard_id) {
345 Some(cid.clone())
346 } else {
347 None
348 }
349 })
350 .collect();
351
352 for cid in &affected {
353 match self.assign_internal(cid) {
355 Ok(new_assignment) => {
356 ops.push(RebalanceOp::MoveContent {
358 cid: cid.clone(),
359 from_shard: shard_id.to_string(),
360 to_shard: new_assignment.shard_id.clone(),
361 });
362 self.assignments.insert(cid.clone(), new_assignment);
363 }
364 Err(_) => {
365 self.assignments.remove(cid);
367 }
368 }
369 }
370
371 Ok(ops)
372 }
373
374 pub fn assign(&mut self, cid: &str) -> Result<ShardAssignment, BalancerError> {
383 let ts = self.tick_clock();
384 let assignment = Self::assign_from_ring(
385 cid,
386 &self.ring,
387 &self.shards,
388 self.config.replication_factor,
389 ts,
390 )?;
391 self.assignments.insert(cid.to_string(), assignment.clone());
392 Ok(assignment)
393 }
394
395 fn assign_internal(&self, cid: &str) -> Result<ShardAssignment, BalancerError> {
396 Self::assign_from_ring(
397 cid,
398 &self.ring,
399 &self.shards,
400 self.config.replication_factor,
401 self.clock,
402 )
403 }
404
405 fn assign_from_ring(
406 cid: &str,
407 ring: &BTreeMap<u64, String>,
408 shards: &HashMap<String, ShardNode>,
409 needed: usize,
410 assigned_at: u64,
411 ) -> Result<ShardAssignment, BalancerError> {
412 let healthy_count = shards.values().filter(|s| s.is_healthy).count();
413 if healthy_count < needed {
414 return Err(BalancerError::InsufficientShards {
415 need: needed,
416 have: healthy_count,
417 });
418 }
419
420 let hash = content_key(cid);
421 let mut selected: Vec<String> = Vec::with_capacity(needed);
422
423 let ring_walk = ring.range(hash..).chain(ring.range(..hash));
426
427 for (_, sid) in ring_walk {
428 if selected.contains(sid) {
429 continue;
430 }
431 if let Some(shard) = shards.get(sid.as_str()) {
432 if shard.is_healthy {
433 selected.push(sid.clone());
434 if selected.len() == needed {
435 break;
436 }
437 }
438 }
439 }
440
441 if selected.len() < needed {
442 return Err(BalancerError::ReplicationFailed(format!(
443 "could only place {} of {} replicas for cid {}",
444 selected.len(),
445 needed,
446 cid
447 )));
448 }
449
450 let primary = selected.remove(0);
451
452 Ok(ShardAssignment {
453 cid: cid.to_string(),
454 shard_id: primary,
455 replica_shards: selected,
456 assigned_at,
457 })
458 }
459
460 pub fn lookup(&self, cid: &str) -> Result<&ShardAssignment, BalancerError> {
462 self.assignments
463 .get(cid)
464 .ok_or_else(|| BalancerError::ContentNotFound(cid.to_string()))
465 }
466
467 pub fn record_usage(&mut self, shard_id: &str, delta_bytes: i64) -> Result<(), BalancerError> {
473 let shard = self
474 .shards
475 .get_mut(shard_id)
476 .ok_or_else(|| BalancerError::ShardNotFound(shard_id.to_string()))?;
477 if delta_bytes >= 0 {
478 shard.used_bytes = shard.used_bytes.saturating_add(delta_bytes as u64);
479 } else {
480 shard.used_bytes = shard.used_bytes.saturating_sub((-delta_bytes) as u64);
481 }
482 Ok(())
483 }
484
485 pub fn set_shard_health(&mut self, shard_id: &str, healthy: bool) -> Result<(), BalancerError> {
491 let shard = self
492 .shards
493 .get_mut(shard_id)
494 .ok_or_else(|| BalancerError::ShardNotFound(shard_id.to_string()))?;
495 shard.is_healthy = healthy;
496 Ok(())
497 }
498
499 pub fn rebalance(&mut self) -> Result<Vec<RebalanceOp>, BalancerError> {
506 let stats = self.stats();
507 if stats.imbalance_ratio <= self.config.rebalance_threshold {
508 self.pending_ops.clear();
509 return Ok(Vec::new());
510 }
511
512 let ops = match &self.config.policy.clone() {
513 RebalancePolicy::LeastLoaded => self.rebalance_least_loaded(),
514 RebalancePolicy::ConsistentHash => self.rebalance_consistent_hash(),
515 RebalancePolicy::RegionAware(region) => {
516 let r = region.clone();
517 self.rebalance_region_aware(&r)
518 }
519 RebalancePolicy::CapacityWeighted => self.rebalance_capacity_weighted(),
520 RebalancePolicy::MinimalMovement => self.rebalance_minimal_movement(),
521 };
522
523 self.pending_ops = ops.clone();
524 Ok(ops)
525 }
526
527 fn rebalance_least_loaded(&mut self) -> Vec<RebalanceOp> {
529 let mut ops = Vec::new();
530 let threshold = self.config.rebalance_threshold;
531
532 for _ in 0..self.assignments.len().saturating_add(1) {
535 let (most_id, least_id) = match self.most_and_least_loaded() {
536 Some(pair) => pair,
537 None => break,
538 };
539
540 let max_util = self.shards[&most_id].utilization();
541 let min_util = self.shards[&least_id].utilization();
542 if max_util == 0.0 || (max_util / min_util.max(f64::EPSILON)) <= threshold {
543 break;
544 }
545
546 let cid = self
548 .assignments
549 .iter()
550 .find(|(_, a)| a.shard_id == most_id)
551 .map(|(c, _)| c.clone());
552
553 match cid {
554 None => break,
555 Some(c) => {
556 let block_size = 1_u64;
558 ops.push(RebalanceOp::MoveContent {
559 cid: c.clone(),
560 from_shard: most_id.clone(),
561 to_shard: least_id.clone(),
562 });
563 if let Some(a) = self.assignments.get_mut(&c) {
565 a.shard_id = least_id.clone();
566 }
567 if let Some(s) = self.shards.get_mut(&most_id) {
568 s.used_bytes = s.used_bytes.saturating_sub(block_size);
569 }
570 if let Some(s) = self.shards.get_mut(&least_id) {
571 s.used_bytes = s.used_bytes.saturating_add(block_size);
572 }
573 }
574 }
575 }
576 ops
577 }
578
579 fn rebalance_consistent_hash(&mut self) -> Vec<RebalanceOp> {
582 let misplaced: Vec<(String, String, String)> = self
583 .assignments
584 .iter()
585 .filter_map(|(cid, a)| {
586 let expected = self.ring_lookup_primary(cid)?;
587 if expected != a.shard_id {
588 Some((cid.clone(), a.shard_id.clone(), expected))
589 } else {
590 None
591 }
592 })
593 .collect();
594
595 let mut ops = Vec::new();
596 for (cid, from, to) in misplaced {
597 ops.push(RebalanceOp::MoveContent {
598 cid: cid.clone(),
599 from_shard: from,
600 to_shard: to.clone(),
601 });
602 if let Some(a) = self.assignments.get_mut(&cid) {
603 a.shard_id = to;
604 }
605 }
606 ops
607 }
608
609 fn rebalance_region_aware(&mut self, prefer_region: &str) -> Vec<RebalanceOp> {
612 let mut ops = Vec::new();
613 let boosts: Vec<(String, f64)> = self
615 .shards
616 .iter()
617 .filter(|(_, s)| s.region == prefer_region && s.is_healthy)
618 .map(|(id, s)| (id.clone(), s.weight * 2.0))
619 .collect();
620 for (sid, new_weight) in boosts {
621 ops.push(RebalanceOp::UpdateWeight {
622 shard_id: sid.clone(),
623 new_weight,
624 });
625 if let Some(s) = self.shards.get_mut(&sid) {
626 s.weight = new_weight;
627 }
628 }
629 ops.extend(self.rebalance_least_loaded());
631 ops
632 }
633
634 fn rebalance_capacity_weighted(&mut self) -> Vec<RebalanceOp> {
637 let mut ops = Vec::new();
638 let threshold = self.config.rebalance_threshold;
639
640 for _ in 0..self.assignments.len().saturating_add(1) {
641 let most_loaded = self
643 .shards
644 .iter()
645 .filter(|(_, s)| s.is_healthy)
646 .max_by(|(_, a), (_, b)| {
647 a.utilization()
648 .partial_cmp(&b.utilization())
649 .unwrap_or(std::cmp::Ordering::Equal)
650 })
651 .map(|(id, _)| id.clone());
652
653 let best_dest = self
655 .shards
656 .iter()
657 .filter(|(_, s)| s.is_healthy)
658 .max_by(|(_, a), (_, b)| {
659 a.capacity_weight()
660 .partial_cmp(&b.capacity_weight())
661 .unwrap_or(std::cmp::Ordering::Equal)
662 })
663 .map(|(id, _)| id.clone());
664
665 let (most_id, dest_id) = match (most_loaded, best_dest) {
666 (Some(m), Some(d)) if m != d => (m, d),
667 _ => break,
668 };
669
670 let max_util = self.shards[&most_id].utilization();
671 let dest_util = self.shards[&dest_id].utilization();
672 if max_util == 0.0 || (max_util / dest_util.max(f64::EPSILON)) <= threshold {
673 break;
674 }
675
676 let cid = self
677 .assignments
678 .iter()
679 .find(|(_, a)| a.shard_id == most_id)
680 .map(|(c, _)| c.clone());
681
682 match cid {
683 None => break,
684 Some(c) => {
685 ops.push(RebalanceOp::MoveContent {
686 cid: c.clone(),
687 from_shard: most_id.clone(),
688 to_shard: dest_id.clone(),
689 });
690 if let Some(a) = self.assignments.get_mut(&c) {
691 a.shard_id = dest_id.clone();
692 }
693 if let Some(s) = self.shards.get_mut(&most_id) {
694 s.used_bytes = s.used_bytes.saturating_sub(1);
695 }
696 if let Some(s) = self.shards.get_mut(&dest_id) {
697 s.used_bytes = s.used_bytes.saturating_add(1);
698 }
699 }
700 }
701 }
702 ops
703 }
704
705 fn rebalance_minimal_movement(&mut self) -> Vec<RebalanceOp> {
708 let mut ops = Vec::new();
709 let threshold = self.config.rebalance_threshold;
710
711 while let Some((most_id, least_id)) = self.most_and_least_loaded() {
712 let max_util = self.shards[&most_id].utilization();
713 let min_util = self.shards[&least_id].utilization();
714
715 if max_util == 0.0 || (max_util / min_util.max(f64::EPSILON)) <= threshold {
716 break;
717 }
718
719 let cid = self
721 .assignments
722 .iter()
723 .find(|(_, a)| a.shard_id == most_id)
724 .map(|(c, _)| c.clone());
725
726 match cid {
727 None => break,
728 Some(c) => {
729 ops.push(RebalanceOp::MoveContent {
730 cid: c.clone(),
731 from_shard: most_id.clone(),
732 to_shard: least_id.clone(),
733 });
734 if let Some(a) = self.assignments.get_mut(&c) {
735 a.shard_id = least_id.clone();
736 }
737 if let Some(s) = self.shards.get_mut(&most_id) {
738 s.used_bytes = s.used_bytes.saturating_sub(1);
739 }
740 if let Some(s) = self.shards.get_mut(&least_id) {
741 s.used_bytes = s.used_bytes.saturating_add(1);
742 }
743 }
744 }
745 }
746 ops
747 }
748
749 pub fn stats(&self) -> SsbBalancerStats {
755 let shard_count = self.shards.len();
756 let total_capacity_bytes: u64 = self.shards.values().map(|s| s.capacity_bytes).sum();
757 let total_used_bytes: u64 = self.shards.values().map(|s| s.used_bytes).sum();
758
759 let utilization_pct = if total_capacity_bytes == 0 {
760 0.0
761 } else {
762 (total_used_bytes as f64 / total_capacity_bytes as f64) * 100.0
763 };
764
765 let imbalance_ratio = self.compute_imbalance_ratio();
766
767 SsbBalancerStats {
768 shard_count,
769 total_capacity_bytes,
770 total_used_bytes,
771 utilization_pct,
772 imbalance_ratio,
773 rebalance_ops_pending: self.pending_ops.len(),
774 }
775 }
776
777 pub fn ring_positions(&self) -> Vec<(u64, String)> {
779 self.ring
780 .iter()
781 .map(|(&pos, sid)| (pos, sid.clone()))
782 .collect()
783 }
784
785 pub fn config(&self) -> &BalancerConfig {
787 &self.config
788 }
789
790 pub fn shard(&self, shard_id: &str) -> Option<&ShardNode> {
792 self.shards.get(shard_id)
793 }
794
795 pub fn shards(&self) -> impl Iterator<Item = &ShardNode> {
797 self.shards.values()
798 }
799
800 pub fn assignment_count(&self) -> usize {
802 self.assignments.len()
803 }
804
805 fn ring_lookup_primary(&self, cid: &str) -> Option<String> {
811 let hash = content_key(cid);
812 let ring_walk = self.ring.range(hash..).chain(self.ring.range(..hash));
813 for (_, sid) in ring_walk {
814 if let Some(shard) = self.shards.get(sid.as_str()) {
815 if shard.is_healthy {
816 return Some(sid.clone());
817 }
818 }
819 }
820 None
821 }
822
823 fn most_and_least_loaded(&self) -> Option<(String, String)> {
824 let healthy: Vec<(&String, &ShardNode)> =
825 self.shards.iter().filter(|(_, s)| s.is_healthy).collect();
826
827 if healthy.len() < 2 {
828 return None;
829 }
830
831 let most = healthy
832 .iter()
833 .max_by(|(_, a), (_, b)| {
834 a.utilization()
835 .partial_cmp(&b.utilization())
836 .unwrap_or(std::cmp::Ordering::Equal)
837 })
838 .map(|(id, _)| (*id).clone())?;
839
840 let least = healthy
841 .iter()
842 .min_by(|(_, a), (_, b)| {
843 a.utilization()
844 .partial_cmp(&b.utilization())
845 .unwrap_or(std::cmp::Ordering::Equal)
846 })
847 .map(|(id, _)| (*id).clone())?;
848
849 if most == least {
850 None
851 } else {
852 Some((most, least))
853 }
854 }
855
856 fn compute_imbalance_ratio(&self) -> f64 {
857 let healthy: Vec<f64> = self
858 .shards
859 .values()
860 .filter(|s| s.is_healthy && s.capacity_bytes > 0)
861 .map(|s| s.utilization())
862 .collect();
863
864 if healthy.len() < 2 {
865 return 1.0;
866 }
867
868 let max = healthy.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
869 let min = healthy.iter().cloned().fold(f64::INFINITY, f64::min);
870
871 if max <= 0.0 {
874 return 1.0;
875 }
876
877 if min <= 0.0 {
880 return max * 1000.0 + 1.0;
881 }
882
883 max / min
884 }
885
886 fn tick_clock(&mut self) -> u64 {
887 self.clock = self.clock.wrapping_add(1);
888 self.clock
889 }
890}
891
892#[cfg(test)]
897mod tests {
898 use super::*;
899
900 fn make_shard(id: &str, cap: u64, used: u64) -> ShardNode {
905 ShardNode {
906 id: id.to_string(),
907 capacity_bytes: cap,
908 used_bytes: used,
909 virtual_nodes: 20,
910 is_healthy: true,
911 region: "us-east".to_string(),
912 weight: 1.0,
913 }
914 }
915
916 fn make_shard_region(id: &str, cap: u64, used: u64, region: &str) -> ShardNode {
917 ShardNode {
918 id: id.to_string(),
919 capacity_bytes: cap,
920 used_bytes: used,
921 virtual_nodes: 20,
922 is_healthy: true,
923 region: region.to_string(),
924 weight: 1.0,
925 }
926 }
927
928 fn two_shard_balancer() -> StorageShardBalancer {
929 let mut b = StorageShardBalancer::new(BalancerConfig {
930 replication_factor: 2,
931 virtual_nodes_per_shard: 20,
932 rebalance_threshold: 1.5,
933 policy: RebalancePolicy::LeastLoaded,
934 });
935 b.add_shard(make_shard("s0", 1_000_000, 0)).unwrap();
936 b.add_shard(make_shard("s1", 1_000_000, 0)).unwrap();
937 b
938 }
939
940 fn three_shard_balancer() -> StorageShardBalancer {
941 let mut b = StorageShardBalancer::new(BalancerConfig {
942 replication_factor: 3,
943 virtual_nodes_per_shard: 20,
944 rebalance_threshold: 1.5,
945 policy: RebalancePolicy::LeastLoaded,
946 });
947 b.add_shard(make_shard("s0", 1_000_000, 0)).unwrap();
948 b.add_shard(make_shard("s1", 1_000_000, 0)).unwrap();
949 b.add_shard(make_shard("s2", 1_000_000, 0)).unwrap();
950 b
951 }
952
953 #[test]
958 fn test_fnv1a_empty() {
959 assert_eq!(fnv1a_64(b""), 14_695_981_039_346_656_037_u64);
960 }
961
962 #[test]
963 fn test_fnv1a_deterministic() {
964 assert_eq!(fnv1a_64(b"hello"), fnv1a_64(b"hello"));
965 }
966
967 #[test]
968 fn test_fnv1a_distinct() {
969 assert_ne!(fnv1a_64(b"alpha"), fnv1a_64(b"beta"));
970 }
971
972 #[test]
973 fn test_virtual_node_key_distinct_replicas() {
974 let k0 = virtual_node_key("shard-a", 0);
975 let k1 = virtual_node_key("shard-a", 1);
976 assert_ne!(k0, k1);
977 }
978
979 #[test]
984 fn test_xorshift64_nonzero() {
985 let mut state = 12345_u64;
986 let v = xorshift64(&mut state);
987 assert_ne!(v, 0);
988 }
989
990 #[test]
991 fn test_xorshift64_sequence() {
992 let mut s = 1_u64;
993 let a = xorshift64(&mut s);
994 let b = xorshift64(&mut s);
995 assert_ne!(a, b);
996 }
997
998 #[test]
1003 fn test_add_shard_populates_ring() {
1004 let mut b = StorageShardBalancer::new(BalancerConfig::default());
1005 b.add_shard(make_shard("s0", 1_000, 0)).unwrap();
1006 assert!(!b.ring.is_empty());
1007 }
1008
1009 #[test]
1010 fn test_add_shard_virtual_node_count() {
1011 let mut b = StorageShardBalancer::new(BalancerConfig {
1012 virtual_nodes_per_shard: 30,
1013 ..BalancerConfig::default()
1014 });
1015 let mut shard = make_shard("s0", 1_000, 0);
1016 shard.virtual_nodes = 30;
1017 b.add_shard(shard).unwrap();
1018 let count = b.ring.values().filter(|id| id.as_str() == "s0").count();
1019 assert!(count >= 1);
1021 }
1022
1023 #[test]
1024 fn test_add_shard_empty_id_error() {
1025 let mut b = StorageShardBalancer::new(BalancerConfig::default());
1026 let result = b.add_shard(ShardNode {
1027 id: String::new(),
1028 ..ShardNode::default()
1029 });
1030 assert!(matches!(
1031 result,
1032 Err(BalancerError::InvalidConfiguration(_))
1033 ));
1034 }
1035
1036 #[test]
1037 fn test_add_multiple_shards() {
1038 let mut b = StorageShardBalancer::new(BalancerConfig::default());
1039 for i in 0..5_u32 {
1040 b.add_shard(make_shard(&format!("s{}", i), 1_000, 0))
1041 .unwrap();
1042 }
1043 assert_eq!(b.shards.len(), 5);
1044 }
1045
1046 #[test]
1051 fn test_remove_shard_cleans_ring() {
1052 let mut b = two_shard_balancer();
1053 let before = b.ring.len();
1054 b.remove_shard("s0").unwrap();
1055 let after = b.ring.len();
1056 assert!(after < before);
1057 assert!(!b.ring.values().any(|id| id == "s0"));
1058 }
1059
1060 #[test]
1061 fn test_remove_shard_not_found_error() {
1062 let mut b = two_shard_balancer();
1063 let err = b.remove_shard("ghost");
1064 assert!(matches!(err, Err(BalancerError::ShardNotFound(_))));
1065 }
1066
1067 #[test]
1068 fn test_remove_shard_generates_remove_vn_ops() {
1069 let mut b = two_shard_balancer();
1070 let ops = b.remove_shard("s0").unwrap();
1071 let remove_vn: Vec<_> = ops
1072 .iter()
1073 .filter(|o| matches!(o, RebalanceOp::RemoveVirtualNode { .. }))
1074 .collect();
1075 assert!(!remove_vn.is_empty());
1076 }
1077
1078 #[test]
1079 fn test_remove_shard_reassigns_content() {
1080 let mut b = three_shard_balancer();
1081 b.assign("cid-abc").unwrap();
1082 b.assign("cid-xyz").unwrap();
1083 let ops = b.remove_shard("s0").unwrap();
1084 let moves: Vec<_> = ops
1085 .iter()
1086 .filter(|o| matches!(o, RebalanceOp::MoveContent { .. }))
1087 .collect();
1088 let _ = moves;
1091 assert!(!b.shards.contains_key("s0"));
1092 }
1093
1094 #[test]
1099 fn test_assign_returns_valid_shard() {
1100 let mut b = two_shard_balancer();
1101 let a = b.assign("Qmtest1").unwrap();
1102 assert!(!a.shard_id.is_empty());
1103 assert!(b.shards.contains_key(&a.shard_id));
1104 }
1105
1106 #[test]
1107 fn test_assign_replication_factor_replicas() {
1108 let mut b = three_shard_balancer();
1109 let a = b.assign("Qmtest-rf3").unwrap();
1110 assert_eq!(a.replica_shards.len(), 2);
1112 assert_ne!(a.shard_id, a.replica_shards[0]);
1114 assert_ne!(a.shard_id, a.replica_shards[1]);
1115 assert_ne!(a.replica_shards[0], a.replica_shards[1]);
1116 }
1117
1118 #[test]
1119 fn test_assign_deterministic() {
1120 let mut b1 = two_shard_balancer();
1121 let mut b2 = two_shard_balancer();
1122 let a1 = b1.assign("cid-det").unwrap();
1123 let a2 = b2.assign("cid-det").unwrap();
1124 assert_eq!(a1.shard_id, a2.shard_id);
1125 }
1126
1127 #[test]
1128 fn test_assign_insufficient_shards_error() {
1129 let mut b = StorageShardBalancer::new(BalancerConfig {
1130 replication_factor: 5,
1131 virtual_nodes_per_shard: 10,
1132 rebalance_threshold: 1.5,
1133 policy: RebalancePolicy::LeastLoaded,
1134 });
1135 b.add_shard(make_shard("only", 1_000, 0)).unwrap();
1136 let err = b.assign("cid-1");
1137 assert!(matches!(err, Err(BalancerError::InsufficientShards { .. })));
1138 }
1139
1140 #[test]
1141 fn test_assign_stores_in_table() {
1142 let mut b = two_shard_balancer();
1143 b.assign("stored-cid").unwrap();
1144 assert!(b.lookup("stored-cid").is_ok());
1145 }
1146
1147 #[test]
1148 fn test_assign_different_cids_may_land_different_shards() {
1149 let mut b = two_shard_balancer();
1150 let mut found_s0 = false;
1151 let mut found_s1 = false;
1152 let mut state = 999_u64;
1153 for _ in 0..50 {
1154 let cid = format!("cid-{}", xorshift64(&mut state));
1155 let a = b.assign(&cid).unwrap();
1156 if a.shard_id == "s0" {
1157 found_s0 = true;
1158 }
1159 if a.shard_id == "s1" {
1160 found_s1 = true;
1161 }
1162 }
1163 assert!(found_s0 && found_s1, "CIDs should distribute across shards");
1164 }
1165
1166 #[test]
1167 fn test_assign_unhealthy_shard_skipped() {
1168 let mut b = StorageShardBalancer::new(BalancerConfig {
1170 replication_factor: 2,
1171 virtual_nodes_per_shard: 20,
1172 rebalance_threshold: 1.5,
1173 policy: RebalancePolicy::LeastLoaded,
1174 });
1175 for i in 0..4_u32 {
1176 b.add_shard(make_shard(&format!("s{}", i), 1_000_000, 0))
1177 .unwrap();
1178 }
1179 b.set_shard_health("s0", false).unwrap();
1180 for i in 0..20 {
1181 let a = b.assign(&format!("cid-{}", i)).unwrap();
1182 assert_ne!(a.shard_id, "s0");
1183 assert!(!a.replica_shards.contains(&"s0".to_string()));
1184 }
1185 }
1186
1187 #[test]
1192 fn test_lookup_existing() {
1193 let mut b = two_shard_balancer();
1194 b.assign("lookup-cid").unwrap();
1195 let r = b.lookup("lookup-cid");
1196 assert!(r.is_ok());
1197 assert_eq!(r.unwrap().cid, "lookup-cid");
1198 }
1199
1200 #[test]
1201 fn test_lookup_missing_error() {
1202 let b = two_shard_balancer();
1203 let err = b.lookup("missing");
1204 assert!(matches!(err, Err(BalancerError::ContentNotFound(_))));
1205 }
1206
1207 #[test]
1212 fn test_record_usage_positive() {
1213 let mut b = two_shard_balancer();
1214 b.record_usage("s0", 500).unwrap();
1215 assert_eq!(b.shards["s0"].used_bytes, 500);
1216 }
1217
1218 #[test]
1219 fn test_record_usage_negative() {
1220 let mut b = two_shard_balancer();
1221 b.record_usage("s0", 1000).unwrap();
1222 b.record_usage("s0", -400).unwrap();
1223 assert_eq!(b.shards["s0"].used_bytes, 600);
1224 }
1225
1226 #[test]
1227 fn test_record_usage_saturating_underflow() {
1228 let mut b = two_shard_balancer();
1229 b.record_usage("s0", -9_999_999).unwrap();
1230 assert_eq!(b.shards["s0"].used_bytes, 0);
1231 }
1232
1233 #[test]
1234 fn test_record_usage_saturating_overflow() {
1235 let mut b = two_shard_balancer();
1236 b.record_usage("s0", i64::MAX).unwrap();
1237 let _ = b.shards["s0"].used_bytes;
1239 }
1240
1241 #[test]
1242 fn test_record_usage_not_found_error() {
1243 let mut b = two_shard_balancer();
1244 let err = b.record_usage("ghost", 100);
1245 assert!(matches!(err, Err(BalancerError::ShardNotFound(_))));
1246 }
1247
1248 #[test]
1253 fn test_set_shard_health_false() {
1254 let mut b = two_shard_balancer();
1255 b.set_shard_health("s0", false).unwrap();
1256 assert!(!b.shards["s0"].is_healthy);
1257 }
1258
1259 #[test]
1260 fn test_set_shard_health_true() {
1261 let mut b = two_shard_balancer();
1262 b.set_shard_health("s0", false).unwrap();
1263 b.set_shard_health("s0", true).unwrap();
1264 assert!(b.shards["s0"].is_healthy);
1265 }
1266
1267 #[test]
1268 fn test_set_shard_health_not_found_error() {
1269 let mut b = two_shard_balancer();
1270 let err = b.set_shard_health("ghost", false);
1271 assert!(matches!(err, Err(BalancerError::ShardNotFound(_))));
1272 }
1273
1274 #[test]
1275 fn test_assign_after_health_toggle() {
1276 let mut b = StorageShardBalancer::new(BalancerConfig {
1278 replication_factor: 2,
1279 virtual_nodes_per_shard: 20,
1280 rebalance_threshold: 1.5,
1281 policy: RebalancePolicy::LeastLoaded,
1282 });
1283 for i in 0..4_u32 {
1284 b.add_shard(make_shard(&format!("s{}", i), 1_000_000, 0))
1285 .unwrap();
1286 }
1287 b.set_shard_health("s0", false).unwrap();
1288 let a = b.assign("cid-h").unwrap();
1289 assert_ne!(a.shard_id, "s0");
1290 b.set_shard_health("s0", true).unwrap();
1291 let _a2 = b.assign("cid-h2").unwrap();
1292 }
1293
1294 #[test]
1299 fn test_stats_empty() {
1300 let b = StorageShardBalancer::new(BalancerConfig::default());
1301 let s = b.stats();
1302 assert_eq!(s.shard_count, 0);
1303 assert_eq!(s.total_capacity_bytes, 0);
1304 }
1305
1306 #[test]
1307 fn test_stats_capacity_sum() {
1308 let b = three_shard_balancer();
1309 let s = b.stats();
1310 assert_eq!(s.total_capacity_bytes, 3_000_000);
1311 assert_eq!(s.shard_count, 3);
1312 }
1313
1314 #[test]
1315 fn test_stats_utilization_pct() {
1316 let mut b = two_shard_balancer();
1317 b.record_usage("s0", 500_000).unwrap();
1318 let s = b.stats();
1319 assert!((s.utilization_pct - 25.0).abs() < 0.01);
1320 }
1321
1322 #[test]
1323 fn test_stats_imbalance_ratio_equal() {
1324 let mut b = two_shard_balancer();
1325 b.record_usage("s0", 100_000).unwrap();
1326 b.record_usage("s1", 100_000).unwrap();
1327 let s = b.stats();
1328 assert!((s.imbalance_ratio - 1.0).abs() < 0.01);
1329 }
1330
1331 #[test]
1332 fn test_stats_imbalance_ratio_unequal() {
1333 let mut b = two_shard_balancer();
1334 b.record_usage("s0", 900_000).unwrap();
1335 b.record_usage("s1", 100_000).unwrap();
1336 let s = b.stats();
1337 assert!(s.imbalance_ratio > 1.0);
1338 }
1339
1340 #[test]
1345 fn test_ring_positions_sorted() {
1346 let b = two_shard_balancer();
1347 let positions = b.ring_positions();
1348 for w in positions.windows(2) {
1349 assert!(w[0].0 <= w[1].0);
1350 }
1351 }
1352
1353 #[test]
1354 fn test_ring_positions_non_empty() {
1355 let b = two_shard_balancer();
1356 assert!(!b.ring_positions().is_empty());
1357 }
1358
1359 #[test]
1360 fn test_ring_positions_contains_shard_ids() {
1361 let b = two_shard_balancer();
1362 let ids: std::collections::HashSet<_> =
1363 b.ring_positions().into_iter().map(|(_, id)| id).collect();
1364 assert!(ids.contains("s0"));
1365 assert!(ids.contains("s1"));
1366 }
1367
1368 #[test]
1369 fn test_ring_positions_after_remove() {
1370 let mut b = two_shard_balancer();
1371 b.remove_shard("s0").unwrap();
1372 let ids: std::collections::HashSet<_> =
1373 b.ring_positions().into_iter().map(|(_, id)| id).collect();
1374 assert!(!ids.contains("s0"));
1375 assert!(ids.contains("s1"));
1376 }
1377
1378 #[test]
1383 fn test_rebalance_no_ops_when_balanced() {
1384 let mut b = two_shard_balancer();
1385 b.record_usage("s0", 500_000).unwrap();
1386 b.record_usage("s1", 500_000).unwrap();
1387 let ops = b.rebalance().unwrap();
1388 assert!(ops.is_empty());
1389 }
1390
1391 #[test]
1392 fn test_rebalance_least_loaded_generates_moves() {
1393 let mut b = StorageShardBalancer::new(BalancerConfig {
1394 replication_factor: 1,
1395 virtual_nodes_per_shard: 20,
1396 rebalance_threshold: 1.5,
1397 policy: RebalancePolicy::LeastLoaded,
1398 });
1399 b.add_shard(make_shard("heavy", 1_000_000, 900_000))
1400 .unwrap();
1401 b.add_shard(make_shard("light", 1_000_000, 10_000)).unwrap();
1402 let mut state = 42_u64;
1404 for _ in 0..10 {
1405 let cid = format!("cid-{}", xorshift64(&mut state));
1406 let _ = b.assign(&cid);
1407 }
1408 let cids: Vec<_> = b.assignments.keys().cloned().collect();
1410 for cid in &cids {
1411 if let Some(a) = b.assignments.get_mut(cid) {
1412 a.shard_id = "heavy".to_string();
1413 }
1414 }
1415 let ops = b.rebalance().unwrap();
1416 assert!(ops
1417 .iter()
1418 .any(|o| matches!(o, RebalanceOp::MoveContent { .. })));
1419 }
1420
1421 #[test]
1426 fn test_rebalance_consistent_hash_fixes_misplaced() {
1427 let mut b = StorageShardBalancer::new(BalancerConfig {
1428 replication_factor: 1,
1429 virtual_nodes_per_shard: 20,
1430 rebalance_threshold: 1.0,
1431 policy: RebalancePolicy::ConsistentHash,
1432 });
1433 b.add_shard(make_shard("s0", 1_000_000, 900_000)).unwrap();
1434 b.add_shard(make_shard("s1", 1_000_000, 10_000)).unwrap();
1435 b.assign("cid-misplace").unwrap();
1436 if let Some(a) = b.assignments.get_mut("cid-misplace") {
1438 let correct = a.shard_id.clone();
1439 let wrong = if correct == "s0" {
1440 "s1".to_string()
1441 } else {
1442 "s0".to_string()
1443 };
1444 a.shard_id = wrong;
1445 }
1446 let ops = b.rebalance().unwrap();
1447 let move_ops: Vec<_> = ops
1448 .iter()
1449 .filter(|o| matches!(o, RebalanceOp::MoveContent { .. }))
1450 .collect();
1451 assert!(!move_ops.is_empty());
1452 }
1453
1454 #[test]
1459 fn test_rebalance_region_aware_boosts_weight() {
1460 let mut b = StorageShardBalancer::new(BalancerConfig {
1461 replication_factor: 1,
1462 virtual_nodes_per_shard: 20,
1463 rebalance_threshold: 1.5,
1464 policy: RebalancePolicy::RegionAware("eu-west".to_string()),
1465 });
1466 b.add_shard(make_shard_region("heavy", 1_000_000, 900_000, "us-east"))
1467 .unwrap();
1468 b.add_shard(make_shard_region("light-eu", 1_000_000, 10_000, "eu-west"))
1469 .unwrap();
1470 let _ = b.assign("r-cid");
1472 if let Some(a) = b.assignments.get_mut("r-cid") {
1473 a.shard_id = "heavy".to_string();
1474 }
1475 let ops = b.rebalance().unwrap();
1476 let update_ops: Vec<_> = ops
1477 .iter()
1478 .filter(|o| matches!(o, RebalanceOp::UpdateWeight { shard_id, .. } if shard_id == "light-eu"))
1479 .collect();
1480 assert!(!update_ops.is_empty());
1481 }
1482
1483 #[test]
1488 fn test_rebalance_capacity_weighted_generates_moves() {
1489 let mut b = StorageShardBalancer::new(BalancerConfig {
1490 replication_factor: 1,
1491 virtual_nodes_per_shard: 20,
1492 rebalance_threshold: 1.5,
1493 policy: RebalancePolicy::CapacityWeighted,
1494 });
1495 b.add_shard(make_shard("full", 1_000_000, 900_000)).unwrap();
1496 b.add_shard(make_shard("empty", 1_000_000, 0)).unwrap();
1497 let cids: Vec<String> = (0..5).map(|i| format!("cap-cid-{}", i)).collect();
1498 for cid in &cids {
1499 let _ = b.assign(cid);
1500 }
1501 for cid in &cids {
1502 if let Some(a) = b.assignments.get_mut(cid) {
1503 a.shard_id = "full".to_string();
1504 }
1505 }
1506 let ops = b.rebalance().unwrap();
1507 assert!(ops
1508 .iter()
1509 .any(|o| matches!(o, RebalanceOp::MoveContent { .. })));
1510 }
1511
1512 #[test]
1517 fn test_rebalance_minimal_movement_reduces_imbalance() {
1518 let mut b = StorageShardBalancer::new(BalancerConfig {
1519 replication_factor: 1,
1520 virtual_nodes_per_shard: 20,
1521 rebalance_threshold: 1.5,
1522 policy: RebalancePolicy::MinimalMovement,
1523 });
1524 b.add_shard(make_shard("heavy", 1_000_000, 900_000))
1525 .unwrap();
1526 b.add_shard(make_shard("light", 1_000_000, 10_000)).unwrap();
1527 let cids: Vec<String> = (0..10).map(|i| format!("min-cid-{}", i)).collect();
1528 for cid in &cids {
1529 let _ = b.assign(cid);
1530 if let Some(a) = b.assignments.get_mut(cid) {
1531 a.shard_id = "heavy".to_string();
1532 }
1533 }
1534 let before_ratio = b.stats().imbalance_ratio;
1535 let ops = b.rebalance().unwrap();
1536 let after_ratio = b.stats().imbalance_ratio;
1537 assert!(
1539 after_ratio < before_ratio
1540 || ops
1541 .iter()
1542 .any(|o| matches!(o, RebalanceOp::MoveContent { .. }))
1543 );
1544 }
1545
1546 #[test]
1551 fn test_pending_ops_tracked_in_stats() {
1552 let mut b = StorageShardBalancer::new(BalancerConfig {
1553 replication_factor: 1,
1554 virtual_nodes_per_shard: 20,
1555 rebalance_threshold: 1.5,
1556 policy: RebalancePolicy::LeastLoaded,
1557 });
1558 b.add_shard(make_shard("heavy", 1_000_000, 900_000))
1559 .unwrap();
1560 b.add_shard(make_shard("light", 1_000_000, 10_000)).unwrap();
1561 let cids: Vec<String> = (0..5).map(|i| format!("pending-{}", i)).collect();
1562 for cid in &cids {
1563 let _ = b.assign(cid);
1564 if let Some(a) = b.assignments.get_mut(cid) {
1565 a.shard_id = "heavy".to_string();
1566 }
1567 }
1568 let ops = b.rebalance().unwrap();
1569 let stats = b.stats();
1570 assert_eq!(stats.rebalance_ops_pending, ops.len());
1571 }
1572
1573 #[test]
1578 fn test_replicas_are_distinct() {
1579 let mut b = three_shard_balancer();
1580 let a = b.assign("rep-cid").unwrap();
1581 let all: Vec<&str> = std::iter::once(a.shard_id.as_str())
1582 .chain(a.replica_shards.iter().map(|s| s.as_str()))
1583 .collect();
1584 let unique: std::collections::HashSet<_> = all.iter().copied().collect();
1585 assert_eq!(
1586 all.len(),
1587 unique.len(),
1588 "replica shards must all be distinct"
1589 );
1590 }
1591
1592 #[test]
1593 fn test_rf1_no_replicas() {
1594 let mut b = StorageShardBalancer::new(BalancerConfig {
1595 replication_factor: 1,
1596 virtual_nodes_per_shard: 10,
1597 rebalance_threshold: 1.5,
1598 policy: RebalancePolicy::LeastLoaded,
1599 });
1600 b.add_shard(make_shard("solo", 1_000, 0)).unwrap();
1601 let a = b.assign("cid-solo").unwrap();
1602 assert!(a.replica_shards.is_empty());
1603 }
1604
1605 #[test]
1606 fn test_rf4_four_replicas() {
1607 let mut b = StorageShardBalancer::new(BalancerConfig {
1608 replication_factor: 4,
1609 virtual_nodes_per_shard: 20,
1610 rebalance_threshold: 1.5,
1611 policy: RebalancePolicy::LeastLoaded,
1612 });
1613 for i in 0..4_u32 {
1614 b.add_shard(make_shard(&format!("s{}", i), 1_000_000, 0))
1615 .unwrap();
1616 }
1617 let a = b.assign("cid-rf4").unwrap();
1618 assert_eq!(a.replica_shards.len(), 3);
1619 }
1620
1621 #[test]
1626 fn test_error_shard_not_found_display() {
1627 let e = BalancerError::ShardNotFound("x".to_string());
1628 assert!(e.to_string().contains("shard not found"));
1629 }
1630
1631 #[test]
1632 fn test_error_content_not_found_display() {
1633 let e = BalancerError::ContentNotFound("c".to_string());
1634 assert!(e.to_string().contains("content not found"));
1635 }
1636
1637 #[test]
1638 fn test_error_insufficient_shards_display() {
1639 let e = BalancerError::InsufficientShards { need: 3, have: 1 };
1640 assert!(e.to_string().contains("insufficient shards"));
1641 }
1642
1643 #[test]
1644 fn test_error_invalid_configuration_display() {
1645 let e = BalancerError::InvalidConfiguration("bad".to_string());
1646 assert!(e.to_string().contains("invalid configuration"));
1647 }
1648
1649 #[test]
1654 fn test_shard_utilization_zero_cap() {
1655 let s = ShardNode {
1656 capacity_bytes: 0,
1657 ..ShardNode::default()
1658 };
1659 assert_eq!(s.utilization(), 1.0);
1660 }
1661
1662 #[test]
1663 fn test_shard_utilization_half() {
1664 let s = make_shard("x", 1_000, 500);
1665 assert!((s.utilization() - 0.5).abs() < 1e-9);
1666 }
1667
1668 #[test]
1669 fn test_shard_free_bytes() {
1670 let s = make_shard("x", 1_000, 300);
1671 assert_eq!(s.free_bytes(), 700);
1672 }
1673
1674 #[test]
1675 fn test_shard_capacity_weight() {
1676 let s = make_shard("x", 1_000, 0);
1677 assert!((s.capacity_weight() - 1.0).abs() < 1e-9);
1678 }
1679
1680 #[test]
1681 fn test_shard_capacity_weight_full() {
1682 let s = make_shard("x", 1_000, 1_000);
1683 assert!((s.capacity_weight() - 0.0).abs() < 1e-9);
1684 }
1685
1686 #[test]
1691 fn test_assignment_count() {
1692 let mut b = two_shard_balancer();
1693 b.assign("c1").unwrap();
1694 b.assign("c2").unwrap();
1695 b.assign("c3").unwrap();
1696 assert_eq!(b.assignment_count(), 3);
1697 }
1698
1699 #[test]
1700 fn test_shards_iterator() {
1701 let b = three_shard_balancer();
1702 assert_eq!(b.shards().count(), 3);
1703 }
1704
1705 #[test]
1706 fn test_shard_accessor() {
1707 let b = two_shard_balancer();
1708 assert!(b.shard("s0").is_some());
1709 assert!(b.shard("ghost").is_none());
1710 }
1711
1712 #[test]
1713 fn test_config_accessor() {
1714 let b = two_shard_balancer();
1715 assert_eq!(b.config().replication_factor, 2);
1716 }
1717
1718 #[test]
1719 fn test_large_ring_distribution() {
1720 let mut b = StorageShardBalancer::new(BalancerConfig {
1721 replication_factor: 1,
1722 virtual_nodes_per_shard: 150,
1723 rebalance_threshold: 2.0,
1724 policy: RebalancePolicy::LeastLoaded,
1725 });
1726 for i in 0..5_u32 {
1727 let mut s = make_shard(&format!("shard-{}", i), 10_000_000, 0);
1728 s.virtual_nodes = 150;
1729 b.add_shard(s).unwrap();
1730 }
1731 let mut counts: HashMap<String, usize> = HashMap::new();
1732 let mut state = 7_u64;
1733 for _ in 0..1000 {
1734 let cid = format!("cid-{}", xorshift64(&mut state));
1735 let a = b.assign(&cid).unwrap();
1736 *counts.entry(a.shard_id).or_insert(0) += 1;
1737 }
1738 for (shard_id, count) in &counts {
1740 assert!(
1741 *count >= 20,
1742 "shard {} received only {} / 1000 assignments",
1743 shard_id,
1744 count
1745 );
1746 }
1747 }
1748
1749 #[test]
1750 fn test_reassign_after_remove_uses_remaining_shards() {
1751 let mut b = three_shard_balancer();
1752 b.assign("cid-remain").unwrap();
1753 b.remove_shard("s2").unwrap();
1754 for a in b.assignments.values() {
1756 assert_ne!(a.shard_id, "s2");
1757 }
1758 }
1759
1760 #[test]
1761 fn test_assign_all_shards_unhealthy_error() {
1762 let mut b = two_shard_balancer();
1763 b.set_shard_health("s0", false).unwrap();
1764 b.set_shard_health("s1", false).unwrap();
1765 let err = b.assign("cid-unhealthy");
1766 assert!(matches!(err, Err(BalancerError::InsufficientShards { .. })));
1767 }
1768
1769 #[test]
1770 fn test_rebalance_consistent_hash_no_moves_when_correct() {
1771 let mut b = StorageShardBalancer::new(BalancerConfig {
1772 replication_factor: 1,
1773 virtual_nodes_per_shard: 20,
1774 rebalance_threshold: 1.0,
1775 policy: RebalancePolicy::ConsistentHash,
1776 });
1777 b.add_shard(make_shard("s0", 1_000_000, 900_000)).unwrap();
1778 b.add_shard(make_shard("s1", 1_000_000, 10_000)).unwrap();
1779 let _ = b.assign("no-move-cid");
1781 let ops = b.rebalance().unwrap();
1783 let moves: Vec<_> = ops
1784 .iter()
1785 .filter(|o| matches!(o, RebalanceOp::MoveContent { .. }))
1786 .collect();
1787 assert!(
1788 moves.is_empty(),
1789 "correctly placed content should not be moved"
1790 );
1791 }
1792}