Skip to main content

ipfrs_storage/
replication_tracker.rs

1//! Storage Replication Tracker
2//!
3//! Tracks replication factor of stored blocks across peers, detects
4//! under-replicated blocks, and generates replication tasks to restore
5//! desired redundancy.
6
7use std::collections::HashMap;
8
9// ── ReplicaLocation ──────────────────────────────────────────────────────────
10
11/// Records where a block replica lives and when it was last confirmed.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ReplicaLocation {
14    /// Peer that holds this replica.
15    pub peer_id: String,
16    /// Logical tick at which the replica was last confirmed present.
17    pub confirmed_at_tick: u64,
18    /// Whether this peer is the designated primary for the block.
19    pub is_primary: bool,
20}
21
22// ── ReplicationStatus ────────────────────────────────────────────────────────
23
24/// Health status of a block's replication level.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum ReplicationStatus {
27    /// Actual replicas equal desired replicas.
28    Healthy,
29    /// Actual replicas are fewer than desired but at least one exists.
30    UnderReplicated,
31    /// Actual replicas exceed desired replicas.
32    OverReplicated,
33    /// No replicas exist at all.
34    Critical,
35}
36
37// ── BlockReplicationEntry ────────────────────────────────────────────────────
38
39/// Replication record for a single content block.
40#[derive(Clone, Debug)]
41pub struct BlockReplicationEntry {
42    /// Stable numeric identifier for this block.
43    pub block_id: u64,
44    /// Content identifier (CID) of the block.
45    pub cid: String,
46    /// How many replicas this block should have.
47    pub desired_replicas: usize,
48    /// Currently known replica locations.
49    pub replicas: Vec<ReplicaLocation>,
50}
51
52impl BlockReplicationEntry {
53    /// Returns the current number of replicas.
54    pub fn actual_replicas(&self) -> usize {
55        self.replicas.len()
56    }
57
58    /// Derives the health status from actual vs. desired replica counts.
59    pub fn status(&self) -> ReplicationStatus {
60        let actual = self.replicas.len();
61        if actual == 0 {
62            ReplicationStatus::Critical
63        } else if actual < self.desired_replicas {
64            ReplicationStatus::UnderReplicated
65        } else if actual > self.desired_replicas {
66            ReplicationStatus::OverReplicated
67        } else {
68            ReplicationStatus::Healthy
69        }
70    }
71
72    /// Number of additional replicas needed to reach the desired count.
73    ///
74    /// Returns `0` when already at or above the desired level.
75    pub fn deficit(&self) -> usize {
76        self.desired_replicas.saturating_sub(self.replicas.len())
77    }
78
79    /// Number of replicas above the desired count.
80    ///
81    /// Returns `0` when at or below the desired level.
82    pub fn surplus(&self) -> usize {
83        self.replicas.len().saturating_sub(self.desired_replicas)
84    }
85}
86
87// ── ReplicationTask ──────────────────────────────────────────────────────────
88
89/// Work item requesting that additional copies of a block be created.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct ReplicationTask {
92    /// Block to be replicated.
93    pub block_id: u64,
94    /// Content identifier of the block.
95    pub cid: String,
96    /// How many new copies must be produced.
97    pub needed_copies: usize,
98    /// Scheduling priority: Critical = 100, UnderReplicated = 50.
99    pub priority: u32,
100}
101
102// ── ReplicationStats ─────────────────────────────────────────────────────────
103
104/// Aggregate replication statistics across all tracked blocks.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct ReplicationStats {
107    /// Total number of tracked blocks.
108    pub total_blocks: usize,
109    /// Blocks with status [`ReplicationStatus::Healthy`].
110    pub healthy: usize,
111    /// Blocks with status [`ReplicationStatus::UnderReplicated`].
112    pub under_replicated: usize,
113    /// Blocks with status [`ReplicationStatus::OverReplicated`].
114    pub over_replicated: usize,
115    /// Blocks with status [`ReplicationStatus::Critical`].
116    pub critical: usize,
117    /// Total replica count across all blocks.
118    pub total_replicas: usize,
119}
120
121// ── StorageReplicationTracker ─────────────────────────────────────────────────
122
123/// Tracks replication of content blocks across a peer network.
124pub struct StorageReplicationTracker {
125    /// All tracked block entries keyed by block_id.
126    pub entries: HashMap<u64, BlockReplicationEntry>,
127    /// Counter used to issue monotonically increasing block IDs.
128    pub next_block_id: u64,
129    /// Replication factor applied when `register_block` receives `None`.
130    pub default_desired_replicas: usize,
131}
132
133impl StorageReplicationTracker {
134    /// Creates a new tracker with the given default replication factor.
135    pub fn new(default_desired_replicas: usize) -> Self {
136        Self {
137            entries: HashMap::new(),
138            next_block_id: 0,
139            default_desired_replicas,
140        }
141    }
142
143    /// Registers a new block and returns its assigned `block_id`.
144    ///
145    /// If `desired_replicas` is `None`, the tracker's default is used.
146    pub fn register_block(&mut self, cid: String, desired_replicas: Option<usize>) -> u64 {
147        let block_id = self.next_block_id;
148        self.next_block_id += 1;
149        let desired = desired_replicas.unwrap_or(self.default_desired_replicas);
150        self.entries.insert(
151            block_id,
152            BlockReplicationEntry {
153                block_id,
154                cid,
155                desired_replicas: desired,
156                replicas: Vec::new(),
157            },
158        );
159        block_id
160    }
161
162    /// Records that `peer_id` holds a replica of `block_id` at `current_tick`.
163    ///
164    /// - Returns `false` if `block_id` is not tracked.
165    /// - If the peer already has a replica, its tick is updated; otherwise a
166    ///   new [`ReplicaLocation`] is appended.
167    /// - Returns `true` on success.
168    pub fn add_replica(
169        &mut self,
170        block_id: u64,
171        peer_id: String,
172        current_tick: u64,
173        is_primary: bool,
174    ) -> bool {
175        let Some(entry) = self.entries.get_mut(&block_id) else {
176            return false;
177        };
178
179        if let Some(loc) = entry.replicas.iter_mut().find(|l| l.peer_id == peer_id) {
180            loc.confirmed_at_tick = current_tick;
181            loc.is_primary = is_primary;
182        } else {
183            entry.replicas.push(ReplicaLocation {
184                peer_id,
185                confirmed_at_tick: current_tick,
186                is_primary,
187            });
188        }
189        true
190    }
191
192    /// Removes the replica held by `peer_id` from `block_id`.
193    ///
194    /// Returns `false` when the block or peer is not found.
195    pub fn remove_replica(&mut self, block_id: u64, peer_id: &str) -> bool {
196        let Some(entry) = self.entries.get_mut(&block_id) else {
197            return false;
198        };
199        let before = entry.replicas.len();
200        entry.replicas.retain(|l| l.peer_id != peer_id);
201        entry.replicas.len() < before
202    }
203
204    /// Returns all blocks that are [`ReplicationStatus::UnderReplicated`] or
205    /// [`ReplicationStatus::Critical`], sorted by `block_id` ascending.
206    pub fn under_replicated_blocks(&self) -> Vec<&BlockReplicationEntry> {
207        let mut blocks: Vec<&BlockReplicationEntry> = self
208            .entries
209            .values()
210            .filter(|e| {
211                matches!(
212                    e.status(),
213                    ReplicationStatus::UnderReplicated | ReplicationStatus::Critical
214                )
215            })
216            .collect();
217        blocks.sort_by_key(|e| e.block_id);
218        blocks
219    }
220
221    /// Generates replication tasks for every block with a positive deficit.
222    ///
223    /// Tasks are ordered by priority descending then `block_id` ascending.
224    pub fn generate_tasks(&self) -> Vec<ReplicationTask> {
225        let mut tasks: Vec<ReplicationTask> = self
226            .entries
227            .values()
228            .filter(|e| e.deficit() > 0)
229            .map(|e| {
230                let priority = match e.status() {
231                    ReplicationStatus::Critical => 100,
232                    _ => 50,
233                };
234                ReplicationTask {
235                    block_id: e.block_id,
236                    cid: e.cid.clone(),
237                    needed_copies: e.deficit(),
238                    priority,
239                }
240            })
241            .collect();
242
243        tasks.sort_by(|a, b| {
244            b.priority
245                .cmp(&a.priority)
246                .then_with(|| a.block_id.cmp(&b.block_id))
247        });
248        tasks
249    }
250
251    /// Returns a reference to the entry for `block_id`, if tracked.
252    pub fn get_entry(&self, block_id: u64) -> Option<&BlockReplicationEntry> {
253        self.entries.get(&block_id)
254    }
255
256    /// Computes aggregate statistics across all tracked blocks.
257    pub fn stats(&self) -> ReplicationStats {
258        let mut stats = ReplicationStats {
259            total_blocks: self.entries.len(),
260            healthy: 0,
261            under_replicated: 0,
262            over_replicated: 0,
263            critical: 0,
264            total_replicas: 0,
265        };
266        for entry in self.entries.values() {
267            stats.total_replicas += entry.replicas.len();
268            match entry.status() {
269                ReplicationStatus::Healthy => stats.healthy += 1,
270                ReplicationStatus::UnderReplicated => stats.under_replicated += 1,
271                ReplicationStatus::OverReplicated => stats.over_replicated += 1,
272                ReplicationStatus::Critical => stats.critical += 1,
273            }
274        }
275        stats
276    }
277}
278
279// ── Tests ─────────────────────────────────────────────────────────────────────
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn tracker() -> StorageReplicationTracker {
286        StorageReplicationTracker::new(3)
287    }
288
289    // ── register_block ────────────────────────────────────────────────────────
290
291    #[test]
292    fn test_register_block_creates_entry() {
293        let mut t = tracker();
294        let id = t.register_block("cid-a".into(), None);
295        assert!(t.entries.contains_key(&id));
296    }
297
298    #[test]
299    fn test_register_block_returns_incrementing_ids() {
300        let mut t = tracker();
301        let id0 = t.register_block("cid-0".into(), None);
302        let id1 = t.register_block("cid-1".into(), None);
303        assert_eq!(id0, 0);
304        assert_eq!(id1, 1);
305    }
306
307    #[test]
308    fn test_register_block_uses_default_desired_replicas_when_none() {
309        let mut t = StorageReplicationTracker::new(5);
310        let id = t.register_block("cid-x".into(), None);
311        assert_eq!(t.entries[&id].desired_replicas, 5);
312    }
313
314    #[test]
315    fn test_register_block_uses_provided_desired_replicas() {
316        let mut t = tracker();
317        let id = t.register_block("cid-x".into(), Some(7));
318        assert_eq!(t.entries[&id].desired_replicas, 7);
319    }
320
321    #[test]
322    fn test_register_block_starts_with_no_replicas() {
323        let mut t = tracker();
324        let id = t.register_block("cid-x".into(), None);
325        assert!(t.entries[&id].replicas.is_empty());
326    }
327
328    #[test]
329    fn test_register_block_stores_cid() {
330        let mut t = tracker();
331        let id = t.register_block("my-cid".into(), None);
332        assert_eq!(t.entries[&id].cid, "my-cid");
333    }
334
335    // ── add_replica ───────────────────────────────────────────────────────────
336
337    #[test]
338    fn test_add_replica_appends_new_location() {
339        let mut t = tracker();
340        let id = t.register_block("c".into(), None);
341        assert!(t.add_replica(id, "peer-1".into(), 10, false));
342        assert_eq!(t.entries[&id].replicas.len(), 1);
343        assert_eq!(t.entries[&id].replicas[0].peer_id, "peer-1");
344    }
345
346    #[test]
347    fn test_add_replica_returns_false_for_unknown_block_id() {
348        let mut t = tracker();
349        assert!(!t.add_replica(999, "peer-1".into(), 1, false));
350    }
351
352    #[test]
353    fn test_add_replica_updates_existing_peer_tick() {
354        let mut t = tracker();
355        let id = t.register_block("c".into(), None);
356        t.add_replica(id, "peer-1".into(), 10, false);
357        t.add_replica(id, "peer-1".into(), 99, true);
358        // still only one entry
359        assert_eq!(t.entries[&id].replicas.len(), 1);
360        assert_eq!(t.entries[&id].replicas[0].confirmed_at_tick, 99);
361        assert!(t.entries[&id].replicas[0].is_primary);
362    }
363
364    #[test]
365    fn test_add_replica_multiple_peers() {
366        let mut t = tracker();
367        let id = t.register_block("c".into(), None);
368        t.add_replica(id, "peer-1".into(), 1, true);
369        t.add_replica(id, "peer-2".into(), 2, false);
370        t.add_replica(id, "peer-3".into(), 3, false);
371        assert_eq!(t.entries[&id].replicas.len(), 3);
372    }
373
374    // ── remove_replica ────────────────────────────────────────────────────────
375
376    #[test]
377    fn test_remove_replica_removes_entry() {
378        let mut t = tracker();
379        let id = t.register_block("c".into(), None);
380        t.add_replica(id, "peer-1".into(), 1, false);
381        assert!(t.remove_replica(id, "peer-1"));
382        assert!(t.entries[&id].replicas.is_empty());
383    }
384
385    #[test]
386    fn test_remove_replica_returns_false_for_unknown_block() {
387        let mut t = tracker();
388        assert!(!t.remove_replica(42, "peer-1"));
389    }
390
391    #[test]
392    fn test_remove_replica_returns_false_for_unknown_peer() {
393        let mut t = tracker();
394        let id = t.register_block("c".into(), None);
395        assert!(!t.remove_replica(id, "ghost-peer"));
396    }
397
398    // ── status ────────────────────────────────────────────────────────────────
399
400    #[test]
401    fn test_status_critical_when_zero_replicas() {
402        let mut t = tracker();
403        let id = t.register_block("c".into(), Some(3));
404        assert_eq!(t.entries[&id].status(), ReplicationStatus::Critical);
405    }
406
407    #[test]
408    fn test_status_under_replicated_when_below_desired() {
409        let mut t = tracker();
410        let id = t.register_block("c".into(), Some(3));
411        t.add_replica(id, "p1".into(), 1, false);
412        t.add_replica(id, "p2".into(), 2, false);
413        assert_eq!(t.entries[&id].status(), ReplicationStatus::UnderReplicated);
414    }
415
416    #[test]
417    fn test_status_healthy_when_equal_desired() {
418        let mut t = tracker();
419        let id = t.register_block("c".into(), Some(2));
420        t.add_replica(id, "p1".into(), 1, true);
421        t.add_replica(id, "p2".into(), 2, false);
422        assert_eq!(t.entries[&id].status(), ReplicationStatus::Healthy);
423    }
424
425    #[test]
426    fn test_status_over_replicated_when_above_desired() {
427        let mut t = tracker();
428        let id = t.register_block("c".into(), Some(1));
429        t.add_replica(id, "p1".into(), 1, true);
430        t.add_replica(id, "p2".into(), 2, false);
431        assert_eq!(t.entries[&id].status(), ReplicationStatus::OverReplicated);
432    }
433
434    // ── deficit / surplus ─────────────────────────────────────────────────────
435
436    #[test]
437    fn test_deficit_correct_value() {
438        let mut t = tracker();
439        let id = t.register_block("c".into(), Some(5));
440        t.add_replica(id, "p1".into(), 1, false);
441        t.add_replica(id, "p2".into(), 2, false);
442        assert_eq!(t.entries[&id].deficit(), 3);
443    }
444
445    #[test]
446    fn test_deficit_zero_when_healthy() {
447        let mut t = tracker();
448        let id = t.register_block("c".into(), Some(2));
449        t.add_replica(id, "p1".into(), 1, false);
450        t.add_replica(id, "p2".into(), 2, false);
451        assert_eq!(t.entries[&id].deficit(), 0);
452    }
453
454    #[test]
455    fn test_deficit_zero_when_over_replicated() {
456        let mut t = tracker();
457        let id = t.register_block("c".into(), Some(1));
458        t.add_replica(id, "p1".into(), 1, false);
459        t.add_replica(id, "p2".into(), 2, false);
460        assert_eq!(t.entries[&id].deficit(), 0);
461    }
462
463    #[test]
464    fn test_surplus_correct_value() {
465        let mut t = tracker();
466        let id = t.register_block("c".into(), Some(1));
467        t.add_replica(id, "p1".into(), 1, false);
468        t.add_replica(id, "p2".into(), 2, false);
469        t.add_replica(id, "p3".into(), 3, false);
470        assert_eq!(t.entries[&id].surplus(), 2);
471    }
472
473    #[test]
474    fn test_surplus_zero_when_healthy() {
475        let mut t = tracker();
476        let id = t.register_block("c".into(), Some(2));
477        t.add_replica(id, "p1".into(), 1, false);
478        t.add_replica(id, "p2".into(), 2, false);
479        assert_eq!(t.entries[&id].surplus(), 0);
480    }
481
482    // ── under_replicated_blocks ───────────────────────────────────────────────
483
484    #[test]
485    fn test_under_replicated_blocks_sorted_by_block_id_asc() {
486        let mut t = tracker();
487        // block ids 0,1,2,3
488        let id0 = t.register_block("c0".into(), Some(3)); // Critical (0 replicas)
489        let id1 = t.register_block("c1".into(), Some(3)); // UnderReplicated (1 replica)
490        let id2 = t.register_block("c2".into(), Some(2)); // Healthy (2 replicas)
491        let id3 = t.register_block("c3".into(), Some(3)); // UnderReplicated (2 replicas)
492
493        t.add_replica(id1, "p1".into(), 1, false);
494        t.add_replica(id2, "p1".into(), 1, false);
495        t.add_replica(id2, "p2".into(), 2, false);
496        t.add_replica(id3, "p1".into(), 1, false);
497        t.add_replica(id3, "p2".into(), 2, false);
498
499        let under = t.under_replicated_blocks();
500        assert_eq!(under.len(), 3); // id0 (Critical), id1 (Under), id3 (Under)
501        assert_eq!(under[0].block_id, id0);
502        assert_eq!(under[1].block_id, id1);
503        assert_eq!(under[2].block_id, id3);
504    }
505
506    #[test]
507    fn test_under_replicated_blocks_excludes_healthy_and_over_replicated() {
508        let mut t = tracker();
509        let id_healthy = t.register_block("h".into(), Some(1));
510        let id_over = t.register_block("o".into(), Some(1));
511        t.add_replica(id_healthy, "p1".into(), 1, false);
512        t.add_replica(id_over, "p1".into(), 1, false);
513        t.add_replica(id_over, "p2".into(), 2, false);
514        assert!(t.under_replicated_blocks().is_empty());
515    }
516
517    // ── generate_tasks ────────────────────────────────────────────────────────
518
519    #[test]
520    fn test_generate_tasks_priority_critical_before_under_replicated() {
521        let mut t = tracker();
522        let id_under = t.register_block("under".into(), Some(3));
523        let id_crit = t.register_block("crit".into(), Some(2));
524        // id_under: 1 replica, deficit 2, UnderReplicated → priority 50
525        t.add_replica(id_under, "p1".into(), 1, false);
526        // id_crit: 0 replicas, deficit 2, Critical → priority 100
527
528        let tasks = t.generate_tasks();
529        assert_eq!(tasks.len(), 2);
530        assert_eq!(tasks[0].block_id, id_crit);
531        assert_eq!(tasks[0].priority, 100);
532        assert_eq!(tasks[1].block_id, id_under);
533        assert_eq!(tasks[1].priority, 50);
534    }
535
536    #[test]
537    fn test_generate_tasks_needed_copies_equals_deficit() {
538        let mut t = tracker();
539        let id = t.register_block("c".into(), Some(5));
540        t.add_replica(id, "p1".into(), 1, false);
541        t.add_replica(id, "p2".into(), 2, false);
542        let tasks = t.generate_tasks();
543        assert_eq!(tasks.len(), 1);
544        assert_eq!(tasks[0].needed_copies, 3);
545    }
546
547    #[test]
548    fn test_generate_tasks_sorted_by_block_id_within_same_priority() {
549        let mut t = tracker();
550        // Both Critical (0 replicas, desired > 0)
551        let id0 = t.register_block("c0".into(), Some(2));
552        let id1 = t.register_block("c1".into(), Some(3));
553        let tasks = t.generate_tasks();
554        assert_eq!(tasks.len(), 2);
555        assert!(tasks[0].block_id <= tasks[1].block_id);
556        let _ = id0;
557        let _ = id1;
558    }
559
560    #[test]
561    fn test_generate_tasks_excludes_healthy_blocks() {
562        let mut t = tracker();
563        let id = t.register_block("c".into(), Some(1));
564        t.add_replica(id, "p1".into(), 1, false);
565        assert!(t.generate_tasks().is_empty());
566    }
567
568    #[test]
569    fn test_generate_tasks_excludes_over_replicated_blocks() {
570        let mut t = tracker();
571        let id = t.register_block("c".into(), Some(1));
572        t.add_replica(id, "p1".into(), 1, false);
573        t.add_replica(id, "p2".into(), 2, false);
574        assert!(t.generate_tasks().is_empty());
575    }
576
577    // ── stats ─────────────────────────────────────────────────────────────────
578
579    #[test]
580    fn test_stats_empty_tracker() {
581        let t = tracker();
582        let s = t.stats();
583        assert_eq!(s.total_blocks, 0);
584        assert_eq!(s.healthy, 0);
585        assert_eq!(s.critical, 0);
586        assert_eq!(s.total_replicas, 0);
587    }
588
589    #[test]
590    fn test_stats_counts_by_status() {
591        let mut t = tracker();
592        // Critical: 0 replicas, desired 2
593        let id_crit = t.register_block("crit".into(), Some(2));
594        // UnderReplicated: 1 of 2
595        let id_under = t.register_block("under".into(), Some(2));
596        t.add_replica(id_under, "p1".into(), 1, false);
597        // Healthy: 2 of 2
598        let id_healthy = t.register_block("healthy".into(), Some(2));
599        t.add_replica(id_healthy, "p1".into(), 1, false);
600        t.add_replica(id_healthy, "p2".into(), 2, false);
601        // OverReplicated: 3 of 2
602        let id_over = t.register_block("over".into(), Some(2));
603        t.add_replica(id_over, "p1".into(), 1, false);
604        t.add_replica(id_over, "p2".into(), 2, false);
605        t.add_replica(id_over, "p3".into(), 3, false);
606        let _ = id_crit;
607
608        let s = t.stats();
609        assert_eq!(s.total_blocks, 4);
610        assert_eq!(s.critical, 1);
611        assert_eq!(s.under_replicated, 1);
612        assert_eq!(s.healthy, 1);
613        assert_eq!(s.over_replicated, 1);
614        assert_eq!(s.total_replicas, 6); // 0+1+2+3
615    }
616
617    // ── get_entry ─────────────────────────────────────────────────────────────
618
619    #[test]
620    fn test_get_entry_returns_some_for_known_block() {
621        let mut t = tracker();
622        let id = t.register_block("c".into(), None);
623        assert!(t.get_entry(id).is_some());
624    }
625
626    #[test]
627    fn test_get_entry_returns_none_for_unknown_block() {
628        let t = tracker();
629        assert!(t.get_entry(9999).is_none());
630    }
631}