Skip to main content

ipfrs_storage/
dedup_tracker.rs

1//! Block deduplication tracker for IPFRS storage.
2//!
3//! Tracks which blocks are referenced by multiple DAG nodes, helping GC avoid
4//! collecting shared blocks and enabling storage savings reporting.
5
6use std::collections::HashMap;
7
8/// A reference entry tracking how many DAG nodes reference a given block.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RefEntry {
11    /// Content identifier for the block.
12    pub cid: String,
13    /// Number of DAG nodes referencing this block.
14    pub ref_count: u32,
15    /// Unix timestamp (seconds) when this block was first seen.
16    pub first_seen_secs: u64,
17    /// Unix timestamp (seconds) when this block was last seen.
18    pub last_seen_secs: u64,
19    /// Size of the block in bytes.
20    pub size_bytes: u64,
21}
22
23impl RefEntry {
24    /// Returns `true` if more than one DAG node references this block.
25    pub fn is_shared(&self) -> bool {
26        self.ref_count > 1
27    }
28
29    /// Returns how many bytes are saved by deduplication for this block.
30    ///
31    /// Calculated as `(ref_count - 1) * size_bytes`.
32    pub fn savings_bytes(&self) -> u64 {
33        (self.ref_count.saturating_sub(1) as u64).saturating_mul(self.size_bytes)
34    }
35}
36
37/// A point-in-time snapshot of deduplication statistics.
38#[derive(Debug, Clone, PartialEq)]
39pub struct DedupStats {
40    /// Total number of unique blocks tracked.
41    pub total_blocks: usize,
42    /// Number of blocks with `ref_count > 1`.
43    pub shared_blocks: usize,
44    /// Number of blocks with `ref_count == 1`.
45    pub unique_blocks: usize,
46    /// Sum of all reference counts across all blocks.
47    pub total_ref_count: u64,
48    /// Sum of `size_bytes` for all tracked blocks (physical storage used).
49    pub total_bytes: u64,
50    /// Total bytes saved by deduplication (sum of `savings_bytes()`).
51    pub saved_bytes: u64,
52}
53
54impl DedupStats {
55    /// Returns the fraction of blocks that are shared (`shared_blocks / total_blocks`).
56    ///
57    /// Returns `0.0` when there are no blocks.
58    pub fn dedup_ratio(&self) -> f64 {
59        self.shared_blocks as f64 / self.total_blocks.max(1) as f64
60    }
61}
62
63/// Tracks block deduplication across DAG nodes.
64///
65/// Maintains a mapping from CID to [`RefEntry`], updated as blocks are
66/// referenced or dereferenced.  Useful for GC safety checks and for
67/// reporting storage savings due to content-addressed deduplication.
68#[derive(Debug, Default)]
69pub struct BlockDeduplicationTracker {
70    entries: HashMap<String, RefEntry>,
71}
72
73impl BlockDeduplicationTracker {
74    /// Creates a new, empty tracker.
75    pub fn new() -> Self {
76        Self {
77            entries: HashMap::new(),
78        }
79    }
80
81    /// Records a reference to `cid`.
82    ///
83    /// - If the block is already tracked, its `ref_count` is incremented and
84    ///   `last_seen_secs` is updated.
85    /// - If the block is new, it is inserted with `ref_count = 1`.
86    pub fn add_ref(&mut self, cid: &str, size_bytes: u64, now_secs: u64) {
87        if let Some(entry) = self.entries.get_mut(cid) {
88            entry.ref_count = entry.ref_count.saturating_add(1);
89            entry.last_seen_secs = now_secs;
90        } else {
91            self.entries.insert(
92                cid.to_owned(),
93                RefEntry {
94                    cid: cid.to_owned(),
95                    ref_count: 1,
96                    first_seen_secs: now_secs,
97                    last_seen_secs: now_secs,
98                    size_bytes,
99                },
100            );
101        }
102    }
103
104    /// Removes one reference to `cid`.
105    ///
106    /// - If `ref_count > 1`, it is decremented.
107    /// - If `ref_count == 1`, the entry is removed entirely.
108    ///
109    /// Returns `true` if the CID was found, `false` otherwise.
110    pub fn remove_ref(&mut self, cid: &str) -> bool {
111        match self.entries.get_mut(cid) {
112            Some(entry) if entry.ref_count > 1 => {
113                entry.ref_count -= 1;
114                true
115            }
116            Some(_) => {
117                self.entries.remove(cid);
118                true
119            }
120            None => false,
121        }
122    }
123
124    /// Returns the current reference count for `cid`, or `0` if not tracked.
125    pub fn ref_count(&self, cid: &str) -> u32 {
126        self.entries.get(cid).map_or(0, |e| e.ref_count)
127    }
128
129    /// Returns `true` if it is safe for GC to delete `cid`.
130    ///
131    /// A block is safe to delete when it is not present in the tracker (no
132    /// live references) or its stored `ref_count` has somehow reached `0`.
133    pub fn is_safe_to_delete(&self, cid: &str) -> bool {
134        match self.entries.get(cid) {
135            None => true,
136            Some(entry) => entry.ref_count == 0,
137        }
138    }
139
140    /// Returns all entries with `ref_count > 1`, sorted by `savings_bytes` descending.
141    pub fn shared_blocks(&self) -> Vec<&RefEntry> {
142        let mut shared: Vec<&RefEntry> = self.entries.values().filter(|e| e.is_shared()).collect();
143        shared.sort_by_key(|b| std::cmp::Reverse(b.savings_bytes()));
144        shared
145    }
146
147    /// Returns all entries with `ref_count == 1`.
148    pub fn unique_blocks(&self) -> Vec<&RefEntry> {
149        self.entries.values().filter(|e| e.ref_count == 1).collect()
150    }
151
152    /// Returns a snapshot of current deduplication statistics.
153    pub fn stats(&self) -> DedupStats {
154        let total_blocks = self.entries.len();
155        let mut shared_blocks = 0usize;
156        let mut unique_blocks = 0usize;
157        let mut total_ref_count = 0u64;
158        let mut total_bytes = 0u64;
159        let mut saved_bytes = 0u64;
160
161        for entry in self.entries.values() {
162            if entry.is_shared() {
163                shared_blocks += 1;
164            } else {
165                unique_blocks += 1;
166            }
167            total_ref_count += entry.ref_count as u64;
168            total_bytes += entry.size_bytes;
169            saved_bytes += entry.savings_bytes();
170        }
171
172        DedupStats {
173            total_blocks,
174            shared_blocks,
175            unique_blocks,
176            total_ref_count,
177            total_bytes,
178            saved_bytes,
179        }
180    }
181
182    /// Returns the top `n` entries by `savings_bytes` descending.
183    pub fn top_savings(&self, n: usize) -> Vec<&RefEntry> {
184        let mut all: Vec<&RefEntry> = self.entries.values().collect();
185        all.sort_by_key(|b| std::cmp::Reverse(b.savings_bytes()));
186        all.truncate(n);
187        all
188    }
189
190    /// Removes all entries whose `ref_count` is `0`.
191    ///
192    /// Returns the number of entries removed.
193    pub fn prune_unreferenced(&mut self) -> usize {
194        let before = self.entries.len();
195        self.entries.retain(|_, e| e.ref_count > 0);
196        before - self.entries.len()
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    // ── helpers ──────────────────────────────────────────────────────────────
205
206    fn tracker_with_entries() -> BlockDeduplicationTracker {
207        let mut t = BlockDeduplicationTracker::new();
208        // "a": ref_count=3, size=100 → savings=200
209        t.add_ref("a", 100, 10);
210        t.add_ref("a", 100, 20);
211        t.add_ref("a", 100, 30);
212        // "b": ref_count=2, size=50  → savings=50
213        t.add_ref("b", 50, 10);
214        t.add_ref("b", 50, 20);
215        // "c": ref_count=1, size=200 → savings=0
216        t.add_ref("c", 200, 10);
217        t
218    }
219
220    // ── 1. new() empty ────────────────────────────────────────────────────────
221
222    #[test]
223    fn test_new_empty() {
224        let t = BlockDeduplicationTracker::new();
225        assert_eq!(t.entries.len(), 0);
226    }
227
228    // ── 2. add_ref: new block inserted with ref_count=1 ───────────────────────
229
230    #[test]
231    fn test_add_ref_new_block() {
232        let mut t = BlockDeduplicationTracker::new();
233        t.add_ref("cid1", 512, 100);
234        let entry = t.entries.get("cid1").expect("entry must exist");
235        assert_eq!(entry.ref_count, 1);
236        assert_eq!(entry.size_bytes, 512);
237        assert_eq!(entry.first_seen_secs, 100);
238        assert_eq!(entry.last_seen_secs, 100);
239        assert_eq!(entry.cid, "cid1");
240    }
241
242    // ── 3. add_ref: existing block increments ref_count ───────────────────────
243
244    #[test]
245    fn test_add_ref_increments_ref_count() {
246        let mut t = BlockDeduplicationTracker::new();
247        t.add_ref("cid1", 512, 100);
248        t.add_ref("cid1", 512, 200);
249        t.add_ref("cid1", 512, 300);
250        assert_eq!(t.ref_count("cid1"), 3);
251    }
252
253    // ── 4. add_ref: last_seen_secs updated ────────────────────────────────────
254
255    #[test]
256    fn test_add_ref_updates_last_seen() {
257        let mut t = BlockDeduplicationTracker::new();
258        t.add_ref("cid1", 512, 100);
259        t.add_ref("cid1", 512, 999);
260        let entry = t.entries.get("cid1").expect("entry must exist");
261        assert_eq!(entry.first_seen_secs, 100);
262        assert_eq!(entry.last_seen_secs, 999);
263    }
264
265    // ── 5. remove_ref: ref_count > 1 decrements ──────────────────────────────
266
267    #[test]
268    fn test_remove_ref_decrements() {
269        let mut t = BlockDeduplicationTracker::new();
270        t.add_ref("x", 64, 1);
271        t.add_ref("x", 64, 2);
272        assert!(t.remove_ref("x"));
273        assert_eq!(t.ref_count("x"), 1);
274        assert!(t.entries.contains_key("x"));
275    }
276
277    // ── 6. remove_ref: ref_count == 1 removes entirely ────────────────────────
278
279    #[test]
280    fn test_remove_ref_removes_entry() {
281        let mut t = BlockDeduplicationTracker::new();
282        t.add_ref("x", 64, 1);
283        assert!(t.remove_ref("x"));
284        assert!(!t.entries.contains_key("x"));
285    }
286
287    // ── 7. remove_ref: not found returns false ────────────────────────────────
288
289    #[test]
290    fn test_remove_ref_not_found() {
291        let mut t = BlockDeduplicationTracker::new();
292        assert!(!t.remove_ref("nonexistent"));
293    }
294
295    // ── 8. ref_count: 0 for unknown ───────────────────────────────────────────
296
297    #[test]
298    fn test_ref_count_unknown() {
299        let t = BlockDeduplicationTracker::new();
300        assert_eq!(t.ref_count("ghost"), 0);
301    }
302
303    // ── 9. ref_count: correct after multiple add/remove ──────────────────────
304
305    #[test]
306    fn test_ref_count_after_add_remove() {
307        let mut t = BlockDeduplicationTracker::new();
308        t.add_ref("y", 32, 1);
309        t.add_ref("y", 32, 2);
310        t.add_ref("y", 32, 3);
311        t.remove_ref("y");
312        assert_eq!(t.ref_count("y"), 2);
313        t.remove_ref("y");
314        assert_eq!(t.ref_count("y"), 1);
315        t.remove_ref("y");
316        assert_eq!(t.ref_count("y"), 0); // removed from map
317    }
318
319    // ── 10. is_safe_to_delete: true when not in map ──────────────────────────
320
321    #[test]
322    fn test_is_safe_to_delete_not_in_map() {
323        let t = BlockDeduplicationTracker::new();
324        assert!(t.is_safe_to_delete("absent"));
325    }
326
327    // ── 11. is_safe_to_delete: false when ref_count > 0 ─────────────────────
328
329    #[test]
330    fn test_is_safe_to_delete_has_refs() {
331        let mut t = BlockDeduplicationTracker::new();
332        t.add_ref("live", 128, 1);
333        assert!(!t.is_safe_to_delete("live"));
334    }
335
336    // ── 12. is_shared: true for ref_count > 1 ────────────────────────────────
337
338    #[test]
339    fn test_is_shared() {
340        let e1 = RefEntry {
341            cid: "a".to_owned(),
342            ref_count: 1,
343            first_seen_secs: 0,
344            last_seen_secs: 0,
345            size_bytes: 100,
346        };
347        let e2 = RefEntry {
348            ref_count: 2,
349            ..e1.clone()
350        };
351        assert!(!e1.is_shared());
352        assert!(e2.is_shared());
353    }
354
355    // ── 13. savings_bytes: (n-1) * size ──────────────────────────────────────
356
357    #[test]
358    fn test_savings_bytes() {
359        let entry = RefEntry {
360            cid: "z".to_owned(),
361            ref_count: 5,
362            first_seen_secs: 0,
363            last_seen_secs: 0,
364            size_bytes: 1000,
365        };
366        assert_eq!(entry.savings_bytes(), 4000);
367
368        let single = RefEntry {
369            ref_count: 1,
370            ..entry.clone()
371        };
372        assert_eq!(single.savings_bytes(), 0);
373    }
374
375    // ── 14. shared_blocks sorted by savings desc ──────────────────────────────
376
377    #[test]
378    fn test_shared_blocks_sorted() {
379        let t = tracker_with_entries();
380        let shared = t.shared_blocks();
381        // "a": savings=200, "b": savings=50
382        assert_eq!(shared.len(), 2);
383        assert_eq!(shared[0].cid, "a");
384        assert_eq!(shared[1].cid, "b");
385    }
386
387    // ── 15. unique_blocks filtered correctly ─────────────────────────────────
388
389    #[test]
390    fn test_unique_blocks() {
391        let t = tracker_with_entries();
392        let unique = t.unique_blocks();
393        assert_eq!(unique.len(), 1);
394        assert_eq!(unique[0].cid, "c");
395    }
396
397    // ── 16. top_savings top-n ─────────────────────────────────────────────────
398
399    #[test]
400    fn test_top_savings() {
401        let t = tracker_with_entries();
402        let top1 = t.top_savings(1);
403        assert_eq!(top1.len(), 1);
404        assert_eq!(top1[0].cid, "a");
405
406        let top2 = t.top_savings(2);
407        assert_eq!(top2.len(), 2);
408        assert_eq!(top2[0].cid, "a");
409        assert_eq!(top2[1].cid, "b");
410
411        // requesting more than available returns all
412        let top10 = t.top_savings(10);
413        assert_eq!(top10.len(), 3);
414    }
415
416    // ── 17. stats: all fields correct ─────────────────────────────────────────
417
418    #[test]
419    fn test_stats_all_fields() {
420        let t = tracker_with_entries();
421        let s = t.stats();
422        assert_eq!(s.total_blocks, 3);
423        assert_eq!(s.shared_blocks, 2); // "a" and "b"
424        assert_eq!(s.unique_blocks, 1); // "c"
425        assert_eq!(s.total_ref_count, 6); // 3+2+1
426        assert_eq!(s.total_bytes, 350); // 100+50+200
427        assert_eq!(s.saved_bytes, 250); // 200+50
428    }
429
430    // ── 18. dedup_ratio calculation ───────────────────────────────────────────
431
432    #[test]
433    fn test_dedup_ratio() {
434        let t = tracker_with_entries();
435        let s = t.stats();
436        // 2 shared out of 3 total
437        let ratio = s.dedup_ratio();
438        assert!((ratio - 2.0 / 3.0).abs() < f64::EPSILON);
439    }
440
441    #[test]
442    fn test_dedup_ratio_empty() {
443        let t = BlockDeduplicationTracker::new();
444        let s = t.stats();
445        assert_eq!(s.dedup_ratio(), 0.0);
446    }
447
448    // ── 19. prune_unreferenced count ─────────────────────────────────────────
449
450    #[test]
451    fn test_prune_unreferenced() {
452        let mut t = BlockDeduplicationTracker::new();
453        t.add_ref("keep", 10, 1);
454        t.add_ref("remove_me", 20, 1);
455
456        // Manually set ref_count to 0 to simulate an edge case.
457        t.entries
458            .get_mut("remove_me")
459            .expect("must exist")
460            .ref_count = 0;
461
462        let pruned = t.prune_unreferenced();
463        assert_eq!(pruned, 1);
464        assert!(t.entries.contains_key("keep"));
465        assert!(!t.entries.contains_key("remove_me"));
466    }
467
468    #[test]
469    fn test_prune_unreferenced_none() {
470        let mut t = tracker_with_entries();
471        let pruned = t.prune_unreferenced();
472        assert_eq!(pruned, 0);
473    }
474}