Skip to main content

ipfrs_storage/
snapshot_differ.rs

1//! Snapshot differ — computes the minimal set of block operations needed to
2//! transform one storage snapshot into another.
3//!
4//! Used for incremental sync and delta-compression of backup chains.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// BlockOp
10// ---------------------------------------------------------------------------
11
12/// A single operation on a content-addressed block.
13#[derive(Clone, Debug, PartialEq)]
14pub enum BlockOp {
15    /// Block must be written / transferred.
16    Put { cid: String, size_bytes: u64 },
17    /// Block must be removed.
18    Delete { cid: String },
19    /// Block is present in both snapshots — no action required.
20    Unchanged { cid: String },
21}
22
23impl BlockOp {
24    /// Returns the CID this operation targets.
25    pub fn cid(&self) -> &str {
26        match self {
27            BlockOp::Put { cid, .. } => cid.as_str(),
28            BlockOp::Delete { cid } => cid.as_str(),
29            BlockOp::Unchanged { cid } => cid.as_str(),
30        }
31    }
32}
33
34// ---------------------------------------------------------------------------
35// SnapshotEntry
36// ---------------------------------------------------------------------------
37
38/// A single content-addressed block referenced by a snapshot.
39#[derive(Clone, Debug, PartialEq)]
40pub struct SnapshotEntry {
41    /// Content identifier (e.g. a CIDv1 string).
42    pub cid: String,
43    /// Size of the block in bytes.
44    pub size_bytes: u64,
45    /// Unix timestamp (seconds) at which this block was created / ingested.
46    pub created_at_secs: u64,
47    /// Arbitrary tags associated with this block (e.g. `["pinned", "index"]`).
48    pub tags: Vec<String>,
49}
50
51// ---------------------------------------------------------------------------
52// Snapshot
53// ---------------------------------------------------------------------------
54
55/// An immutable point-in-time view of a set of content-addressed blocks.
56#[derive(Clone, Debug, PartialEq)]
57pub struct Snapshot {
58    /// Unique identifier for this snapshot.
59    pub id: String,
60    /// Unix timestamp (seconds) at which the snapshot was taken.
61    pub taken_at_secs: u64,
62    /// The entries (blocks) present in this snapshot.
63    pub entries: Vec<SnapshotEntry>,
64}
65
66impl Snapshot {
67    /// Returns the number of entries in this snapshot.
68    pub fn entry_count(&self) -> usize {
69        self.entries.len()
70    }
71
72    /// Returns the sum of `size_bytes` across all entries.
73    pub fn total_bytes(&self) -> u64 {
74        self.entries.iter().map(|e| e.size_bytes).sum()
75    }
76}
77
78// ---------------------------------------------------------------------------
79// DiffResult
80// ---------------------------------------------------------------------------
81
82/// The result of comparing two snapshots.
83#[derive(Clone, Debug, PartialEq)]
84pub struct DiffResult {
85    /// All operations, sorted by CID for determinism.
86    ///
87    /// When `SnapshotDiffer::include_unchanged` is `false`, `Unchanged` ops
88    /// are **excluded** from this vec (but are still counted in `unchanged`).
89    pub ops: Vec<BlockOp>,
90    /// Number of `Put` operations.
91    pub puts: usize,
92    /// Number of `Delete` operations.
93    pub deletes: usize,
94    /// Number of `Unchanged` operations.
95    pub unchanged: usize,
96    /// Total bytes that would be added.
97    pub bytes_added: u64,
98    /// Total bytes that would be removed.
99    pub bytes_removed: u64,
100}
101
102impl DiffResult {
103    /// Net byte delta: `bytes_added` − `bytes_removed`.
104    pub fn net_bytes(&self) -> i64 {
105        self.bytes_added as i64 - self.bytes_removed as i64
106    }
107
108    /// `true` if there is at least one `Put` or `Delete` operation.
109    pub fn has_changes(&self) -> bool {
110        self.puts > 0 || self.deletes > 0
111    }
112}
113
114// ---------------------------------------------------------------------------
115// SnapshotDiffer
116// ---------------------------------------------------------------------------
117
118/// Computes the minimal set of [`BlockOp`]s to transition from one
119/// [`Snapshot`] to another.
120#[derive(Clone, Debug)]
121pub struct SnapshotDiffer {
122    /// When `true`, `Unchanged` ops are included in [`DiffResult::ops`].
123    /// Counts are always tracked regardless of this flag.
124    pub include_unchanged: bool,
125}
126
127impl SnapshotDiffer {
128    /// Creates a new `SnapshotDiffer`.
129    pub fn new(include_unchanged: bool) -> Self {
130        Self { include_unchanged }
131    }
132
133    /// Computes the diff between `old` and `new` snapshots.
134    pub fn diff(&self, old: &Snapshot, new: &Snapshot) -> DiffResult {
135        // Build lookup for old entries keyed by CID.
136        let old_map: HashMap<&str, &SnapshotEntry> =
137            old.entries.iter().map(|e| (e.cid.as_str(), e)).collect();
138
139        // Build lookup for new entries to detect deletions cheaply.
140        let new_cids: HashMap<&str, ()> =
141            new.entries.iter().map(|e| (e.cid.as_str(), ())).collect();
142
143        let mut ops: Vec<BlockOp> = Vec::new();
144        let mut puts: usize = 0;
145        let mut deletes: usize = 0;
146        let mut unchanged: usize = 0;
147        let mut bytes_added: u64 = 0;
148        let mut bytes_removed: u64 = 0;
149
150        // Walk new entries → Put or Unchanged.
151        for entry in &new.entries {
152            if old_map.contains_key(entry.cid.as_str()) {
153                unchanged += 1;
154                if self.include_unchanged {
155                    ops.push(BlockOp::Unchanged {
156                        cid: entry.cid.clone(),
157                    });
158                }
159            } else {
160                puts += 1;
161                bytes_added += entry.size_bytes;
162                ops.push(BlockOp::Put {
163                    cid: entry.cid.clone(),
164                    size_bytes: entry.size_bytes,
165                });
166            }
167        }
168
169        // Walk old entries → Delete for anything not in new.
170        for entry in &old.entries {
171            if !new_cids.contains_key(entry.cid.as_str()) {
172                deletes += 1;
173                bytes_removed += entry.size_bytes;
174                ops.push(BlockOp::Delete {
175                    cid: entry.cid.clone(),
176                });
177            }
178        }
179
180        // Sort by CID for determinism.
181        ops.sort_by(|a, b| a.cid().cmp(b.cid()));
182
183        DiffResult {
184            ops,
185            puts,
186            deletes,
187            unchanged,
188            bytes_added,
189            bytes_removed,
190        }
191    }
192
193    /// Applies a slice of [`BlockOp`]s to `base`, producing a new [`Snapshot`].
194    ///
195    /// - `Delete` removes the matching entry.
196    /// - `Put` adds a new entry (`created_at_secs = 0`, `tags = []`).
197    /// - `Unchanged` is a no-op (the entry is already present in `base`).
198    ///
199    /// The returned snapshot has `id = "applied"` and `taken_at_secs = 0`.
200    pub fn apply_ops(base: &Snapshot, ops: &[BlockOp]) -> Snapshot {
201        // Start with all base entries.
202        let mut entries: Vec<SnapshotEntry> = base.entries.clone();
203
204        for op in ops {
205            match op {
206                BlockOp::Delete { cid } => {
207                    entries.retain(|e| &e.cid != cid);
208                }
209                BlockOp::Put { cid, size_bytes } => {
210                    // Only add if not already present (idempotent).
211                    if !entries.iter().any(|e| &e.cid == cid) {
212                        entries.push(SnapshotEntry {
213                            cid: cid.clone(),
214                            size_bytes: *size_bytes,
215                            created_at_secs: 0,
216                            tags: Vec::new(),
217                        });
218                    }
219                }
220                BlockOp::Unchanged { .. } => {
221                    // No-op — entry is already in base.
222                }
223            }
224        }
225
226        Snapshot {
227            id: "applied".to_string(),
228            taken_at_secs: 0,
229            entries,
230        }
231    }
232
233    /// Computes diffs between every consecutive pair in `snapshots`.
234    ///
235    /// Returns a `Vec` of length `snapshots.len() - 1`, or an empty `Vec`
236    /// if fewer than two snapshots are provided.
237    pub fn chain_diff(&self, snapshots: &[Snapshot]) -> Vec<DiffResult> {
238        if snapshots.len() < 2 {
239            return Vec::new();
240        }
241        snapshots
242            .windows(2)
243            .map(|pair| self.diff(&pair[0], &pair[1]))
244            .collect()
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn make_entry(cid: &str, size: u64) -> SnapshotEntry {
257        SnapshotEntry {
258            cid: cid.to_string(),
259            size_bytes: size,
260            created_at_secs: 1_000,
261            tags: vec!["pinned".to_string()],
262        }
263    }
264
265    fn make_snapshot(id: &str, entries: Vec<SnapshotEntry>) -> Snapshot {
266        Snapshot {
267            id: id.to_string(),
268            taken_at_secs: 1_000,
269            entries,
270        }
271    }
272
273    // -----------------------------------------------------------------------
274    // constructor
275    // -----------------------------------------------------------------------
276
277    #[test]
278    fn new_include_unchanged_true() {
279        let d = SnapshotDiffer::new(true);
280        assert!(d.include_unchanged);
281    }
282
283    #[test]
284    fn new_include_unchanged_false() {
285        let d = SnapshotDiffer::new(false);
286        assert!(!d.include_unchanged);
287    }
288
289    // -----------------------------------------------------------------------
290    // diff: edge cases
291    // -----------------------------------------------------------------------
292
293    #[test]
294    fn diff_both_empty() {
295        let d = SnapshotDiffer::new(true);
296        let old = make_snapshot("old", vec![]);
297        let new = make_snapshot("new", vec![]);
298        let result = d.diff(&old, &new);
299        assert_eq!(result.ops.len(), 0);
300        assert_eq!(result.puts, 0);
301        assert_eq!(result.deletes, 0);
302        assert_eq!(result.unchanged, 0);
303    }
304
305    #[test]
306    fn diff_empty_old_new_entries_all_put() {
307        let d = SnapshotDiffer::new(true);
308        let old = make_snapshot("old", vec![]);
309        let new = make_snapshot(
310            "new",
311            vec![make_entry("cid-a", 100), make_entry("cid-b", 200)],
312        );
313        let result = d.diff(&old, &new);
314        assert_eq!(result.puts, 2);
315        assert_eq!(result.deletes, 0);
316        assert_eq!(result.unchanged, 0);
317        assert!(result
318            .ops
319            .iter()
320            .all(|op| matches!(op, BlockOp::Put { .. })));
321    }
322
323    #[test]
324    fn diff_old_entries_empty_new_all_delete() {
325        let d = SnapshotDiffer::new(true);
326        let old = make_snapshot(
327            "old",
328            vec![make_entry("cid-a", 100), make_entry("cid-b", 200)],
329        );
330        let new = make_snapshot("new", vec![]);
331        let result = d.diff(&old, &new);
332        assert_eq!(result.puts, 0);
333        assert_eq!(result.deletes, 2);
334        assert_eq!(result.unchanged, 0);
335        assert!(result
336            .ops
337            .iter()
338            .all(|op| matches!(op, BlockOp::Delete { .. })));
339    }
340
341    #[test]
342    fn diff_identical_snapshots_all_unchanged() {
343        let d = SnapshotDiffer::new(true);
344        let entries = vec![make_entry("cid-a", 100), make_entry("cid-b", 200)];
345        let old = make_snapshot("old", entries.clone());
346        let new = make_snapshot("new", entries);
347        let result = d.diff(&old, &new);
348        assert_eq!(result.puts, 0);
349        assert_eq!(result.deletes, 0);
350        assert_eq!(result.unchanged, 2);
351        assert!(result
352            .ops
353            .iter()
354            .all(|op| matches!(op, BlockOp::Unchanged { .. })));
355    }
356
357    // -----------------------------------------------------------------------
358    // diff: mixed operations
359    // -----------------------------------------------------------------------
360
361    #[test]
362    fn diff_one_added_one_removed_one_unchanged() {
363        let d = SnapshotDiffer::new(true);
364        let old = make_snapshot(
365            "old",
366            vec![make_entry("cid-keep", 50), make_entry("cid-old", 100)],
367        );
368        let new = make_snapshot(
369            "new",
370            vec![make_entry("cid-keep", 50), make_entry("cid-new", 200)],
371        );
372        let result = d.diff(&old, &new);
373        assert_eq!(result.puts, 1);
374        assert_eq!(result.deletes, 1);
375        assert_eq!(result.unchanged, 1);
376    }
377
378    #[test]
379    fn diff_result_sorted_by_cid() {
380        let d = SnapshotDiffer::new(true);
381        let old = make_snapshot("old", vec![make_entry("zzz", 10)]);
382        let new = make_snapshot("new", vec![make_entry("bbb", 20), make_entry("aaa", 30)]);
383        let result = d.diff(&old, &new);
384        let cids: Vec<&str> = result.ops.iter().map(|op| op.cid()).collect();
385        let mut sorted = cids.clone();
386        sorted.sort_unstable();
387        assert_eq!(cids, sorted, "ops must be sorted by CID");
388    }
389
390    #[test]
391    fn diff_bytes_added_removed_correct() {
392        let d = SnapshotDiffer::new(true);
393        let old = make_snapshot("old", vec![make_entry("del", 300)]);
394        let new = make_snapshot("new", vec![make_entry("put", 500)]);
395        let result = d.diff(&old, &new);
396        assert_eq!(result.bytes_added, 500);
397        assert_eq!(result.bytes_removed, 300);
398    }
399
400    #[test]
401    fn diff_net_bytes_positive_when_more_added() {
402        let d = SnapshotDiffer::new(true);
403        let old = make_snapshot("old", vec![make_entry("del", 100)]);
404        let new = make_snapshot("new", vec![make_entry("put", 900)]);
405        let result = d.diff(&old, &new);
406        assert!(result.net_bytes() > 0);
407        assert_eq!(result.net_bytes(), 800_i64);
408    }
409
410    #[test]
411    fn diff_has_changes_true() {
412        let d = SnapshotDiffer::new(true);
413        let old = make_snapshot("old", vec![make_entry("a", 1)]);
414        let new = make_snapshot("new", vec![make_entry("b", 1)]);
415        assert!(d.diff(&old, &new).has_changes());
416    }
417
418    #[test]
419    fn diff_has_changes_false_when_identical() {
420        let d = SnapshotDiffer::new(true);
421        let entries = vec![make_entry("a", 1)];
422        let old = make_snapshot("old", entries.clone());
423        let new = make_snapshot("new", entries);
424        assert!(!d.diff(&old, &new).has_changes());
425    }
426
427    // -----------------------------------------------------------------------
428    // include_unchanged flag
429    // -----------------------------------------------------------------------
430
431    #[test]
432    fn diff_include_unchanged_false_excludes_unchanged_from_ops() {
433        let d = SnapshotDiffer::new(false);
434        let entries = vec![make_entry("cid-keep", 50)];
435        let old = make_snapshot("old", entries.clone());
436        let new = make_snapshot("new", entries);
437        let result = d.diff(&old, &new);
438        // ops vec should be empty because the only op is Unchanged and flag is false
439        assert_eq!(result.ops.len(), 0);
440    }
441
442    #[test]
443    fn diff_include_unchanged_false_still_counts_unchanged() {
444        let d = SnapshotDiffer::new(false);
445        let old = make_snapshot(
446            "old",
447            vec![make_entry("keep", 10), make_entry("old-only", 20)],
448        );
449        let new = make_snapshot(
450            "new",
451            vec![make_entry("keep", 10), make_entry("new-only", 30)],
452        );
453        let result = d.diff(&old, &new);
454        assert_eq!(result.unchanged, 1);
455        assert_eq!(result.puts, 1);
456        assert_eq!(result.deletes, 1);
457        // Unchanged not in ops, but Put and Delete are
458        assert!(result
459            .ops
460            .iter()
461            .all(|op| !matches!(op, BlockOp::Unchanged { .. })));
462    }
463
464    // -----------------------------------------------------------------------
465    // apply_ops
466    // -----------------------------------------------------------------------
467
468    #[test]
469    fn apply_ops_put_adds_entry() {
470        let base = make_snapshot("base", vec![]);
471        let ops = vec![BlockOp::Put {
472            cid: "new-cid".to_string(),
473            size_bytes: 42,
474        }];
475        let result = SnapshotDiffer::apply_ops(&base, &ops);
476        assert_eq!(result.entry_count(), 1);
477        assert_eq!(result.entries[0].cid, "new-cid");
478        assert_eq!(result.entries[0].size_bytes, 42);
479    }
480
481    #[test]
482    fn apply_ops_delete_removes_entry() {
483        let base = make_snapshot("base", vec![make_entry("to-delete", 100)]);
484        let ops = vec![BlockOp::Delete {
485            cid: "to-delete".to_string(),
486        }];
487        let result = SnapshotDiffer::apply_ops(&base, &ops);
488        assert_eq!(result.entry_count(), 0);
489    }
490
491    #[test]
492    fn apply_ops_unchanged_has_no_effect() {
493        let base = make_snapshot("base", vec![make_entry("stable", 50)]);
494        let ops = vec![BlockOp::Unchanged {
495            cid: "stable".to_string(),
496        }];
497        let result = SnapshotDiffer::apply_ops(&base, &ops);
498        assert_eq!(result.entry_count(), 1);
499        assert_eq!(result.entries[0].cid, "stable");
500    }
501
502    // -----------------------------------------------------------------------
503    // chain_diff
504    // -----------------------------------------------------------------------
505
506    #[test]
507    fn chain_diff_empty_input_returns_empty() {
508        let d = SnapshotDiffer::new(true);
509        let result = d.chain_diff(&[]);
510        assert!(result.is_empty());
511    }
512
513    #[test]
514    fn chain_diff_single_snapshot_returns_empty() {
515        let d = SnapshotDiffer::new(true);
516        let s = make_snapshot("only", vec![make_entry("cid", 1)]);
517        let result = d.chain_diff(&[s]);
518        assert!(result.is_empty());
519    }
520
521    #[test]
522    fn chain_diff_two_snapshots_returns_one_result() {
523        let d = SnapshotDiffer::new(true);
524        let s0 = make_snapshot("s0", vec![make_entry("a", 10)]);
525        let s1 = make_snapshot("s1", vec![make_entry("b", 20)]);
526        let results = d.chain_diff(&[s0, s1]);
527        assert_eq!(results.len(), 1);
528        assert_eq!(results[0].puts, 1);
529        assert_eq!(results[0].deletes, 1);
530    }
531}