Skip to main content

ipfrs_storage/
replication_manager.rs

1//! Storage Replication Manager
2//!
3//! Tracks and manages block replication across storage nodes with configurable
4//! replication factors and health monitoring. Provides visibility into replica
5//! health, selection of candidate nodes for new replicas, stale node eviction,
6//! and comprehensive statistics.
7
8use std::collections::HashMap;
9
10// ── Error ─────────────────────────────────────────────────────────────────────
11
12/// Errors produced by [`StorageReplicationManager`].
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ReplicationError {
15    /// A node with the given ID is already registered.
16    NodeAlreadyRegistered(String),
17    /// No node with the given ID is registered.
18    NodeNotFound(String),
19    /// No replicas are tracked for the given CID.
20    CidNotFound(String),
21}
22
23impl std::fmt::Display for ReplicationError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::NodeAlreadyRegistered(id) => write!(f, "node already registered: {id}"),
27            Self::NodeNotFound(id) => write!(f, "node not found: {id}"),
28            Self::CidNotFound(cid) => write!(f, "CID not found: {cid}"),
29        }
30    }
31}
32
33impl std::error::Error for ReplicationError {}
34
35// ── ReplicaNode ───────────────────────────────────────────────────────────────
36
37/// A storage node that participates in the replication cluster.
38#[derive(Debug, Clone)]
39pub struct ReplicaNode {
40    /// Unique identifier for the node.
41    pub node_id: String,
42    /// Network address of the node.
43    pub address: String,
44    /// Millisecond timestamp of when the node was last observed.
45    pub last_seen: u64,
46    /// Whether the node is currently considered healthy.
47    pub healthy: bool,
48    /// Total storage capacity in bytes.
49    pub capacity_bytes: u64,
50    /// Bytes currently in use.
51    pub used_bytes: u64,
52}
53
54impl ReplicaNode {
55    /// Returns the number of bytes available for new data.
56    pub fn available_bytes(&self) -> u64 {
57        self.capacity_bytes.saturating_sub(self.used_bytes)
58    }
59
60    /// Returns the fraction of capacity that is used, in `[0.0, 1.0]`.
61    pub fn utilization(&self) -> f64 {
62        self.used_bytes as f64 / self.capacity_bytes.max(1) as f64
63    }
64}
65
66// ── ReplicaLocation ───────────────────────────────────────────────────────────
67
68/// Records where a specific CID replica is stored.
69#[derive(Debug, Clone)]
70pub struct RmReplicaLocation {
71    /// The node that holds this replica.
72    pub node_id: String,
73    /// Content identifier being stored.
74    pub cid: String,
75    /// Millisecond timestamp when the replica was stored.
76    pub stored_at: u64,
77    /// Millisecond timestamp of the last successful verification, if any.
78    pub verified_at: Option<u64>,
79    /// Checksum of the stored content.
80    pub checksum: u64,
81}
82
83// ── ReplicationPolicy ─────────────────────────────────────────────────────────
84
85/// Policy governing how many replicas are maintained and which nodes are chosen.
86#[derive(Debug, Clone)]
87pub struct RmReplicationPolicy {
88    /// Target number of replicas per CID.
89    pub replication_factor: u32,
90    /// Minimum number of healthy replicas required for a CID to be `Healthy`.
91    pub min_healthy_replicas: u32,
92    /// Prefer nodes that are in the same locality.
93    pub prefer_local: bool,
94    /// Nodes above this utilization fraction are not eligible for new replicas.
95    pub max_node_utilization: f64,
96}
97
98impl Default for RmReplicationPolicy {
99    fn default() -> Self {
100        Self {
101            replication_factor: 3,
102            min_healthy_replicas: 2,
103            prefer_local: false,
104            max_node_utilization: 0.85,
105        }
106    }
107}
108
109// ── ReplicationStatus ─────────────────────────────────────────────────────────
110
111/// Health status of a CID's replication across the cluster.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum RmReplicationStatus {
114    /// Healthy replicas meet or exceed `min_healthy_replicas`.
115    Healthy {
116        /// Number of healthy replicas currently known.
117        replicas: u32,
118    },
119    /// Healthy replicas are below `min_healthy_replicas`.
120    UnderReplicated {
121        /// Number of healthy replicas currently known.
122        current: u32,
123        /// Target replication factor.
124        target: u32,
125    },
126    /// Healthy replicas exceed `replication_factor`.
127    OverReplicated {
128        /// Number of healthy replicas currently known.
129        current: u32,
130        /// Target replication factor.
131        target: u32,
132    },
133    /// No replicas exist for this CID at all.
134    Missing,
135}
136
137// ── ReplicationStats ──────────────────────────────────────────────────────────
138
139/// Aggregate statistics for [`StorageReplicationManager`].
140#[derive(Debug, Clone)]
141pub struct RmReplicationStats {
142    /// Total number of distinct CIDs being tracked.
143    pub total_cids: usize,
144    /// CIDs whose status is `Healthy`.
145    pub healthy_cids: usize,
146    /// CIDs whose status is `UnderReplicated`.
147    pub under_replicated_cids: usize,
148    /// CIDs whose status is `OverReplicated`.
149    pub over_replicated_cids: usize,
150    /// CIDs whose status is `Missing`.
151    pub missing_cids: usize,
152    /// Total number of nodes registered.
153    pub total_nodes: usize,
154    /// Number of nodes that are currently healthy.
155    pub healthy_nodes: usize,
156    /// Mean number of healthy replicas across all tracked CIDs.
157    pub avg_replication_factor: f64,
158}
159
160// ── StorageReplicationManager ─────────────────────────────────────────────────
161
162/// Manages block replication across a set of storage nodes.
163///
164/// Tracks the health and capacity of each node, records where each CID is
165/// stored, and exposes methods to query replication status, select candidate
166/// nodes for new replicas, and evict stale nodes.
167pub struct StorageReplicationManager {
168    /// Policy controlling replication factors and node selection.
169    pub policy: RmReplicationPolicy,
170    /// Registered nodes, keyed by `node_id`.
171    pub nodes: HashMap<String, ReplicaNode>,
172    /// Replica locations, keyed by CID. Each entry is a list of locations.
173    pub replicas: HashMap<String, Vec<RmReplicaLocation>>,
174}
175
176impl StorageReplicationManager {
177    /// Create a new manager with the supplied policy.
178    pub fn new(policy: RmReplicationPolicy) -> Self {
179        Self {
180            policy,
181            nodes: HashMap::new(),
182            replicas: HashMap::new(),
183        }
184    }
185
186    // ── Node management ───────────────────────────────────────────────
187
188    /// Register a new storage node.
189    ///
190    /// Returns [`ReplicationError::NodeAlreadyRegistered`] if a node with the
191    /// same `node_id` is already present.
192    pub fn register_node(&mut self, node: ReplicaNode) -> Result<(), ReplicationError> {
193        if self.nodes.contains_key(&node.node_id) {
194            return Err(ReplicationError::NodeAlreadyRegistered(
195                node.node_id.clone(),
196            ));
197        }
198        self.nodes.insert(node.node_id.clone(), node);
199        Ok(())
200    }
201
202    /// Remove a node and all replica records that reference it.
203    ///
204    /// Returns [`ReplicationError::NodeNotFound`] if the node is not present.
205    pub fn deregister_node(&mut self, node_id: &str) -> Result<(), ReplicationError> {
206        if self.nodes.remove(node_id).is_none() {
207            return Err(ReplicationError::NodeNotFound(node_id.to_string()));
208        }
209        // Remove replica records that referenced the removed node.
210        for locations in self.replicas.values_mut() {
211            locations.retain(|loc| loc.node_id != node_id);
212        }
213        // Drop CID entries that are now empty.
214        self.replicas.retain(|_, locs| !locs.is_empty());
215        Ok(())
216    }
217
218    /// Mark a node as healthy and update its `last_seen` timestamp.
219    ///
220    /// Returns `true` if the node was found.
221    pub fn mark_healthy(&mut self, node_id: &str, now: u64) -> bool {
222        if let Some(node) = self.nodes.get_mut(node_id) {
223            node.healthy = true;
224            node.last_seen = now;
225            true
226        } else {
227            false
228        }
229    }
230
231    /// Mark a node as unhealthy.
232    ///
233    /// Returns `true` if the node was found.
234    pub fn mark_unhealthy(&mut self, node_id: &str) -> bool {
235        if let Some(node) = self.nodes.get_mut(node_id) {
236            node.healthy = false;
237            true
238        } else {
239            false
240        }
241    }
242
243    // ── Replica management ────────────────────────────────────────────
244
245    /// Record that a CID has been stored on a node.
246    ///
247    /// Returns [`ReplicationError::NodeNotFound`] if `node_id` is not registered.
248    pub fn record_replica(
249        &mut self,
250        cid: String,
251        node_id: &str,
252        checksum: u64,
253        now: u64,
254    ) -> Result<(), ReplicationError> {
255        if !self.nodes.contains_key(node_id) {
256            return Err(ReplicationError::NodeNotFound(node_id.to_string()));
257        }
258        let location = RmReplicaLocation {
259            node_id: node_id.to_string(),
260            cid: cid.clone(),
261            stored_at: now,
262            verified_at: None,
263            checksum,
264        };
265        self.replicas.entry(cid).or_default().push(location);
266        Ok(())
267    }
268
269    /// Update the `verified_at` timestamp for a specific CID + node pair.
270    ///
271    /// Returns `true` if the replica record was found and updated.
272    pub fn verify_replica(&mut self, cid: &str, node_id: &str, now: u64) -> bool {
273        if let Some(locations) = self.replicas.get_mut(cid) {
274            for loc in locations.iter_mut() {
275                if loc.node_id == node_id {
276                    loc.verified_at = Some(now);
277                    return true;
278                }
279            }
280        }
281        false
282    }
283
284    /// Remove a specific replica location.
285    ///
286    /// Returns `true` if the location was found and removed.
287    pub fn remove_replica(&mut self, cid: &str, node_id: &str) -> bool {
288        if let Some(locations) = self.replicas.get_mut(cid) {
289            let before = locations.len();
290            locations.retain(|loc| loc.node_id != node_id);
291            let removed = locations.len() < before;
292            if locations.is_empty() {
293                self.replicas.remove(cid);
294            }
295            removed
296        } else {
297            false
298        }
299    }
300
301    // ── Status queries ────────────────────────────────────────────────
302
303    /// Return the replication status for a CID.
304    ///
305    /// A "healthy replica" is one where both the replica record exists **and**
306    /// the backing node is registered and marked healthy.
307    pub fn replication_status(&self, cid: &str) -> RmReplicationStatus {
308        let Some(locations) = self.replicas.get(cid) else {
309            return RmReplicationStatus::Missing;
310        };
311
312        let healthy_count = self.count_healthy_replicas(locations);
313        let target = self.policy.replication_factor;
314        let min_healthy = self.policy.min_healthy_replicas;
315
316        if healthy_count == 0 {
317            // There are location records but no healthy nodes backing them.
318            RmReplicationStatus::UnderReplicated { current: 0, target }
319        } else if healthy_count > target {
320            RmReplicationStatus::OverReplicated {
321                current: healthy_count,
322                target,
323            }
324        } else if healthy_count >= min_healthy {
325            RmReplicationStatus::Healthy {
326                replicas: healthy_count,
327            }
328        } else {
329            RmReplicationStatus::UnderReplicated {
330                current: healthy_count,
331                target,
332            }
333        }
334    }
335
336    /// Return all CIDs that are `UnderReplicated` or `Missing`, sorted.
337    pub fn under_replicated_cids(&self) -> Vec<&str> {
338        let mut result: Vec<&str> = self
339            .all_known_cids()
340            .filter(|cid| {
341                matches!(
342                    self.replication_status(cid),
343                    RmReplicationStatus::UnderReplicated { .. } | RmReplicationStatus::Missing
344                )
345            })
346            .collect();
347        result.sort_unstable();
348        result
349    }
350
351    /// Return all CIDs that are `OverReplicated`, sorted.
352    pub fn over_replicated_cids(&self) -> Vec<&str> {
353        let mut result: Vec<&str> = self
354            .all_known_cids()
355            .filter(|cid| {
356                matches!(
357                    self.replication_status(cid),
358                    RmReplicationStatus::OverReplicated { .. }
359                )
360            })
361            .collect();
362        result.sort_unstable();
363        result
364    }
365
366    /// Select up to `count` healthy nodes suitable for storing a new replica of `cid`.
367    ///
368    /// Eligibility criteria (in order of filtering):
369    /// 1. Node must be healthy.
370    /// 2. Node must not already hold a replica of the given CID.
371    /// 3. Node's utilization must be ≤ `max_node_utilization`.
372    ///
373    /// Among eligible nodes, those with the most `available_bytes` are preferred.
374    pub fn select_nodes_for_replication(&self, cid: &str, count: usize) -> Vec<&ReplicaNode> {
375        // Collect node IDs that already have a replica for this CID.
376        let already_has: std::collections::HashSet<&str> = self
377            .replicas
378            .get(cid)
379            .map(|locs| locs.iter().map(|l| l.node_id.as_str()).collect())
380            .unwrap_or_default();
381
382        let max_util = self.policy.max_node_utilization;
383
384        let mut candidates: Vec<&ReplicaNode> = self
385            .nodes
386            .values()
387            .filter(|n| {
388                n.healthy
389                    && !already_has.contains(n.node_id.as_str())
390                    && n.utilization() <= max_util
391            })
392            .collect();
393
394        // Sort descending by available bytes so we pick the most-available nodes first.
395        candidates.sort_by_key(|n| std::cmp::Reverse(n.available_bytes()));
396        candidates.truncate(count);
397        candidates
398    }
399
400    // ── Maintenance ───────────────────────────────────────────────────
401
402    /// Remove nodes whose `last_seen` is more than `max_age_ms` milliseconds
403    /// before `now`, and clean up associated replica records.
404    ///
405    /// Returns the number of nodes evicted.
406    pub fn evict_stale_nodes(&mut self, max_age_ms: u64, now: u64) -> usize {
407        let stale_ids: Vec<String> = self
408            .nodes
409            .iter()
410            .filter(|(_, n)| now.saturating_sub(n.last_seen) > max_age_ms)
411            .map(|(id, _)| id.clone())
412            .collect();
413
414        let count = stale_ids.len();
415        for id in &stale_ids {
416            self.nodes.remove(id);
417            for locations in self.replicas.values_mut() {
418                locations.retain(|loc| &loc.node_id != id);
419            }
420        }
421        self.replicas.retain(|_, locs| !locs.is_empty());
422        count
423    }
424
425    // ── Counts ────────────────────────────────────────────────────────
426
427    /// Returns the total number of distinct CIDs being tracked.
428    pub fn cid_count(&self) -> usize {
429        self.replicas.len()
430    }
431
432    /// Returns the total number of registered nodes.
433    pub fn node_count(&self) -> usize {
434        self.nodes.len()
435    }
436
437    /// Returns the number of nodes currently marked as healthy.
438    pub fn healthy_node_count(&self) -> usize {
439        self.nodes.values().filter(|n| n.healthy).count()
440    }
441
442    // ── Statistics ────────────────────────────────────────────────────
443
444    /// Compute aggregate statistics for the current state.
445    pub fn stats(&self) -> RmReplicationStats {
446        let total_cids = self.cid_count();
447        let total_nodes = self.node_count();
448        let healthy_nodes = self.healthy_node_count();
449
450        let mut healthy_cids = 0usize;
451        let mut under_replicated_cids = 0usize;
452        let mut over_replicated_cids = 0usize;
453        let mut missing_cids = 0usize;
454        let mut total_healthy_replicas = 0u64;
455
456        for cid in self.replicas.keys() {
457            match self.replication_status(cid) {
458                RmReplicationStatus::Healthy { replicas } => {
459                    healthy_cids += 1;
460                    total_healthy_replicas += u64::from(replicas);
461                }
462                RmReplicationStatus::UnderReplicated { current, .. } => {
463                    under_replicated_cids += 1;
464                    total_healthy_replicas += u64::from(current);
465                }
466                RmReplicationStatus::OverReplicated { current, .. } => {
467                    over_replicated_cids += 1;
468                    total_healthy_replicas += u64::from(current);
469                }
470                RmReplicationStatus::Missing => {
471                    missing_cids += 1;
472                }
473            }
474        }
475
476        let avg_replication_factor = if total_cids == 0 {
477            0.0
478        } else {
479            total_healthy_replicas as f64 / total_cids as f64
480        };
481
482        RmReplicationStats {
483            total_cids,
484            healthy_cids,
485            under_replicated_cids,
486            over_replicated_cids,
487            missing_cids,
488            total_nodes,
489            healthy_nodes,
490            avg_replication_factor,
491        }
492    }
493
494    // ── Private helpers ───────────────────────────────────────────────
495
496    fn count_healthy_replicas(&self, locations: &[RmReplicaLocation]) -> u32 {
497        locations
498            .iter()
499            .filter(|loc| {
500                self.nodes
501                    .get(&loc.node_id)
502                    .map(|n| n.healthy)
503                    .unwrap_or(false)
504            })
505            .count() as u32
506    }
507
508    fn all_known_cids(&self) -> impl Iterator<Item = &str> {
509        self.replicas.keys().map(|s| s.as_str())
510    }
511}
512
513// ── Re-exports kept for backward compatibility ────────────────────────────────
514
515/// Configuration for the replication manager (legacy name kept for backwards
516/// compatibility; prefer [`RmReplicationPolicy`] for new code).
517#[derive(Debug, Clone)]
518pub struct ReplicationConfig {
519    /// Default number of replicas desired per block.
520    pub default_replica_count: usize,
521    /// Maximum number of replication attempts before giving up.
522    pub max_attempts: u32,
523    /// Number of ticks to wait before retrying a failed replication.
524    pub retry_interval_ticks: u64,
525}
526
527impl Default for ReplicationConfig {
528    fn default() -> Self {
529        Self {
530            default_replica_count: 3,
531            max_attempts: 5,
532            retry_interval_ticks: 10,
533        }
534    }
535}
536
537/// State of a single replica of a block (legacy type).
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum ReplicationState {
540    /// Replica has been registered but replication has not started.
541    Pending,
542    /// Replication is currently in progress.
543    InProgress,
544    /// Replication completed successfully.
545    Completed,
546    /// Replication failed.
547    Failed,
548}
549
550/// Information about a single replica of a block on a specific peer (legacy type).
551#[derive(Debug, Clone)]
552pub struct ReplicaInfo {
553    /// The peer holding (or targeted to hold) this replica.
554    pub peer_id: String,
555    /// Current state of replication to this peer.
556    pub state: ReplicationState,
557    /// The tick at which replication completed, if applicable.
558    pub replicated_tick: Option<u64>,
559    /// Number of replication attempts made.
560    pub attempts: u32,
561}
562
563/// Tracks all replicas of a single block (legacy type).
564#[derive(Debug, Clone)]
565pub struct BlockReplicas {
566    /// CID of the block being replicated.
567    pub block_cid: String,
568    /// Desired number of replicas.
569    pub desired_replicas: usize,
570    /// Current replicas (one per target peer).
571    pub replicas: Vec<ReplicaInfo>,
572}
573
574/// Aggregate statistics for the replication manager (legacy type).
575#[derive(Debug, Clone)]
576pub struct ReplicationManagerStats {
577    /// Number of blocks currently tracked.
578    pub tracked_blocks: usize,
579    /// Total number of successful replications since creation.
580    pub total_replications: u64,
581    /// Total number of replication failures since creation.
582    pub total_failures: u64,
583    /// Number of blocks that are under-replicated.
584    pub under_replicated: usize,
585    /// Number of blocks that are fully replicated.
586    pub fully_replicated: usize,
587}
588
589// ── Tests ─────────────────────────────────────────────────────────────────────
590
591#[cfg(test)]
592mod tests {
593    use crate::replication_manager::{
594        BlockReplicas, ReplicaInfo, ReplicaNode, ReplicationConfig, ReplicationError,
595        ReplicationManagerStats, ReplicationState, RmReplicaLocation, RmReplicationPolicy,
596        RmReplicationStatus, StorageReplicationManager,
597    };
598
599    // ── helpers ───────────────────────────────────────────────────────
600
601    fn default_policy() -> RmReplicationPolicy {
602        RmReplicationPolicy::default()
603    }
604
605    fn make_manager() -> StorageReplicationManager {
606        StorageReplicationManager::new(default_policy())
607    }
608
609    fn healthy_node(id: &str, capacity: u64, used: u64) -> ReplicaNode {
610        ReplicaNode {
611            node_id: id.to_string(),
612            address: format!("192.168.0.1:{id}"),
613            last_seen: 1000,
614            healthy: true,
615            capacity_bytes: capacity,
616            used_bytes: used,
617        }
618    }
619
620    fn unhealthy_node(id: &str) -> ReplicaNode {
621        ReplicaNode {
622            node_id: id.to_string(),
623            address: format!("192.168.0.2:{id}"),
624            last_seen: 500,
625            healthy: false,
626            capacity_bytes: 1_000_000,
627            used_bytes: 100_000,
628        }
629    }
630
631    // ── ReplicaNode ───────────────────────────────────────────────────
632
633    #[test]
634    fn test_replica_node_available_bytes() {
635        let n = healthy_node("n1", 1_000, 300);
636        assert_eq!(n.available_bytes(), 700);
637    }
638
639    #[test]
640    fn test_replica_node_available_bytes_saturates() {
641        let n = ReplicaNode {
642            node_id: "n".into(),
643            address: "a".into(),
644            last_seen: 0,
645            healthy: true,
646            capacity_bytes: 100,
647            used_bytes: 200,
648        };
649        assert_eq!(n.available_bytes(), 0);
650    }
651
652    #[test]
653    fn test_replica_node_utilization() {
654        let n = healthy_node("n1", 1_000, 500);
655        assert!((n.utilization() - 0.5).abs() < f64::EPSILON);
656    }
657
658    #[test]
659    fn test_replica_node_utilization_zero_capacity() {
660        let n = ReplicaNode {
661            node_id: "n".into(),
662            address: "a".into(),
663            last_seen: 0,
664            healthy: true,
665            capacity_bytes: 0,
666            used_bytes: 0,
667        };
668        // capacity.max(1) == 1, used == 0 => 0.0
669        assert!((n.utilization() - 0.0).abs() < f64::EPSILON);
670    }
671
672    // ── register_node / deregister_node ───────────────────────────────
673
674    #[test]
675    fn test_register_node_success() {
676        let mut mgr = make_manager();
677        let result = mgr.register_node(healthy_node("n1", 1_000_000, 0));
678        assert!(result.is_ok());
679        assert_eq!(mgr.node_count(), 1);
680    }
681
682    #[test]
683    fn test_register_node_duplicate_error() {
684        let mut mgr = make_manager();
685        mgr.register_node(healthy_node("n1", 1_000_000, 0))
686            .unwrap_or_default();
687        let err = mgr
688            .register_node(healthy_node("n1", 500_000, 0))
689            .unwrap_err();
690        assert_eq!(err, ReplicationError::NodeAlreadyRegistered("n1".into()));
691    }
692
693    #[test]
694    fn test_deregister_node_success() {
695        let mut mgr = make_manager();
696        mgr.register_node(healthy_node("n1", 1_000_000, 0))
697            .unwrap_or_default();
698        let result = mgr.deregister_node("n1");
699        assert!(result.is_ok());
700        assert_eq!(mgr.node_count(), 0);
701    }
702
703    #[test]
704    fn test_deregister_node_not_found() {
705        let mut mgr = make_manager();
706        let err = mgr.deregister_node("ghost").unwrap_err();
707        assert_eq!(err, ReplicationError::NodeNotFound("ghost".into()));
708    }
709
710    #[test]
711    fn test_deregister_node_removes_replicas() {
712        let mut mgr = make_manager();
713        mgr.register_node(healthy_node("n1", 1_000_000, 0))
714            .unwrap_or_default();
715        mgr.record_replica("cid1".into(), "n1", 42, 100)
716            .unwrap_or_default();
717        assert_eq!(mgr.cid_count(), 1);
718        mgr.deregister_node("n1").unwrap_or_default();
719        // CID entry should be removed because it has no remaining locations.
720        assert_eq!(mgr.cid_count(), 0);
721    }
722
723    // ── mark_healthy / mark_unhealthy ─────────────────────────────────
724
725    #[test]
726    fn test_mark_healthy_updates_node() {
727        let mut mgr = make_manager();
728        mgr.register_node(unhealthy_node("n1")).unwrap_or_default();
729        assert!(mgr.mark_healthy("n1", 9999));
730        let n = mgr.nodes.get("n1").expect("node must exist");
731        assert!(n.healthy);
732        assert_eq!(n.last_seen, 9999);
733    }
734
735    #[test]
736    fn test_mark_healthy_returns_false_unknown() {
737        let mut mgr = make_manager();
738        assert!(!mgr.mark_healthy("nope", 1));
739    }
740
741    #[test]
742    fn test_mark_unhealthy() {
743        let mut mgr = make_manager();
744        mgr.register_node(healthy_node("n1", 1_000_000, 0))
745            .unwrap_or_default();
746        assert!(mgr.mark_unhealthy("n1"));
747        let n = mgr.nodes.get("n1").expect("node must exist");
748        assert!(!n.healthy);
749    }
750
751    #[test]
752    fn test_mark_unhealthy_returns_false_unknown() {
753        let mut mgr = make_manager();
754        assert!(!mgr.mark_unhealthy("nope"));
755    }
756
757    // ── record_replica / verify_replica / remove_replica ─────────────
758
759    #[test]
760    fn test_record_replica_success() {
761        let mut mgr = make_manager();
762        mgr.register_node(healthy_node("n1", 1_000_000, 0))
763            .unwrap_or_default();
764        let r = mgr.record_replica("cid1".into(), "n1", 0xdeadbeef, 200);
765        assert!(r.is_ok());
766        assert_eq!(mgr.cid_count(), 1);
767    }
768
769    #[test]
770    fn test_record_replica_node_not_found() {
771        let mut mgr = make_manager();
772        let err = mgr
773            .record_replica("cid1".into(), "ghost", 0, 0)
774            .unwrap_err();
775        assert_eq!(err, ReplicationError::NodeNotFound("ghost".into()));
776    }
777
778    #[test]
779    fn test_verify_replica_found() {
780        let mut mgr = make_manager();
781        mgr.register_node(healthy_node("n1", 1_000_000, 0))
782            .unwrap_or_default();
783        mgr.record_replica("cid1".into(), "n1", 0, 100)
784            .unwrap_or_default();
785        assert!(mgr.verify_replica("cid1", "n1", 300));
786        let loc = &mgr.replicas["cid1"][0];
787        assert_eq!(loc.verified_at, Some(300));
788    }
789
790    #[test]
791    fn test_verify_replica_not_found() {
792        let mut mgr = make_manager();
793        assert!(!mgr.verify_replica("cid_missing", "n1", 0));
794    }
795
796    #[test]
797    fn test_remove_replica_success() {
798        let mut mgr = make_manager();
799        mgr.register_node(healthy_node("n1", 1_000_000, 0))
800            .unwrap_or_default();
801        mgr.record_replica("cid1".into(), "n1", 0, 100)
802            .unwrap_or_default();
803        assert!(mgr.remove_replica("cid1", "n1"));
804        assert_eq!(mgr.cid_count(), 0);
805    }
806
807    #[test]
808    fn test_remove_replica_not_found() {
809        let mut mgr = make_manager();
810        assert!(!mgr.remove_replica("cid_missing", "n1"));
811    }
812
813    // ── replication_status ────────────────────────────────────────────
814
815    #[test]
816    fn test_status_missing_no_cid() {
817        let mgr = make_manager();
818        assert_eq!(mgr.replication_status("cid1"), RmReplicationStatus::Missing);
819    }
820
821    #[test]
822    fn test_status_under_replicated_no_healthy_nodes() {
823        let mut mgr = make_manager();
824        mgr.register_node(unhealthy_node("n1")).unwrap_or_default();
825        mgr.record_replica("cid1".into(), "n1", 0, 100)
826            .unwrap_or_default();
827        assert_eq!(
828            mgr.replication_status("cid1"),
829            RmReplicationStatus::UnderReplicated {
830                current: 0,
831                target: 3
832            }
833        );
834    }
835
836    #[test]
837    fn test_status_healthy() {
838        let mut mgr = make_manager();
839        for i in 0..2u8 {
840            let id = format!("n{i}");
841            mgr.register_node(healthy_node(&id, 1_000_000, 0))
842                .unwrap_or_default();
843            mgr.record_replica("cid1".into(), &id, 0, 100)
844                .unwrap_or_default();
845        }
846        assert_eq!(
847            mgr.replication_status("cid1"),
848            RmReplicationStatus::Healthy { replicas: 2 }
849        );
850    }
851
852    #[test]
853    fn test_status_over_replicated() {
854        let mut mgr = StorageReplicationManager::new(RmReplicationPolicy {
855            replication_factor: 2,
856            min_healthy_replicas: 1,
857            ..Default::default()
858        });
859        for i in 0..3u8 {
860            let id = format!("n{i}");
861            mgr.register_node(healthy_node(&id, 1_000_000, 0))
862                .unwrap_or_default();
863            mgr.record_replica("cid1".into(), &id, 0, 100)
864                .unwrap_or_default();
865        }
866        assert_eq!(
867            mgr.replication_status("cid1"),
868            RmReplicationStatus::OverReplicated {
869                current: 3,
870                target: 2
871            }
872        );
873    }
874
875    // ── under_replicated_cids / over_replicated_cids ──────────────────
876
877    #[test]
878    fn test_under_replicated_cids_sorted() {
879        let mut mgr = make_manager();
880        // Register a single unhealthy node and record replicas on it — both CIDs
881        // will be under-replicated.
882        mgr.register_node(unhealthy_node("n1")).unwrap_or_default();
883        mgr.record_replica("cid_b".into(), "n1", 0, 1)
884            .unwrap_or_default();
885        mgr.record_replica("cid_a".into(), "n1", 0, 1)
886            .unwrap_or_default();
887        let under = mgr.under_replicated_cids();
888        assert_eq!(under, vec!["cid_a", "cid_b"]);
889    }
890
891    #[test]
892    fn test_over_replicated_cids_sorted() {
893        let mut mgr = StorageReplicationManager::new(RmReplicationPolicy {
894            replication_factor: 1,
895            min_healthy_replicas: 1,
896            ..Default::default()
897        });
898        for suffix in ["z", "a", "m"] {
899            let cid = format!("cid_{suffix}");
900            for i in 0..2u8 {
901                let node_id = format!("n{suffix}{i}");
902                mgr.register_node(healthy_node(&node_id, 1_000_000, 0))
903                    .unwrap_or_default();
904                mgr.record_replica(cid.clone(), &node_id, 0, 1)
905                    .unwrap_or_default();
906            }
907        }
908        let over = mgr.over_replicated_cids();
909        assert_eq!(over, vec!["cid_a", "cid_m", "cid_z"]);
910    }
911
912    // ── select_nodes_for_replication ──────────────────────────────────
913
914    #[test]
915    fn test_select_excludes_existing_replica_node() {
916        let mut mgr = make_manager();
917        mgr.register_node(healthy_node("n1", 1_000_000, 0))
918            .unwrap_or_default();
919        mgr.register_node(healthy_node("n2", 1_000_000, 0))
920            .unwrap_or_default();
921        mgr.record_replica("cid1".into(), "n1", 0, 1)
922            .unwrap_or_default();
923        let selected = mgr.select_nodes_for_replication("cid1", 10);
924        assert_eq!(selected.len(), 1);
925        assert_eq!(selected[0].node_id, "n2");
926    }
927
928    #[test]
929    fn test_select_excludes_unhealthy_nodes() {
930        let mut mgr = make_manager();
931        mgr.register_node(healthy_node("n1", 1_000_000, 0))
932            .unwrap_or_default();
933        mgr.register_node(unhealthy_node("n2")).unwrap_or_default();
934        let selected = mgr.select_nodes_for_replication("cid1", 10);
935        assert_eq!(selected.len(), 1);
936        assert_eq!(selected[0].node_id, "n1");
937    }
938
939    #[test]
940    fn test_select_excludes_over_utilized_nodes() {
941        let mut mgr = make_manager();
942        // 90% utilization — exceeds default max of 0.85
943        mgr.register_node(healthy_node("n1", 1_000_000, 900_000))
944            .unwrap_or_default();
945        mgr.register_node(healthy_node("n2", 1_000_000, 100_000))
946            .unwrap_or_default();
947        let selected = mgr.select_nodes_for_replication("cid1", 10);
948        assert_eq!(selected.len(), 1);
949        assert_eq!(selected[0].node_id, "n2");
950    }
951
952    #[test]
953    fn test_select_sorted_by_available_bytes_desc() {
954        let mut mgr = make_manager();
955        mgr.register_node(healthy_node("n1", 1_000_000, 800_000))
956            .unwrap_or_default(); // 200k free
957        mgr.register_node(healthy_node("n2", 1_000_000, 100_000))
958            .unwrap_or_default(); // 900k free
959        mgr.register_node(healthy_node("n3", 1_000_000, 500_000))
960            .unwrap_or_default(); // 500k free
961        let selected = mgr.select_nodes_for_replication("cid1", 3);
962        assert_eq!(selected.len(), 3);
963        assert_eq!(selected[0].node_id, "n2");
964        assert_eq!(selected[1].node_id, "n3");
965        assert_eq!(selected[2].node_id, "n1");
966    }
967
968    #[test]
969    fn test_select_count_limits_result() {
970        let mut mgr = make_manager();
971        for i in 0..5u8 {
972            mgr.register_node(healthy_node(&format!("n{i}"), 1_000_000, 0))
973                .unwrap_or_default();
974        }
975        let selected = mgr.select_nodes_for_replication("cid1", 2);
976        assert_eq!(selected.len(), 2);
977    }
978
979    #[test]
980    fn test_select_returns_empty_when_no_eligible() {
981        let mut mgr = make_manager();
982        mgr.register_node(unhealthy_node("n1")).unwrap_or_default();
983        let selected = mgr.select_nodes_for_replication("cid1", 5);
984        assert!(selected.is_empty());
985    }
986
987    // ── evict_stale_nodes ─────────────────────────────────────────────
988
989    #[test]
990    fn test_evict_stale_nodes_removes_old() {
991        let mut mgr = make_manager();
992        mgr.register_node(ReplicaNode {
993            node_id: "old".into(),
994            address: "a".into(),
995            last_seen: 100,
996            healthy: true,
997            capacity_bytes: 1_000_000,
998            used_bytes: 0,
999        })
1000        .unwrap_or_default();
1001        mgr.register_node(ReplicaNode {
1002            node_id: "fresh".into(),
1003            address: "b".into(),
1004            last_seen: 9_000,
1005            healthy: true,
1006            capacity_bytes: 1_000_000,
1007            used_bytes: 0,
1008        })
1009        .unwrap_or_default();
1010        // now=10_000, max_age=1_000 → "old" (last_seen=100, age=9900) evicted,
1011        // "fresh" (age=1000, NOT >1000) kept.
1012        let evicted = mgr.evict_stale_nodes(1_000, 10_000);
1013        assert_eq!(evicted, 1);
1014        assert_eq!(mgr.node_count(), 1);
1015        assert!(mgr.nodes.contains_key("fresh"));
1016    }
1017
1018    #[test]
1019    fn test_evict_stale_cleans_replicas() {
1020        let mut mgr = make_manager();
1021        mgr.register_node(ReplicaNode {
1022            node_id: "old".into(),
1023            address: "a".into(),
1024            last_seen: 0,
1025            healthy: true,
1026            capacity_bytes: 1_000_000,
1027            used_bytes: 0,
1028        })
1029        .unwrap_or_default();
1030        mgr.record_replica("cid1".into(), "old", 0, 1)
1031            .unwrap_or_default();
1032        mgr.evict_stale_nodes(500, 10_000);
1033        assert_eq!(mgr.cid_count(), 0);
1034    }
1035
1036    #[test]
1037    fn test_evict_stale_none_old() {
1038        let mut mgr = make_manager();
1039        mgr.register_node(healthy_node("n1", 1_000_000, 0))
1040            .unwrap_or_default();
1041        let evicted = mgr.evict_stale_nodes(10_000, 2_000);
1042        assert_eq!(evicted, 0);
1043        assert_eq!(mgr.node_count(), 1);
1044    }
1045
1046    // ── cid_count / node_count / healthy_node_count ───────────────────
1047
1048    #[test]
1049    fn test_node_count() {
1050        let mut mgr = make_manager();
1051        assert_eq!(mgr.node_count(), 0);
1052        mgr.register_node(healthy_node("n1", 1_000_000, 0))
1053            .unwrap_or_default();
1054        mgr.register_node(healthy_node("n2", 1_000_000, 0))
1055            .unwrap_or_default();
1056        assert_eq!(mgr.node_count(), 2);
1057    }
1058
1059    #[test]
1060    fn test_healthy_node_count() {
1061        let mut mgr = make_manager();
1062        mgr.register_node(healthy_node("n1", 1_000_000, 0))
1063            .unwrap_or_default();
1064        mgr.register_node(unhealthy_node("n2")).unwrap_or_default();
1065        assert_eq!(mgr.healthy_node_count(), 1);
1066    }
1067
1068    #[test]
1069    fn test_cid_count() {
1070        let mut mgr = make_manager();
1071        mgr.register_node(healthy_node("n1", 1_000_000, 0))
1072            .unwrap_or_default();
1073        mgr.record_replica("cid1".into(), "n1", 0, 1)
1074            .unwrap_or_default();
1075        mgr.record_replica("cid2".into(), "n1", 0, 1)
1076            .unwrap_or_default();
1077        assert_eq!(mgr.cid_count(), 2);
1078    }
1079
1080    // ── stats ─────────────────────────────────────────────────────────
1081
1082    #[test]
1083    fn test_stats_empty() {
1084        let mgr = make_manager();
1085        let s = mgr.stats();
1086        assert_eq!(s.total_cids, 0);
1087        assert_eq!(s.total_nodes, 0);
1088        assert_eq!(s.healthy_nodes, 0);
1089        assert!((s.avg_replication_factor - 0.0).abs() < f64::EPSILON);
1090    }
1091
1092    #[test]
1093    fn test_stats_all_healthy() {
1094        let mut mgr = make_manager();
1095        for i in 0..3u8 {
1096            let id = format!("n{i}");
1097            mgr.register_node(healthy_node(&id, 1_000_000, 0))
1098                .unwrap_or_default();
1099            mgr.record_replica("cid1".into(), &id, 0, 1)
1100                .unwrap_or_default();
1101        }
1102        let s = mgr.stats();
1103        assert_eq!(s.total_cids, 1);
1104        assert_eq!(s.healthy_cids, 1);
1105        assert_eq!(s.under_replicated_cids, 0);
1106        assert_eq!(s.over_replicated_cids, 0);
1107        assert_eq!(s.missing_cids, 0);
1108        assert!((s.avg_replication_factor - 3.0).abs() < f64::EPSILON);
1109    }
1110
1111    #[test]
1112    fn test_stats_under_and_over() {
1113        // policy: factor=2, min_healthy=2
1114        let policy = RmReplicationPolicy {
1115            replication_factor: 2,
1116            min_healthy_replicas: 2,
1117            ..Default::default()
1118        };
1119        let mut mgr = StorageReplicationManager::new(policy);
1120        // "cid_over": 3 healthy replicas on factor=2 → OverReplicated
1121        for i in 0..3u8 {
1122            let id = format!("over_n{i}");
1123            mgr.register_node(healthy_node(&id, 1_000_000, 0))
1124                .unwrap_or_default();
1125            mgr.record_replica("cid_over".into(), &id, 0, 1)
1126                .unwrap_or_default();
1127        }
1128        // "cid_under": 1 healthy replica → UnderReplicated
1129        mgr.register_node(healthy_node("under_n0", 1_000_000, 0))
1130            .unwrap_or_default();
1131        mgr.record_replica("cid_under".into(), "under_n0", 0, 1)
1132            .unwrap_or_default();
1133
1134        let s = mgr.stats();
1135        assert_eq!(s.over_replicated_cids, 1);
1136        assert_eq!(s.under_replicated_cids, 1);
1137        assert_eq!(s.healthy_cids, 0);
1138    }
1139
1140    // ── error display ─────────────────────────────────────────────────
1141
1142    #[test]
1143    fn test_error_display_node_already_registered() {
1144        let e = ReplicationError::NodeAlreadyRegistered("n1".into());
1145        assert!(e.to_string().contains("n1"));
1146    }
1147
1148    #[test]
1149    fn test_error_display_node_not_found() {
1150        let e = ReplicationError::NodeNotFound("n2".into());
1151        assert!(e.to_string().contains("n2"));
1152    }
1153
1154    #[test]
1155    fn test_error_display_cid_not_found() {
1156        let e = ReplicationError::CidNotFound("cid_x".into());
1157        assert!(e.to_string().contains("cid_x"));
1158    }
1159
1160    // ── policy defaults ───────────────────────────────────────────────
1161
1162    #[test]
1163    fn test_policy_defaults() {
1164        let p = RmReplicationPolicy::default();
1165        assert_eq!(p.replication_factor, 3);
1166        assert_eq!(p.min_healthy_replicas, 2);
1167        assert!(!p.prefer_local);
1168        assert!((p.max_node_utilization - 0.85).abs() < f64::EPSILON);
1169    }
1170
1171    // ── legacy type smoke-tests (kept for backwards compat) ───────────
1172
1173    #[test]
1174    fn test_legacy_replication_config_default() {
1175        let cfg = ReplicationConfig::default();
1176        assert_eq!(cfg.default_replica_count, 3);
1177        assert_eq!(cfg.max_attempts, 5);
1178        assert_eq!(cfg.retry_interval_ticks, 10);
1179    }
1180
1181    #[test]
1182    fn test_legacy_block_replicas_fields() {
1183        let br = BlockReplicas {
1184            block_cid: "cid".into(),
1185            desired_replicas: 2,
1186            replicas: vec![ReplicaInfo {
1187                peer_id: "p".into(),
1188                state: ReplicationState::Pending,
1189                replicated_tick: None,
1190                attempts: 0,
1191            }],
1192        };
1193        assert_eq!(br.desired_replicas, 2);
1194        assert_eq!(br.replicas[0].state, ReplicationState::Pending);
1195    }
1196
1197    #[test]
1198    fn test_legacy_replication_manager_stats_fields() {
1199        let s = ReplicationManagerStats {
1200            tracked_blocks: 1,
1201            total_replications: 2,
1202            total_failures: 3,
1203            under_replicated: 4,
1204            fully_replicated: 5,
1205        };
1206        assert_eq!(s.tracked_blocks, 1);
1207        assert_eq!(s.fully_replicated, 5);
1208    }
1209
1210    // ── deregister keeps other CIDs intact ───────────────────────────
1211
1212    #[test]
1213    fn test_deregister_leaves_other_cid_replicas() {
1214        let mut mgr = make_manager();
1215        mgr.register_node(healthy_node("n1", 1_000_000, 0))
1216            .unwrap_or_default();
1217        mgr.register_node(healthy_node("n2", 1_000_000, 0))
1218            .unwrap_or_default();
1219        mgr.record_replica("cid1".into(), "n1", 0, 1)
1220            .unwrap_or_default();
1221        mgr.record_replica("cid1".into(), "n2", 0, 1)
1222            .unwrap_or_default();
1223        mgr.record_replica("cid2".into(), "n1", 0, 1)
1224            .unwrap_or_default();
1225        mgr.deregister_node("n1").unwrap_or_default();
1226        // cid1 still has n2; cid2 had only n1 → removed
1227        assert_eq!(mgr.cid_count(), 1);
1228        let locs = &mgr.replicas["cid1"];
1229        assert_eq!(locs.len(), 1);
1230        assert_eq!(locs[0].node_id, "n2");
1231    }
1232
1233    // ── multiple replicas same CID ────────────────────────────────────
1234
1235    #[test]
1236    fn test_multiple_replicas_same_cid() {
1237        let mut mgr = make_manager();
1238        for i in 0..5u8 {
1239            let id = format!("n{i}");
1240            mgr.register_node(healthy_node(&id, 1_000_000, 0))
1241                .unwrap_or_default();
1242            mgr.record_replica("cid1".into(), &id, i as u64, 1)
1243                .unwrap_or_default();
1244        }
1245        assert_eq!(mgr.replicas["cid1"].len(), 5);
1246    }
1247
1248    // ── verify_replica updates correct entry ──────────────────────────
1249
1250    #[test]
1251    fn test_verify_replica_updates_correct_location() {
1252        let mut mgr = make_manager();
1253        mgr.register_node(healthy_node("n1", 1_000_000, 0))
1254            .unwrap_or_default();
1255        mgr.register_node(healthy_node("n2", 1_000_000, 0))
1256            .unwrap_or_default();
1257        mgr.record_replica("cid1".into(), "n1", 0, 1)
1258            .unwrap_or_default();
1259        mgr.record_replica("cid1".into(), "n2", 0, 1)
1260            .unwrap_or_default();
1261        mgr.verify_replica("cid1", "n2", 999);
1262        let locs = &mgr.replicas["cid1"];
1263        let n1_loc = locs.iter().find(|l| l.node_id == "n1").expect("n1 loc");
1264        let n2_loc = locs.iter().find(|l| l.node_id == "n2").expect("n2 loc");
1265        assert_eq!(n1_loc.verified_at, None);
1266        assert_eq!(n2_loc.verified_at, Some(999));
1267    }
1268
1269    // ── RmReplicaLocation fields ──────────────────────────────────────
1270
1271    #[test]
1272    fn test_replica_location_fields() {
1273        let loc = RmReplicaLocation {
1274            node_id: "n1".into(),
1275            cid: "cid1".into(),
1276            stored_at: 100,
1277            verified_at: Some(200),
1278            checksum: 0xabcd,
1279        };
1280        assert_eq!(loc.checksum, 0xabcd);
1281        assert_eq!(loc.verified_at, Some(200));
1282    }
1283}