Skip to main content

ipfrs_storage/
block_packer.rs

1//! Storage Block Packer — packs multiple small blocks into larger "pack files"
2//! to reduce storage overhead and improve sequential read performance,
3//! similar to Git's packfile format.
4
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// FNV-1a 64-bit hash
9// ---------------------------------------------------------------------------
10
11/// Computes the FNV-1a 64-bit hash of `bytes`.
12pub fn fnv1a(bytes: &[u8]) -> u64 {
13    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
14    const PRIME: u64 = 1_099_511_628_211;
15
16    let mut hash = OFFSET_BASIS;
17    for &b in bytes {
18        hash ^= u64::from(b);
19        hash = hash.wrapping_mul(PRIME);
20    }
21    hash
22}
23
24// ---------------------------------------------------------------------------
25// PackEntry
26// ---------------------------------------------------------------------------
27
28/// A single block entry within a pack file.
29#[derive(Debug, Clone, PartialEq)]
30pub struct PackEntry {
31    /// Content identifier for this block.
32    pub cid: String,
33    /// Byte offset of this block within the pack file.
34    pub offset: u64,
35    /// Size of this block in bytes.
36    pub size_bytes: u64,
37    /// FNV-1a 64-bit hash of the CID bytes, for integrity verification.
38    pub checksum: u64,
39}
40
41// ---------------------------------------------------------------------------
42// Pack
43// ---------------------------------------------------------------------------
44
45/// A single pack file that contains multiple block entries stored sequentially.
46#[derive(Debug, Clone)]
47pub struct Pack {
48    /// Unique identifier for this pack.
49    pub pack_id: u64,
50    /// Entries within this pack, sorted by offset ascending.
51    pub entries: Vec<PackEntry>,
52    /// Sum of all entry sizes in bytes (not including header overhead).
53    pub total_size_bytes: u64,
54    /// Unix timestamp (seconds) when this pack was created.
55    pub created_at_secs: u64,
56}
57
58impl Pack {
59    /// Returns the number of entries in this pack.
60    pub fn entry_count(&self) -> usize {
61        self.entries.len()
62    }
63
64    /// Returns `true` if this pack contains a block with the given CID.
65    pub fn contains(&self, cid: &str) -> bool {
66        self.entries.iter().any(|e| e.cid == cid)
67    }
68
69    /// Returns a reference to the `PackEntry` for the given CID, or `None`.
70    pub fn find(&self, cid: &str) -> Option<&PackEntry> {
71        self.entries.iter().find(|e| e.cid == cid)
72    }
73}
74
75// ---------------------------------------------------------------------------
76// PackerConfig
77// ---------------------------------------------------------------------------
78
79/// Configuration for the `StorageBlockPacker`.
80#[derive(Debug, Clone)]
81pub struct PackerConfig {
82    /// Maximum total size (in bytes) of a single pack file. Default: 64 MiB.
83    pub max_pack_size_bytes: u64,
84    /// Blocks strictly smaller than this threshold are candidates for packing.
85    /// Default: 64 KiB.
86    pub min_block_size_bytes: u64,
87    /// Maximum number of entries per pack. Default: 1024.
88    pub max_entries_per_pack: usize,
89}
90
91impl Default for PackerConfig {
92    fn default() -> Self {
93        Self {
94            max_pack_size_bytes: 67_108_864, // 64 MiB
95            min_block_size_bytes: 65_536,    // 64 KiB
96            max_entries_per_pack: 1024,
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// PackerStats
103// ---------------------------------------------------------------------------
104
105/// Aggregate statistics over all packs managed by a `StorageBlockPacker`.
106#[derive(Debug, Clone, PartialEq)]
107pub struct PackerStats {
108    /// Total number of packs.
109    pub total_packs: usize,
110    /// Total number of entries across all packs.
111    pub total_entries: usize,
112    /// Total bytes packed across all packs.
113    pub total_packed_bytes: u64,
114    /// Average pack utilization: mean(pack.total_size_bytes / max_pack_size_bytes).
115    /// Returns 0.0 if there are no packs.
116    pub avg_pack_utilization: f64,
117}
118
119// ---------------------------------------------------------------------------
120// StorageBlockPacker
121// ---------------------------------------------------------------------------
122
123/// Packs multiple small blocks into larger pack files for efficient storage.
124pub struct StorageBlockPacker {
125    /// All packs, keyed by pack_id.
126    pub packs: HashMap<u64, Pack>,
127    /// The next pack_id to assign.
128    pub next_pack_id: u64,
129    /// Configuration controlling packing behavior.
130    pub config: PackerConfig,
131}
132
133impl StorageBlockPacker {
134    /// Creates a new `StorageBlockPacker` with the given configuration.
135    pub fn new(config: PackerConfig) -> Self {
136        Self {
137            packs: HashMap::new(),
138            next_pack_id: 1,
139            config,
140        }
141    }
142
143    /// Packs a list of `(cid, size_bytes)` blocks into one or more pack files.
144    ///
145    /// Only blocks where `size_bytes < min_block_size_bytes` are eligible.
146    /// Blocks are greedily packed: a new pack is started whenever the current
147    /// pack would exceed `max_pack_size_bytes` or `max_entries_per_pack`.
148    ///
149    /// Returns the list of newly created pack IDs (empty if no eligible blocks).
150    pub fn pack(&mut self, blocks: Vec<(String, u64)>, now_secs: u64) -> Vec<u64> {
151        // Filter to only eligible (small) blocks
152        let eligible: Vec<(String, u64)> = blocks
153            .into_iter()
154            .filter(|(_, size)| *size < self.config.min_block_size_bytes)
155            .collect();
156
157        if eligible.is_empty() {
158            return Vec::new();
159        }
160
161        let mut created_ids: Vec<u64> = Vec::new();
162
163        // State for the current pack being built
164        let mut current_entries: Vec<PackEntry> = Vec::new();
165        let mut current_size: u64 = 0;
166        let mut current_offset: u64 = 0;
167
168        for (cid, size_bytes) in eligible {
169            // Determine if adding this block would exceed limits
170            let would_exceed_size = current_size + size_bytes > self.config.max_pack_size_bytes;
171            let would_exceed_entries = current_entries.len() >= self.config.max_entries_per_pack;
172
173            if !current_entries.is_empty() && (would_exceed_size || would_exceed_entries) {
174                // Flush the current pack
175                let pack_id = self.next_pack_id;
176                self.next_pack_id += 1;
177
178                let pack = Pack {
179                    pack_id,
180                    total_size_bytes: current_size,
181                    entries: current_entries,
182                    created_at_secs: now_secs,
183                };
184                self.packs.insert(pack_id, pack);
185                created_ids.push(pack_id);
186
187                // Reset for the next pack
188                current_entries = Vec::new();
189                current_size = 0;
190                current_offset = 0;
191            }
192
193            let checksum = fnv1a(cid.as_bytes());
194            let entry = PackEntry {
195                cid,
196                offset: current_offset,
197                size_bytes,
198                checksum,
199            };
200            current_offset += size_bytes;
201            current_size += size_bytes;
202            current_entries.push(entry);
203        }
204
205        // Flush any remaining entries
206        if !current_entries.is_empty() {
207            let pack_id = self.next_pack_id;
208            self.next_pack_id += 1;
209
210            let pack = Pack {
211                pack_id,
212                total_size_bytes: current_size,
213                entries: current_entries,
214                created_at_secs: now_secs,
215            };
216            self.packs.insert(pack_id, pack);
217            created_ids.push(pack_id);
218        }
219
220        created_ids
221    }
222
223    /// Searches all packs for the given CID.
224    ///
225    /// Returns `Some((&Pack, &PackEntry))` if found, `None` otherwise.
226    pub fn find_pack(&self, cid: &str) -> Option<(&Pack, &PackEntry)> {
227        for pack in self.packs.values() {
228            if let Some(entry) = pack.find(cid) {
229                return Some((pack, entry));
230            }
231        }
232        None
233    }
234
235    /// Returns a reference to the pack with the given ID, or `None`.
236    pub fn get_pack(&self, pack_id: u64) -> Option<&Pack> {
237        self.packs.get(&pack_id)
238    }
239
240    /// Removes the pack with the given ID.
241    ///
242    /// Returns `true` if the pack existed and was removed, `false` otherwise.
243    pub fn delete_pack(&mut self, pack_id: u64) -> bool {
244        self.packs.remove(&pack_id).is_some()
245    }
246
247    /// Computes aggregate statistics over all packs.
248    pub fn stats(&self) -> PackerStats {
249        let total_packs = self.packs.len();
250        let total_entries = self.packs.values().map(|p| p.entry_count()).sum();
251        let total_packed_bytes = self.packs.values().map(|p| p.total_size_bytes).sum();
252
253        let avg_pack_utilization = if total_packs == 0 {
254            0.0
255        } else {
256            let sum: f64 = self
257                .packs
258                .values()
259                .map(|p| p.total_size_bytes as f64 / self.config.max_pack_size_bytes as f64)
260                .sum();
261            sum / total_packs as f64
262        };
263
264        PackerStats {
265            total_packs,
266            total_entries,
267            total_packed_bytes,
268            avg_pack_utilization,
269        }
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Tests
275// ---------------------------------------------------------------------------
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn default_packer() -> StorageBlockPacker {
282        StorageBlockPacker::new(PackerConfig::default())
283    }
284
285    fn small_block(cid: &str, kb: u64) -> (String, u64) {
286        (cid.to_string(), kb * 1024)
287    }
288
289    fn large_block(cid: &str) -> (String, u64) {
290        // Blocks >= 64 KiB should be filtered out (default min_block_size_bytes = 65536)
291        (cid.to_string(), 65_536)
292    }
293
294    // 1. new() starts empty
295    #[test]
296    fn test_new_starts_empty() {
297        let packer = default_packer();
298        assert!(packer.packs.is_empty());
299        assert_eq!(packer.next_pack_id, 1);
300    }
301
302    // 2. pack returns empty for no eligible blocks
303    #[test]
304    fn test_pack_empty_input_returns_empty() {
305        let mut packer = default_packer();
306        let ids = packer.pack(vec![], 100);
307        assert!(ids.is_empty());
308    }
309
310    // 3. pack filters blocks >= min_block_size_bytes
311    #[test]
312    fn test_pack_filters_large_blocks() {
313        let mut packer = default_packer();
314        let blocks = vec![large_block("cid-large")];
315        let ids = packer.pack(blocks, 100);
316        assert!(ids.is_empty(), "large block should be filtered out");
317        assert!(packer.packs.is_empty());
318    }
319
320    // 4. pack creates single pack for small set
321    #[test]
322    fn test_pack_creates_single_pack() {
323        let mut packer = default_packer();
324        let blocks = vec![small_block("cid-a", 1), small_block("cid-b", 2)];
325        let ids = packer.pack(blocks, 100);
326        assert_eq!(ids.len(), 1);
327        assert_eq!(packer.packs.len(), 1);
328    }
329
330    // 5. pack creates multiple packs when size exceeded
331    #[test]
332    fn test_pack_creates_multiple_packs_on_size_overflow() {
333        let config = PackerConfig {
334            max_pack_size_bytes: 10_000,
335            min_block_size_bytes: 65_536,
336            max_entries_per_pack: 1024,
337        };
338        let mut packer = StorageBlockPacker::new(config);
339        // Each block is 6000 bytes; two blocks exceed 10000 bytes
340        let blocks = vec![
341            ("cid-a".to_string(), 6000u64),
342            ("cid-b".to_string(), 6000u64),
343            ("cid-c".to_string(), 6000u64),
344        ];
345        let ids = packer.pack(blocks, 200);
346        // First pack: cid-a (6000). cid-b would make 12000 > 10000, so flush.
347        // Second pack: cid-b (6000). cid-c would make 12000 > 10000, so flush.
348        // Third pack: cid-c (6000).
349        assert_eq!(ids.len(), 3);
350        assert_eq!(packer.packs.len(), 3);
351    }
352
353    // 6. pack creates new pack when max_entries reached
354    #[test]
355    fn test_pack_creates_new_pack_on_max_entries() {
356        let config = PackerConfig {
357            max_pack_size_bytes: 67_108_864,
358            min_block_size_bytes: 65_536,
359            max_entries_per_pack: 2,
360        };
361        let mut packer = StorageBlockPacker::new(config);
362        let blocks = vec![
363            ("cid-1".to_string(), 100u64),
364            ("cid-2".to_string(), 100u64),
365            ("cid-3".to_string(), 100u64),
366        ];
367        let ids = packer.pack(blocks, 300);
368        // Pack 1: cid-1, cid-2 (at max_entries). cid-3 triggers new pack.
369        // Pack 2: cid-3
370        assert_eq!(ids.len(), 2);
371        let pack1 = packer.get_pack(ids[0]).expect("pack 1 should exist");
372        assert_eq!(pack1.entry_count(), 2);
373        let pack2 = packer.get_pack(ids[1]).expect("pack 2 should exist");
374        assert_eq!(pack2.entry_count(), 1);
375    }
376
377    // 7. PackEntry offset computed correctly
378    #[test]
379    fn test_pack_entry_offsets() {
380        let mut packer = default_packer();
381        let blocks = vec![
382            ("cid-x".to_string(), 100u64),
383            ("cid-y".to_string(), 250u64),
384            ("cid-z".to_string(), 50u64),
385        ];
386        let ids = packer.pack(blocks, 100);
387        assert_eq!(ids.len(), 1);
388        let pack = packer.get_pack(ids[0]).expect("pack should exist");
389
390        let x = pack.find("cid-x").expect("cid-x not found");
391        assert_eq!(x.offset, 0);
392
393        let y = pack.find("cid-y").expect("cid-y not found");
394        assert_eq!(y.offset, 100);
395
396        let z = pack.find("cid-z").expect("cid-z not found");
397        assert_eq!(z.offset, 350);
398    }
399
400    // 8. PackEntry checksum = fnv1a(cid)
401    #[test]
402    fn test_pack_entry_checksum() {
403        let mut packer = default_packer();
404        let cid = "QmTestChecksum";
405        let blocks = vec![(cid.to_string(), 500u64)];
406        let ids = packer.pack(blocks, 100);
407        let pack = packer.get_pack(ids[0]).expect("pack should exist");
408        let entry = pack.find(cid).expect("entry not found");
409        assert_eq!(entry.checksum, fnv1a(cid.as_bytes()));
410    }
411
412    // 9. Pack.contains correct
413    #[test]
414    fn test_pack_contains() {
415        let mut packer = default_packer();
416        let blocks = vec![small_block("cid-p", 1), small_block("cid-q", 2)];
417        let ids = packer.pack(blocks, 100);
418        let pack = packer.get_pack(ids[0]).expect("pack should exist");
419        assert!(pack.contains("cid-p"));
420        assert!(pack.contains("cid-q"));
421        assert!(!pack.contains("cid-missing"));
422    }
423
424    // 10. Pack.find returns correct entry
425    #[test]
426    fn test_pack_find_returns_correct_entry() {
427        let mut packer = default_packer();
428        let blocks = vec![("cid-find".to_string(), 1234u64)];
429        let ids = packer.pack(blocks, 100);
430        let pack = packer.get_pack(ids[0]).expect("pack should exist");
431        let entry = pack.find("cid-find").expect("entry not found");
432        assert_eq!(entry.cid, "cid-find");
433        assert_eq!(entry.size_bytes, 1234);
434        assert!(pack.find("nonexistent").is_none());
435    }
436
437    // 11. Pack.entry_count correct
438    #[test]
439    fn test_pack_entry_count() {
440        let mut packer = default_packer();
441        let blocks = vec![
442            small_block("c1", 1),
443            small_block("c2", 2),
444            small_block("c3", 3),
445        ];
446        let ids = packer.pack(blocks, 100);
447        let pack = packer.get_pack(ids[0]).expect("pack should exist");
448        assert_eq!(pack.entry_count(), 3);
449    }
450
451    // 12. find_pack searches across packs
452    #[test]
453    fn test_find_pack_searches_across_packs() {
454        let config = PackerConfig {
455            max_pack_size_bytes: 10_000,
456            min_block_size_bytes: 65_536,
457            max_entries_per_pack: 1024,
458        };
459        let mut packer = StorageBlockPacker::new(config);
460        // Force two packs
461        let blocks = vec![
462            ("cid-first".to_string(), 6000u64),
463            ("cid-second".to_string(), 6000u64),
464        ];
465        let ids = packer.pack(blocks, 100);
466        assert_eq!(ids.len(), 2);
467
468        let result = packer.find_pack("cid-first");
469        assert!(result.is_some());
470        let (_, entry) = result.expect("should find cid-first");
471        assert_eq!(entry.cid, "cid-first");
472
473        let result2 = packer.find_pack("cid-second");
474        assert!(result2.is_some());
475        let (_, entry2) = result2.expect("should find cid-second");
476        assert_eq!(entry2.cid, "cid-second");
477    }
478
479    // 13. find_pack returns None for unknown cid
480    #[test]
481    fn test_find_pack_returns_none_for_unknown() {
482        let mut packer = default_packer();
483        packer.pack(vec![small_block("known", 1)], 100);
484        assert!(packer.find_pack("unknown-cid").is_none());
485    }
486
487    // 14. get_pack Some/None
488    #[test]
489    fn test_get_pack_some_and_none() {
490        let mut packer = default_packer();
491        let ids = packer.pack(vec![small_block("cid-gp", 1)], 100);
492        assert!(packer.get_pack(ids[0]).is_some());
493        assert!(packer.get_pack(9999).is_none());
494    }
495
496    // 15. delete_pack true/false
497    #[test]
498    fn test_delete_pack_true_false() {
499        let mut packer = default_packer();
500        let ids = packer.pack(vec![small_block("cid-del", 1)], 100);
501        let pack_id = ids[0];
502        assert!(
503            packer.delete_pack(pack_id),
504            "should return true when pack exists"
505        );
506        assert!(
507            !packer.delete_pack(pack_id),
508            "should return false when already deleted"
509        );
510        assert!(packer.get_pack(pack_id).is_none());
511    }
512
513    // 16. stats total_packs correct
514    #[test]
515    fn test_stats_total_packs() {
516        let config = PackerConfig {
517            max_pack_size_bytes: 10_000,
518            min_block_size_bytes: 65_536,
519            max_entries_per_pack: 1024,
520        };
521        let mut packer = StorageBlockPacker::new(config);
522        packer.pack(
523            vec![("c1".to_string(), 6000u64), ("c2".to_string(), 6000u64)],
524            100,
525        );
526        let stats = packer.stats();
527        assert_eq!(stats.total_packs, 2);
528    }
529
530    // 17. stats total_entries correct
531    #[test]
532    fn test_stats_total_entries() {
533        let mut packer = default_packer();
534        packer.pack(
535            vec![
536                small_block("e1", 1),
537                small_block("e2", 2),
538                small_block("e3", 3),
539            ],
540            100,
541        );
542        let stats = packer.stats();
543        assert_eq!(stats.total_entries, 3);
544    }
545
546    // 18. stats total_packed_bytes correct
547    #[test]
548    fn test_stats_total_packed_bytes() {
549        let mut packer = default_packer();
550        packer.pack(
551            vec![
552                ("b1".to_string(), 1000u64),
553                ("b2".to_string(), 2000u64),
554                ("b3".to_string(), 3000u64),
555            ],
556            100,
557        );
558        let stats = packer.stats();
559        assert_eq!(stats.total_packed_bytes, 6000);
560    }
561
562    // 19. stats avg_pack_utilization computed
563    #[test]
564    fn test_stats_avg_pack_utilization() {
565        let config = PackerConfig {
566            max_pack_size_bytes: 10_000,
567            min_block_size_bytes: 65_536,
568            max_entries_per_pack: 1024,
569        };
570        let mut packer = StorageBlockPacker::new(config);
571        // Force a single pack with 5000 bytes → utilization = 0.5
572        packer.pack(vec![("u1".to_string(), 5000u64)], 100);
573        let stats = packer.stats();
574        let expected = 5000.0_f64 / 10_000.0_f64;
575        assert!(
576            (stats.avg_pack_utilization - expected).abs() < 1e-10,
577            "expected {expected}, got {}",
578            stats.avg_pack_utilization
579        );
580    }
581
582    // 20. stats avg_pack_utilization is 0.0 when no packs
583    #[test]
584    fn test_stats_avg_utilization_empty() {
585        let packer = default_packer();
586        let stats = packer.stats();
587        assert_eq!(stats.avg_pack_utilization, 0.0);
588    }
589
590    // 21. pack_id monotonically increasing
591    #[test]
592    fn test_pack_id_monotonically_increasing() {
593        let config = PackerConfig {
594            max_pack_size_bytes: 10_000,
595            min_block_size_bytes: 65_536,
596            max_entries_per_pack: 1024,
597        };
598        let mut packer = StorageBlockPacker::new(config);
599        let ids = packer.pack(
600            vec![
601                ("m1".to_string(), 6000u64),
602                ("m2".to_string(), 6000u64),
603                ("m3".to_string(), 6000u64),
604            ],
605            100,
606        );
607        assert_eq!(ids.len(), 3);
608        assert!(ids[0] < ids[1], "pack IDs must be monotonically increasing");
609        assert!(ids[1] < ids[2], "pack IDs must be monotonically increasing");
610    }
611
612    // 22. fnv1a produces deterministic results
613    #[test]
614    fn test_fnv1a_deterministic() {
615        let a = fnv1a(b"hello");
616        let b = fnv1a(b"hello");
617        assert_eq!(a, b);
618        let c = fnv1a(b"world");
619        assert_ne!(a, c);
620    }
621
622    // 23. Mixed eligible/ineligible blocks — only eligible packed
623    #[test]
624    fn test_pack_mixed_blocks() {
625        let mut packer = default_packer();
626        let blocks = vec![
627            ("small".to_string(), 1000u64),   // eligible (< 65536)
628            ("large".to_string(), 65_536u64), // ineligible (== min_block_size_bytes, not <)
629            ("tiny".to_string(), 512u64),     // eligible
630        ];
631        let ids = packer.pack(blocks, 100);
632        assert_eq!(ids.len(), 1);
633        let pack = packer.get_pack(ids[0]).expect("pack should exist");
634        assert_eq!(pack.entry_count(), 2);
635        assert!(pack.contains("small"));
636        assert!(pack.contains("tiny"));
637        assert!(!pack.contains("large"));
638    }
639
640    // 24. Pack created_at_secs matches provided timestamp
641    #[test]
642    fn test_pack_created_at_secs() {
643        let mut packer = default_packer();
644        let ts = 999_999_u64;
645        let ids = packer.pack(vec![small_block("time-cid", 1)], ts);
646        let pack = packer.get_pack(ids[0]).expect("pack should exist");
647        assert_eq!(pack.created_at_secs, ts);
648    }
649
650    // 25. entries in pack are sorted by offset ascending
651    #[test]
652    fn test_pack_entries_sorted_by_offset() {
653        let mut packer = default_packer();
654        let blocks = vec![
655            ("first".to_string(), 100u64),
656            ("second".to_string(), 200u64),
657            ("third".to_string(), 300u64),
658        ];
659        let ids = packer.pack(blocks, 100);
660        let pack = packer.get_pack(ids[0]).expect("pack should exist");
661        let offsets: Vec<u64> = pack.entries.iter().map(|e| e.offset).collect();
662        let mut sorted = offsets.clone();
663        sorted.sort_unstable();
664        assert_eq!(offsets, sorted, "entries should be sorted by offset");
665    }
666}