Skip to main content

ipfrs_storage/
block_manifest.rs

1//! Storage Block Manifest — catalogs all blocks in a partition with metadata
2//! for fast lookup, consistency checking, and export/import.
3
4use std::collections::HashMap;
5
6// ---------------------------------------------------------------------------
7// FNV-1a 64-bit hash
8// ---------------------------------------------------------------------------
9
10/// Computes the FNV-1a 64-bit hash of `bytes`.
11pub fn fnv1a(bytes: &[u8]) -> u64 {
12    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
13    const PRIME: u64 = 1_099_511_628_211;
14
15    let mut hash = OFFSET_BASIS;
16    for &b in bytes {
17        hash ^= u64::from(b);
18        hash = hash.wrapping_mul(PRIME);
19    }
20    hash
21}
22
23// ---------------------------------------------------------------------------
24// ManifestEntry
25// ---------------------------------------------------------------------------
26
27/// A single block entry in the manifest.
28#[derive(Debug, Clone, PartialEq)]
29pub struct ManifestEntry {
30    /// Content identifier for this block.
31    pub cid: String,
32    /// Size of the block in bytes.
33    pub size_bytes: u64,
34    /// Content type, e.g. `"raw"`, `"dag-cbor"`, `"arrow-ipc"`.
35    pub content_type: String,
36    /// Unix timestamp (seconds) when this entry was added.
37    pub added_at_secs: u64,
38    /// Whether this block is pinned (protected from GC).
39    pub pinned: bool,
40    /// FNV-1a 64-bit hash of the CID bytes, for integrity verification.
41    pub checksum: u64,
42}
43
44impl ManifestEntry {
45    /// Construct a new `ManifestEntry`, computing the checksum automatically.
46    pub fn new(
47        cid: impl Into<String>,
48        size_bytes: u64,
49        content_type: impl Into<String>,
50        added_at_secs: u64,
51        pinned: bool,
52    ) -> Self {
53        let cid = cid.into();
54        let checksum = fnv1a(cid.as_bytes());
55        Self {
56            cid,
57            size_bytes,
58            content_type: content_type.into(),
59            added_at_secs,
60            pinned,
61            checksum,
62        }
63    }
64}
65
66// ---------------------------------------------------------------------------
67// ManifestStats
68// ---------------------------------------------------------------------------
69
70/// Aggregate statistics over all entries in a manifest.
71#[derive(Debug, Clone, PartialEq)]
72pub struct ManifestStats {
73    /// Total number of entries.
74    pub total_entries: usize,
75    /// Total storage consumed by all entries.
76    pub total_size_bytes: u64,
77    /// Number of pinned entries.
78    pub pinned_count: usize,
79    /// Per-content-type entry count.
80    pub content_type_counts: HashMap<String, usize>,
81}
82
83// ---------------------------------------------------------------------------
84// ManifestFilter
85// ---------------------------------------------------------------------------
86
87/// Predicate used to select a subset of manifest entries.
88#[derive(Debug, Clone, PartialEq)]
89pub enum ManifestFilter {
90    /// Include every entry.
91    All,
92    /// Include only pinned entries.
93    PinnedOnly,
94    /// Include only entries whose `content_type` matches the given string.
95    ContentType(String),
96    /// Include only entries added after the given Unix timestamp (exclusive).
97    AddedAfter(u64),
98    /// Include only entries whose size exceeds the threshold (exclusive).
99    SizeAbove(u64),
100}
101
102impl ManifestFilter {
103    fn matches(&self, entry: &ManifestEntry) -> bool {
104        match self {
105            ManifestFilter::All => true,
106            ManifestFilter::PinnedOnly => entry.pinned,
107            ManifestFilter::ContentType(ct) => &entry.content_type == ct,
108            ManifestFilter::AddedAfter(threshold) => entry.added_at_secs > *threshold,
109            ManifestFilter::SizeAbove(threshold) => entry.size_bytes > *threshold,
110        }
111    }
112}
113
114// ---------------------------------------------------------------------------
115// StorageBlockManifest
116// ---------------------------------------------------------------------------
117
118/// Catalogs all blocks in a storage partition.
119///
120/// Keyed by CID; supports fast lookup, consistency verification, filtering,
121/// pinning, statistics, merge, and sorted export.
122#[derive(Debug, Clone)]
123pub struct StorageBlockManifest {
124    /// Block entries indexed by CID.
125    pub entries: HashMap<String, ManifestEntry>,
126}
127
128impl StorageBlockManifest {
129    /// Create an empty manifest.
130    pub fn new() -> Self {
131        Self {
132            entries: HashMap::new(),
133        }
134    }
135
136    /// Add (or overwrite) an entry.  The key used is `entry.cid`.
137    pub fn add_entry(&mut self, entry: ManifestEntry) {
138        self.entries.insert(entry.cid.clone(), entry);
139    }
140
141    /// Remove an entry by CID.
142    ///
143    /// Returns `true` if the entry existed and was removed, `false` otherwise.
144    pub fn remove_entry(&mut self, cid: &str) -> bool {
145        self.entries.remove(cid).is_some()
146    }
147
148    /// Retrieve an entry by CID, or `None` if not present.
149    pub fn get_entry(&self, cid: &str) -> Option<&ManifestEntry> {
150        self.entries.get(cid)
151    }
152
153    /// Mark the entry with the given CID as pinned.
154    ///
155    /// Returns `false` if no such entry exists.
156    pub fn pin(&mut self, cid: &str) -> bool {
157        match self.entries.get_mut(cid) {
158            Some(entry) => {
159                entry.pinned = true;
160                true
161            }
162            None => false,
163        }
164    }
165
166    /// Mark the entry with the given CID as unpinned.
167    ///
168    /// Returns `false` if no such entry exists.
169    pub fn unpin(&mut self, cid: &str) -> bool {
170        match self.entries.get_mut(cid) {
171            Some(entry) => {
172                entry.pinned = false;
173                true
174            }
175            None => false,
176        }
177    }
178
179    /// Return all entries matching `filter`, sorted by `added_at_secs` ascending.
180    pub fn filter(&self, filter: &ManifestFilter) -> Vec<&ManifestEntry> {
181        let mut matched: Vec<&ManifestEntry> = self
182            .entries
183            .values()
184            .filter(|e| filter.matches(e))
185            .collect();
186        matched.sort_by_key(|e| e.added_at_secs);
187        matched
188    }
189
190    /// Compute aggregate statistics for the manifest.
191    pub fn stats(&self) -> ManifestStats {
192        let mut total_size_bytes: u64 = 0;
193        let mut pinned_count: usize = 0;
194        let mut content_type_counts: HashMap<String, usize> = HashMap::new();
195
196        for entry in self.entries.values() {
197            total_size_bytes = total_size_bytes.saturating_add(entry.size_bytes);
198            if entry.pinned {
199                pinned_count += 1;
200            }
201            *content_type_counts
202                .entry(entry.content_type.clone())
203                .or_insert(0) += 1;
204        }
205
206        ManifestStats {
207            total_entries: self.entries.len(),
208            total_size_bytes,
209            pinned_count,
210            content_type_counts,
211        }
212    }
213
214    /// Merge entries from `other` into `self`.
215    ///
216    /// - If a CID in `other` is absent from `self`, it is inserted.
217    /// - If a CID is present in both and `other`'s entry is pinned, `self`'s
218    ///   entry is also set to pinned (pin propagation).
219    pub fn merge(&mut self, other: &StorageBlockManifest) {
220        for (cid, other_entry) in &other.entries {
221            match self.entries.get_mut(cid) {
222                Some(self_entry) => {
223                    if other_entry.pinned {
224                        self_entry.pinned = true;
225                    }
226                }
227                None => {
228                    self.entries.insert(cid.clone(), other_entry.clone());
229                }
230            }
231        }
232    }
233
234    /// Return all CIDs in the manifest, sorted alphabetically.
235    pub fn export_cids(&self) -> Vec<String> {
236        let mut cids: Vec<String> = self.entries.keys().cloned().collect();
237        cids.sort();
238        cids
239    }
240
241    /// Return the CIDs of entries whose stored checksum does not match
242    /// the FNV-1a hash recomputed from the CID bytes.
243    pub fn verify_checksums(&self) -> Vec<String> {
244        let mut mismatches: Vec<String> = self
245            .entries
246            .values()
247            .filter(|e| fnv1a(e.cid.as_bytes()) != e.checksum)
248            .map(|e| e.cid.clone())
249            .collect();
250        mismatches.sort();
251        mismatches
252    }
253}
254
255impl Default for StorageBlockManifest {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261// ---------------------------------------------------------------------------
262// Tests
263// ---------------------------------------------------------------------------
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn make_entry(
270        cid: &str,
271        size_bytes: u64,
272        content_type: &str,
273        added_at_secs: u64,
274        pinned: bool,
275    ) -> ManifestEntry {
276        ManifestEntry::new(cid, size_bytes, content_type, added_at_secs, pinned)
277    }
278
279    // ------------------------------------------------------------------
280    // new()
281    // ------------------------------------------------------------------
282
283    #[test]
284    fn test_new_starts_empty() {
285        let manifest = StorageBlockManifest::new();
286        assert!(manifest.entries.is_empty());
287    }
288
289    // ------------------------------------------------------------------
290    // add_entry / get_entry
291    // ------------------------------------------------------------------
292
293    #[test]
294    fn test_add_entry_stores_correctly() {
295        let mut m = StorageBlockManifest::new();
296        let entry = make_entry("cid1", 100, "raw", 1000, false);
297        m.add_entry(entry.clone());
298        let got = m.get_entry("cid1").expect("entry should exist");
299        assert_eq!(got, &entry);
300    }
301
302    #[test]
303    fn test_add_entry_overwrites_existing() {
304        let mut m = StorageBlockManifest::new();
305        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
306        m.add_entry(make_entry("cid1", 200, "dag-cbor", 2000, true));
307        let got = m.get_entry("cid1").expect("entry should exist");
308        assert_eq!(got.size_bytes, 200);
309        assert_eq!(got.content_type, "dag-cbor");
310    }
311
312    #[test]
313    fn test_get_entry_none_for_missing() {
314        let m = StorageBlockManifest::new();
315        assert!(m.get_entry("nonexistent").is_none());
316    }
317
318    // ------------------------------------------------------------------
319    // remove_entry
320    // ------------------------------------------------------------------
321
322    #[test]
323    fn test_remove_entry_returns_true_when_found() {
324        let mut m = StorageBlockManifest::new();
325        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
326        assert!(m.remove_entry("cid1"));
327        assert!(m.get_entry("cid1").is_none());
328    }
329
330    #[test]
331    fn test_remove_entry_returns_false_when_not_found() {
332        let mut m = StorageBlockManifest::new();
333        assert!(!m.remove_entry("ghost"));
334    }
335
336    // ------------------------------------------------------------------
337    // pin / unpin
338    // ------------------------------------------------------------------
339
340    #[test]
341    fn test_pin_returns_false_when_missing() {
342        let mut m = StorageBlockManifest::new();
343        assert!(!m.pin("ghost"));
344    }
345
346    #[test]
347    fn test_unpin_returns_false_when_missing() {
348        let mut m = StorageBlockManifest::new();
349        assert!(!m.unpin("ghost"));
350    }
351
352    #[test]
353    fn test_pin_sets_pinned_true() {
354        let mut m = StorageBlockManifest::new();
355        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
356        assert!(m.pin("cid1"));
357        assert!(m.get_entry("cid1").unwrap().pinned);
358    }
359
360    #[test]
361    fn test_unpin_sets_pinned_false() {
362        let mut m = StorageBlockManifest::new();
363        m.add_entry(make_entry("cid1", 100, "raw", 1000, true));
364        assert!(m.unpin("cid1"));
365        assert!(!m.get_entry("cid1").unwrap().pinned);
366    }
367
368    #[test]
369    fn test_pin_returns_true_when_found() {
370        let mut m = StorageBlockManifest::new();
371        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
372        assert!(m.pin("cid1"));
373    }
374
375    #[test]
376    fn test_unpin_returns_true_when_found() {
377        let mut m = StorageBlockManifest::new();
378        m.add_entry(make_entry("cid1", 100, "raw", 1000, true));
379        assert!(m.unpin("cid1"));
380    }
381
382    // ------------------------------------------------------------------
383    // filter
384    // ------------------------------------------------------------------
385
386    #[test]
387    fn test_filter_all_returns_all() {
388        let mut m = StorageBlockManifest::new();
389        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
390        m.add_entry(make_entry("cid2", 200, "dag-cbor", 2000, true));
391        assert_eq!(m.filter(&ManifestFilter::All).len(), 2);
392    }
393
394    #[test]
395    fn test_filter_pinned_only() {
396        let mut m = StorageBlockManifest::new();
397        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
398        m.add_entry(make_entry("cid2", 200, "dag-cbor", 2000, true));
399        let result = m.filter(&ManifestFilter::PinnedOnly);
400        assert_eq!(result.len(), 1);
401        assert_eq!(result[0].cid, "cid2");
402    }
403
404    #[test]
405    fn test_filter_content_type() {
406        let mut m = StorageBlockManifest::new();
407        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
408        m.add_entry(make_entry("cid2", 200, "dag-cbor", 2000, false));
409        m.add_entry(make_entry("cid3", 300, "raw", 3000, false));
410        let result = m.filter(&ManifestFilter::ContentType("raw".to_string()));
411        assert_eq!(result.len(), 2);
412    }
413
414    #[test]
415    fn test_filter_added_after() {
416        let mut m = StorageBlockManifest::new();
417        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
418        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
419        m.add_entry(make_entry("cid3", 300, "raw", 3000, false));
420        let result = m.filter(&ManifestFilter::AddedAfter(1500));
421        assert_eq!(result.len(), 2);
422        for e in &result {
423            assert!(e.added_at_secs > 1500);
424        }
425    }
426
427    #[test]
428    fn test_filter_size_above() {
429        let mut m = StorageBlockManifest::new();
430        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
431        m.add_entry(make_entry("cid2", 500, "raw", 2000, false));
432        m.add_entry(make_entry("cid3", 1000, "raw", 3000, false));
433        let result = m.filter(&ManifestFilter::SizeAbove(400));
434        assert_eq!(result.len(), 2);
435        for e in &result {
436            assert!(e.size_bytes > 400);
437        }
438    }
439
440    #[test]
441    fn test_filter_sorted_by_added_at_secs_asc() {
442        let mut m = StorageBlockManifest::new();
443        // Add in reverse order to ensure sorting is actually performed.
444        m.add_entry(make_entry("cid3", 300, "raw", 3000, false));
445        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
446        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
447        let result = m.filter(&ManifestFilter::All);
448        let timestamps: Vec<u64> = result.iter().map(|e| e.added_at_secs).collect();
449        assert_eq!(timestamps, vec![1000, 2000, 3000]);
450    }
451
452    // ------------------------------------------------------------------
453    // stats
454    // ------------------------------------------------------------------
455
456    #[test]
457    fn test_stats_total_entries() {
458        let mut m = StorageBlockManifest::new();
459        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
460        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
461        assert_eq!(m.stats().total_entries, 2);
462    }
463
464    #[test]
465    fn test_stats_total_size_bytes() {
466        let mut m = StorageBlockManifest::new();
467        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
468        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
469        assert_eq!(m.stats().total_size_bytes, 300);
470    }
471
472    #[test]
473    fn test_stats_pinned_count() {
474        let mut m = StorageBlockManifest::new();
475        m.add_entry(make_entry("cid1", 100, "raw", 1000, true));
476        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
477        m.add_entry(make_entry("cid3", 300, "raw", 3000, true));
478        assert_eq!(m.stats().pinned_count, 2);
479    }
480
481    #[test]
482    fn test_stats_content_type_counts() {
483        let mut m = StorageBlockManifest::new();
484        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
485        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
486        m.add_entry(make_entry("cid3", 300, "dag-cbor", 3000, false));
487        let stats = m.stats();
488        assert_eq!(*stats.content_type_counts.get("raw").unwrap_or(&0), 2);
489        assert_eq!(*stats.content_type_counts.get("dag-cbor").unwrap_or(&0), 1);
490    }
491
492    // ------------------------------------------------------------------
493    // merge
494    // ------------------------------------------------------------------
495
496    #[test]
497    fn test_merge_adds_missing_entries() {
498        let mut m1 = StorageBlockManifest::new();
499        m1.add_entry(make_entry("cid1", 100, "raw", 1000, false));
500
501        let mut m2 = StorageBlockManifest::new();
502        m2.add_entry(make_entry("cid2", 200, "raw", 2000, false));
503
504        m1.merge(&m2);
505        assert!(m1.get_entry("cid2").is_some());
506        assert_eq!(m1.entries.len(), 2);
507    }
508
509    #[test]
510    fn test_merge_propagates_pin_from_other() {
511        let mut m1 = StorageBlockManifest::new();
512        m1.add_entry(make_entry("cid1", 100, "raw", 1000, false));
513
514        let mut m2 = StorageBlockManifest::new();
515        m2.add_entry(make_entry("cid1", 100, "raw", 1000, true));
516
517        m1.merge(&m2);
518        assert!(m1.get_entry("cid1").unwrap().pinned);
519    }
520
521    #[test]
522    fn test_merge_does_not_unpin_existing() {
523        let mut m1 = StorageBlockManifest::new();
524        m1.add_entry(make_entry("cid1", 100, "raw", 1000, true));
525
526        let mut m2 = StorageBlockManifest::new();
527        m2.add_entry(make_entry("cid1", 100, "raw", 1000, false));
528
529        m1.merge(&m2);
530        // Pin should not be cleared by a non-pinned other entry.
531        assert!(m1.get_entry("cid1").unwrap().pinned);
532    }
533
534    // ------------------------------------------------------------------
535    // export_cids
536    // ------------------------------------------------------------------
537
538    #[test]
539    fn test_export_cids_sorted() {
540        let mut m = StorageBlockManifest::new();
541        m.add_entry(make_entry("cidZ", 100, "raw", 1000, false));
542        m.add_entry(make_entry("cidA", 200, "raw", 2000, false));
543        m.add_entry(make_entry("cidM", 300, "raw", 3000, false));
544        let cids = m.export_cids();
545        assert_eq!(cids, vec!["cidA", "cidM", "cidZ"]);
546    }
547
548    // ------------------------------------------------------------------
549    // verify_checksums
550    // ------------------------------------------------------------------
551
552    #[test]
553    fn test_verify_checksums_correct_entries_pass() {
554        let mut m = StorageBlockManifest::new();
555        m.add_entry(make_entry("cid1", 100, "raw", 1000, false));
556        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
557        assert!(m.verify_checksums().is_empty());
558    }
559
560    #[test]
561    fn test_verify_checksums_detects_mismatch() {
562        let mut m = StorageBlockManifest::new();
563        let mut bad = make_entry("cid1", 100, "raw", 1000, false);
564        bad.checksum = bad.checksum.wrapping_add(1); // corrupt
565        m.add_entry(bad);
566        m.add_entry(make_entry("cid2", 200, "raw", 2000, false));
567        let mismatches = m.verify_checksums();
568        assert_eq!(mismatches, vec!["cid1"]);
569    }
570
571    // ------------------------------------------------------------------
572    // fnv1a helper
573    // ------------------------------------------------------------------
574
575    #[test]
576    fn test_fnv1a_empty_input() {
577        // FNV-1a of empty input is the offset basis.
578        assert_eq!(fnv1a(b""), 14_695_981_039_346_656_037);
579    }
580
581    #[test]
582    fn test_fnv1a_deterministic() {
583        let h1 = fnv1a(b"hello");
584        let h2 = fnv1a(b"hello");
585        assert_eq!(h1, h2);
586    }
587
588    #[test]
589    fn test_fnv1a_different_inputs() {
590        assert_ne!(fnv1a(b"foo"), fnv1a(b"bar"));
591    }
592}