Skip to main content

ipfrs_storage/
storage_shard_balancer.rs

1//! StorageShardBalancer — consistent-hashing-based shard management and rebalancing.
2//!
3//! Uses FNV-1a virtual nodes arranged in a sorted ring (BTreeMap) for O(log n)
4//! lookup. Supports multiple rebalancing policies: LeastLoaded, ConsistentHash,
5//! RegionAware, CapacityWeighted, and MinimalMovement.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ipfrs_storage::storage_shard_balancer::{
11//!     StorageShardBalancer, ShardNode, BalancerConfig, RebalancePolicy,
12//! };
13//!
14//! let config = BalancerConfig {
15//!     replication_factor: 2,
16//!     virtual_nodes_per_shard: 10,
17//!     rebalance_threshold: 1.5,
18//!     policy: RebalancePolicy::LeastLoaded,
19//! };
20//! let mut balancer = StorageShardBalancer::new(config);
21//!
22//! balancer.add_shard(ShardNode {
23//!     id: "shard-a".to_string(),
24//!     capacity_bytes: 1_000_000,
25//!     used_bytes: 0,
26//!     virtual_nodes: 10,
27//!     is_healthy: true,
28//!     region: "us-east".to_string(),
29//!     weight: 1.0,
30//! }).unwrap();
31//!
32//! balancer.add_shard(ShardNode {
33//!     id: "shard-b".to_string(),
34//!     capacity_bytes: 1_000_000,
35//!     used_bytes: 0,
36//!     virtual_nodes: 10,
37//!     is_healthy: true,
38//!     region: "us-west".to_string(),
39//!     weight: 1.0,
40//! }).unwrap();
41//!
42//! let assignment = balancer.assign("QmExampleCid123").unwrap();
43//! assert!(!assignment.shard_id.is_empty());
44//! ```
45
46use std::collections::{BTreeMap, HashMap};
47
48// ---------------------------------------------------------------------------
49// FNV-1a hashing
50// ---------------------------------------------------------------------------
51
52/// FNV-1a 64-bit hash — deterministic and fast for consistent hashing.
53pub 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
71/// Xorshift64 PRNG — used in tests (no `rand` crate dependency).
72pub 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// ---------------------------------------------------------------------------
82// Core types
83// ---------------------------------------------------------------------------
84
85/// A storage shard node participating in the consistent hash ring.
86#[derive(Debug, Clone)]
87pub struct ShardNode {
88    /// Unique identifier for the shard.
89    pub id: String,
90    /// Total storage capacity in bytes.
91    pub capacity_bytes: u64,
92    /// Currently used storage in bytes.
93    pub used_bytes: u64,
94    /// Number of virtual nodes to place in the ring (default 150).
95    pub virtual_nodes: usize,
96    /// Whether this shard is accepting reads/writes.
97    pub is_healthy: bool,
98    /// Deployment region identifier.
99    pub region: String,
100    /// Relative weight for capacity-weighted policies (default 1.0).
101    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    /// Utilization ratio in [0.0, 1.0].
120    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    /// Free bytes remaining.
128    pub fn free_bytes(&self) -> u64 {
129        self.capacity_bytes.saturating_sub(self.used_bytes)
130    }
131
132    /// Effective capacity weight for CapacityWeighted policy.
133    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/// Assignment of a CID to primary and replica shards.
143#[derive(Debug, Clone)]
144pub struct ShardAssignment {
145    /// Content identifier being assigned.
146    pub cid: String,
147    /// Primary shard holding the canonical copy.
148    pub shard_id: String,
149    /// Replica shards for redundancy.
150    pub replica_shards: Vec<String>,
151    /// Unix timestamp (seconds) when the assignment was created.
152    pub assigned_at: u64,
153}
154
155/// Rebalancing operations produced by the balancer.
156#[derive(Debug, Clone, PartialEq)]
157pub enum RebalanceOp {
158    /// Move a piece of content from one shard to another.
159    MoveContent {
160        cid: String,
161        from_shard: String,
162        to_shard: String,
163    },
164    /// Add a virtual node at the given ring position.
165    AddVirtualNode { shard_id: String, position: u64 },
166    /// Remove a virtual node from the given ring position.
167    RemoveVirtualNode { shard_id: String, position: u64 },
168    /// Update the weight of a shard.
169    UpdateWeight { shard_id: String, new_weight: f64 },
170}
171
172/// Rebalancing policy controlling how imbalance is resolved.
173#[derive(Debug, Clone, PartialEq)]
174pub enum RebalancePolicy {
175    /// Move content from the most-loaded shard to the least-loaded shard.
176    LeastLoaded,
177    /// Reassign misplaced content to its ring-correct shard.
178    ConsistentHash,
179    /// Prefer shards in the given region for new assignments.
180    RegionAware(String),
181    /// Weight assignments by (capacity − used) / capacity.
182    CapacityWeighted,
183    /// Only move what is strictly necessary to fall below the threshold.
184    MinimalMovement,
185}
186
187/// Configuration for `StorageShardBalancer`.
188#[derive(Debug, Clone)]
189pub struct BalancerConfig {
190    /// Number of replica copies per CID (primary + replicas).
191    pub replication_factor: usize,
192    /// Virtual nodes per shard placed in the ring.
193    pub virtual_nodes_per_shard: usize,
194    /// Trigger rebalancing if max_load / min_load > threshold.
195    pub rebalance_threshold: f64,
196    /// Policy used when generating rebalance operations.
197    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/// Snapshot of balancer state for observability.
212#[derive(Debug, Clone)]
213pub struct SsbBalancerStats {
214    /// Number of shards in the ring.
215    pub shard_count: usize,
216    /// Sum of all shard capacities.
217    pub total_capacity_bytes: u64,
218    /// Sum of all shard used bytes.
219    pub total_used_bytes: u64,
220    /// Overall utilization percentage (0–100).
221    pub utilization_pct: f64,
222    /// Ratio of the most-loaded to least-loaded shard (by utilization).
223    pub imbalance_ratio: f64,
224    /// Number of pending rebalance operations (from last `rebalance()` call).
225    pub rebalance_ops_pending: usize,
226}
227
228/// Errors produced by the balancer.
229#[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
247// ---------------------------------------------------------------------------
248// StorageShardBalancer
249// ---------------------------------------------------------------------------
250
251/// Production-grade consistent-hashing shard balancer.
252///
253/// Virtual nodes for each shard are distributed across a `BTreeMap<u64, String>`
254/// ring. Content is assigned to shards by walking the ring clockwise from the
255/// FNV-1a hash of the CID, selecting the first `replication_factor` distinct
256/// healthy shards.
257pub struct StorageShardBalancer {
258    config: BalancerConfig,
259    /// Virtual-node ring: position → shard_id.
260    ring: BTreeMap<u64, String>,
261    /// All registered shards.
262    shards: HashMap<String, ShardNode>,
263    /// Current content assignments.
264    assignments: HashMap<String, ShardAssignment>,
265    /// Pending operations from the last `rebalance()` call.
266    pending_ops: Vec<RebalanceOp>,
267    /// Monotonic counter used as a simple clock for `assigned_at`.
268    clock: u64,
269}
270
271impl StorageShardBalancer {
272    /// Create a new balancer with the provided configuration.
273    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    /// Create a balancer with default configuration.
285    pub fn with_defaults() -> Self {
286        Self::new(BalancerConfig::default())
287    }
288
289    // -----------------------------------------------------------------------
290    // Shard management
291    // -----------------------------------------------------------------------
292
293    /// Register a shard and add its virtual nodes to the ring.
294    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    /// Remove a shard from the ring and generate `MoveContent` ops for all
315    /// affected assignments.
316    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        // Remove all virtual nodes belonging to this shard.
322        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        // Generate MoveContent ops for assignments that involve this shard.
340        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            // Attempt to re-assign to a new shard (ring already updated).
354            match self.assign_internal(cid) {
355                Ok(new_assignment) => {
356                    // Emit a MoveContent op to the new primary.
357                    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                    // Not enough shards remain — remove the assignment.
366                    self.assignments.remove(cid);
367                }
368            }
369        }
370
371        Ok(ops)
372    }
373
374    // -----------------------------------------------------------------------
375    // Assignment
376    // -----------------------------------------------------------------------
377
378    /// Assign a CID to shards using consistent hashing.
379    ///
380    /// Returns an error when fewer than `replication_factor` healthy shards
381    /// are available.
382    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        // Walk the ring clockwise from `hash`, collecting `needed` distinct
424        // healthy shards. We must iterate twice (wrap around) in the worst case.
425        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    /// Returns a reference to an existing assignment.
461    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    // -----------------------------------------------------------------------
468    // Usage tracking
469    // -----------------------------------------------------------------------
470
471    /// Update used bytes for a shard (delta can be negative for deletions).
472    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    // -----------------------------------------------------------------------
486    // Health
487    // -----------------------------------------------------------------------
488
489    /// Mark a shard healthy or unhealthy.
490    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    // -----------------------------------------------------------------------
500    // Rebalancing
501    // -----------------------------------------------------------------------
502
503    /// Compute rebalance operations if the imbalance ratio exceeds the
504    /// configured threshold.  Operations are also stored in `pending_ops`.
505    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    /// LeastLoaded: move content from most-loaded shard to least-loaded shard.
528    fn rebalance_least_loaded(&mut self) -> Vec<RebalanceOp> {
529        let mut ops = Vec::new();
530        let threshold = self.config.rebalance_threshold;
531
532        // Iterative: repeatedly move one item from most-loaded to least-loaded
533        // until the ratio drops below threshold or no moves are possible.
534        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            // Pick one content item currently primary on most_id.
547            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                    // Estimate block size (not tracked exactly; use 1 byte placeholder).
557                    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                    // Update in-memory state to reflect the move.
564                    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    /// ConsistentHash: find content whose primary shard differs from the
580    /// hash-assigned shard and emit moves.
581    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    /// RegionAware: generate UpdateWeight ops to prefer shards in the given
610    /// region, then run LeastLoaded within those shards.
611    fn rebalance_region_aware(&mut self, prefer_region: &str) -> Vec<RebalanceOp> {
612        let mut ops = Vec::new();
613        // Boost weight for shards in the preferred region.
614        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        // Then perform a LeastLoaded rebalance with the updated weights.
630        ops.extend(self.rebalance_least_loaded());
631        ops
632    }
633
634    /// CapacityWeighted: move content from over-capacity shards to shards with
635    /// the highest (free/capacity) * weight score.
636    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            // Most loaded by utilization.
642            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            // Best destination by capacity_weight.
654            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    /// MinimalMovement: only move the minimum number of items needed to bring
706    /// the imbalance ratio below the threshold.
707    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            // Move exactly one item.
720            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    // -----------------------------------------------------------------------
750    // Stats and inspection
751    // -----------------------------------------------------------------------
752
753    /// Return a snapshot of current balancer statistics.
754    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    /// Return all virtual node ring positions, sorted ascending.
778    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    /// Return the current config.
786    pub fn config(&self) -> &BalancerConfig {
787        &self.config
788    }
789
790    /// Return a reference to a shard by id.
791    pub fn shard(&self, shard_id: &str) -> Option<&ShardNode> {
792        self.shards.get(shard_id)
793    }
794
795    /// Return iterator over all registered shards.
796    pub fn shards(&self) -> impl Iterator<Item = &ShardNode> {
797        self.shards.values()
798    }
799
800    /// Return the number of CIDs currently assigned.
801    pub fn assignment_count(&self) -> usize {
802        self.assignments.len()
803    }
804
805    // -----------------------------------------------------------------------
806    // Internal helpers
807    // -----------------------------------------------------------------------
808
809    /// Walk the ring from the CID hash and return the primary healthy shard id.
810    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 the most-loaded shard has zero utilization, everything is empty —
872        // no imbalance.
873        if max <= 0.0 {
874            return 1.0;
875        }
876
877        // If the least-loaded shard has zero utilization but some shard is
878        // loaded, treat the ratio as very large (extreme imbalance).
879        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// ---------------------------------------------------------------------------
893// Tests
894// ---------------------------------------------------------------------------
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    // -----------------------------------------------------------------------
901    // Helpers
902    // -----------------------------------------------------------------------
903
904    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    // -----------------------------------------------------------------------
954    // FNV-1a correctness
955    // -----------------------------------------------------------------------
956
957    #[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    // -----------------------------------------------------------------------
980    // xorshift64 PRNG
981    // -----------------------------------------------------------------------
982
983    #[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    // -----------------------------------------------------------------------
999    // add_shard
1000    // -----------------------------------------------------------------------
1001
1002    #[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        // Collisions may reduce count; assert at least 1 unique node placed.
1020        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    // -----------------------------------------------------------------------
1047    // remove_shard
1048    // -----------------------------------------------------------------------
1049
1050    #[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        // At minimum we expect 0 or more moves (depends on which shards held
1089        // the content).  Assert the ring no longer contains s0.
1090        let _ = moves;
1091        assert!(!b.shards.contains_key("s0"));
1092    }
1093
1094    // -----------------------------------------------------------------------
1095    // assign
1096    // -----------------------------------------------------------------------
1097
1098    #[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        // 1 primary + (rf-1) replicas = rf total
1111        assert_eq!(a.replica_shards.len(), 2);
1112        // All distinct
1113        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        // Use rf=2 with 4 shards so marking one unhealthy still leaves 3 healthy.
1169        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    // -----------------------------------------------------------------------
1188    // lookup
1189    // -----------------------------------------------------------------------
1190
1191    #[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    // -----------------------------------------------------------------------
1208    // record_usage
1209    // -----------------------------------------------------------------------
1210
1211    #[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        // used_bytes capped at capacity (u64 always fits in u64::MAX)
1238        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    // -----------------------------------------------------------------------
1249    // set_shard_health
1250    // -----------------------------------------------------------------------
1251
1252    #[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        // Use rf=2 with 4 shards so marking one unhealthy still leaves 3 healthy.
1277        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    // -----------------------------------------------------------------------
1295    // stats
1296    // -----------------------------------------------------------------------
1297
1298    #[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    // -----------------------------------------------------------------------
1341    // ring_positions
1342    // -----------------------------------------------------------------------
1343
1344    #[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    // -----------------------------------------------------------------------
1379    // rebalance — LeastLoaded
1380    // -----------------------------------------------------------------------
1381
1382    #[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        // Assign some content to heavy shard.
1403        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        // Force all assignments to heavy shard so moves are guaranteed.
1409        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    // -----------------------------------------------------------------------
1422    // rebalance — ConsistentHash
1423    // -----------------------------------------------------------------------
1424
1425    #[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        // Manually misplace.
1437        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    // -----------------------------------------------------------------------
1455    // rebalance — RegionAware
1456    // -----------------------------------------------------------------------
1457
1458    #[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        // Force assignment on heavy.
1471        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    // -----------------------------------------------------------------------
1484    // rebalance — CapacityWeighted
1485    // -----------------------------------------------------------------------
1486
1487    #[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    // -----------------------------------------------------------------------
1513    // rebalance — MinimalMovement
1514    // -----------------------------------------------------------------------
1515
1516    #[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        // Imbalance should have decreased (or ops were generated).
1538        assert!(
1539            after_ratio < before_ratio
1540                || ops
1541                    .iter()
1542                    .any(|o| matches!(o, RebalanceOp::MoveContent { .. }))
1543        );
1544    }
1545
1546    // -----------------------------------------------------------------------
1547    // rebalance — stats.rebalance_ops_pending
1548    // -----------------------------------------------------------------------
1549
1550    #[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    // -----------------------------------------------------------------------
1574    // Replication / multi-replica tests
1575    // -----------------------------------------------------------------------
1576
1577    #[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    // -----------------------------------------------------------------------
1622    // Error cases
1623    // -----------------------------------------------------------------------
1624
1625    #[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    // -----------------------------------------------------------------------
1650    // ShardNode helpers
1651    // -----------------------------------------------------------------------
1652
1653    #[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    // -----------------------------------------------------------------------
1687    // Miscellaneous / integration
1688    // -----------------------------------------------------------------------
1689
1690    #[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        // Each shard should receive at least 5% of assignments.
1739        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        // All remaining assignments should reference only s0 or s1.
1755        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        // Assign and let the ring decide — assignments should already be correct.
1780        let _ = b.assign("no-move-cid");
1781        // Consistent hash on correct assignments should produce no moves.
1782        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}