Skip to main content

ipfrs_storage/
block_compactor.rs

1//! Storage Block Compactor
2//!
3//! Merges small fragmented blocks into larger compacted segments for storage efficiency,
4//! tracking fragmentation metrics and producing compaction plans.
5
6use std::collections::HashMap;
7
8/// A small, fragmented block eligible for compaction.
9#[derive(Debug, Clone)]
10pub struct BlockFragment {
11    /// Unique block identifier.
12    pub block_id: u64,
13    /// Content identifier (CID) for this block.
14    pub cid: String,
15    /// Size of the block in bytes.
16    pub size_bytes: u64,
17    /// Logical tick when the block was created.
18    pub created_at_tick: u64,
19    /// Logical tick when the block was last accessed.
20    pub last_accessed_tick: u64,
21}
22
23/// A compacted segment consisting of multiple merged blocks.
24#[derive(Debug, Clone)]
25pub struct CompactionSegment {
26    /// Unique segment identifier.
27    pub segment_id: u64,
28    /// Constituent block IDs, sorted ascending.
29    pub block_ids: Vec<u64>,
30    /// Total bytes of all constituent blocks.
31    pub total_bytes: u64,
32    /// Ideal target size for this segment in bytes.
33    pub target_size_bytes: u64,
34}
35
36impl CompactionSegment {
37    /// Returns the ratio of total_bytes to target_size_bytes.
38    /// Returns 0.0 if target_size_bytes is 0.
39    pub fn fill_ratio(&self) -> f64 {
40        if self.target_size_bytes == 0 {
41            0.0
42        } else {
43            self.total_bytes as f64 / self.target_size_bytes as f64
44        }
45    }
46
47    /// Returns true if total_bytes >= target_size_bytes.
48    pub fn is_full(&self) -> bool {
49        self.total_bytes >= self.target_size_bytes
50    }
51}
52
53/// A plan describing which blocks to merge into which segments.
54#[derive(Debug, Clone)]
55pub struct CompactionPlan {
56    /// Segments to create.
57    pub segments: Vec<CompactionSegment>,
58    /// Total number of blocks assigned to segments.
59    pub blocks_compacted: usize,
60    /// Total bytes of all blocks assigned to segments.
61    pub bytes_compacted: u64,
62    /// Estimated overhead saved (64 bytes per block merged).
63    pub estimated_savings_bytes: u64,
64}
65
66impl CompactionPlan {
67    /// Returns the number of segments in this plan.
68    pub fn segment_count(&self) -> usize {
69        self.segments.len()
70    }
71}
72
73/// Configuration for the `StorageBlockCompactor`.
74#[derive(Debug, Clone)]
75pub struct CompactorConfig {
76    /// Ideal segment size in bytes (default: 4 MB).
77    pub target_segment_bytes: u64,
78    /// Only compact blocks strictly smaller than this threshold (default: 64 KB).
79    pub min_block_size_to_compact: u64,
80    /// Maximum number of blocks per compacted segment (default: 128).
81    pub max_blocks_per_segment: usize,
82}
83
84impl Default for CompactorConfig {
85    fn default() -> Self {
86        Self {
87            target_segment_bytes: 4_194_304,   // 4 MB
88            min_block_size_to_compact: 65_536, // 64 KB
89            max_blocks_per_segment: 128,
90        }
91    }
92}
93
94/// Accumulated statistics for the `StorageBlockCompactor`.
95#[derive(Debug, Clone, Default)]
96pub struct CompactorStats {
97    /// Number of compaction plans generated so far.
98    pub total_plans_generated: u64,
99    /// Total blocks that have been included in compaction plans.
100    pub total_blocks_compacted: u64,
101    /// Total bytes included in compaction plans.
102    pub total_bytes_compacted: u64,
103    /// Total segments created across all plans.
104    pub total_segments_created: u64,
105}
106
107/// Merges small fragmented blocks into larger compacted segments.
108pub struct StorageBlockCompactor {
109    /// Registered fragments keyed by block_id.
110    pub fragments: HashMap<u64, BlockFragment>,
111    /// Compactor configuration.
112    pub config: CompactorConfig,
113    /// Accumulated statistics.
114    pub stats: CompactorStats,
115    /// Counter for generating unique segment IDs.
116    pub next_segment_id: u64,
117}
118
119impl StorageBlockCompactor {
120    /// Creates a new `StorageBlockCompactor` with the given configuration.
121    pub fn new(config: CompactorConfig) -> Self {
122        Self {
123            fragments: HashMap::new(),
124            config,
125            stats: CompactorStats::default(),
126            next_segment_id: 0,
127        }
128    }
129
130    /// Registers a block as a compaction candidate.
131    ///
132    /// Only blocks with `size_bytes < min_block_size_to_compact` are registered;
133    /// larger blocks are silently skipped.
134    pub fn register_block(&mut self, block_id: u64, cid: String, size_bytes: u64, tick: u64) {
135        if size_bytes >= self.config.min_block_size_to_compact {
136            return;
137        }
138        let fragment = BlockFragment {
139            block_id,
140            cid,
141            size_bytes,
142            created_at_tick: tick,
143            last_accessed_tick: tick,
144        };
145        self.fragments.insert(block_id, fragment);
146    }
147
148    /// Updates the last_accessed_tick for a registered block.
149    ///
150    /// Returns `true` if the block was found and updated, `false` otherwise.
151    pub fn touch(&mut self, block_id: u64, tick: u64) -> bool {
152        match self.fragments.get_mut(&block_id) {
153            Some(fragment) => {
154                fragment.last_accessed_tick = tick;
155                true
156            }
157            None => false,
158        }
159    }
160
161    /// Produces a compaction plan by greedily grouping fragments into segments.
162    ///
163    /// Fragments are sorted by size (ascending), then by block_id (ascending) for stable ordering.
164    /// Segments with fewer than 2 blocks are excluded from the plan.
165    pub fn plan_compaction(&mut self) -> CompactionPlan {
166        // Collect and sort fragments: by size ascending, then block_id ascending for ties.
167        let mut sorted: Vec<BlockFragment> = self.fragments.values().cloned().collect();
168        sorted.sort_by(|a, b| {
169            a.size_bytes
170                .cmp(&b.size_bytes)
171                .then_with(|| a.block_id.cmp(&b.block_id))
172        });
173
174        let target = self.config.target_segment_bytes;
175        let max_per_seg = self.config.max_blocks_per_segment;
176
177        let mut segments: Vec<CompactionSegment> = Vec::new();
178
179        let mut idx = 0;
180        while idx < sorted.len() {
181            let mut seg_block_ids: Vec<u64> = Vec::new();
182            let mut seg_bytes: u64 = 0;
183
184            // Fill one segment.
185            while idx < sorted.len() && seg_bytes < target && seg_block_ids.len() < max_per_seg {
186                let frag = &sorted[idx];
187                seg_block_ids.push(frag.block_id);
188                seg_bytes += frag.size_bytes;
189                idx += 1;
190            }
191
192            // Only keep segments with at least 2 blocks.
193            if seg_block_ids.len() < 2 {
194                continue;
195            }
196
197            seg_block_ids.sort_unstable();
198
199            let seg = CompactionSegment {
200                segment_id: self.next_segment_id,
201                block_ids: seg_block_ids,
202                total_bytes: seg_bytes,
203                target_size_bytes: target,
204            };
205            self.next_segment_id += 1;
206            segments.push(seg);
207        }
208
209        let blocks_compacted: usize = segments.iter().map(|s| s.block_ids.len()).sum();
210        let bytes_compacted: u64 = segments.iter().map(|s| s.total_bytes).sum();
211        let estimated_savings_bytes = 64 * blocks_compacted as u64;
212
213        // Update accumulated statistics.
214        self.stats.total_plans_generated += 1;
215        self.stats.total_blocks_compacted += blocks_compacted as u64;
216        self.stats.total_bytes_compacted += bytes_compacted;
217        self.stats.total_segments_created += segments.len() as u64;
218
219        CompactionPlan {
220            segments,
221            blocks_compacted,
222            bytes_compacted,
223            estimated_savings_bytes,
224        }
225    }
226
227    /// Removes a registered block fragment.
228    ///
229    /// Returns `true` if the block was present and removed, `false` otherwise.
230    pub fn remove_block(&mut self, block_id: u64) -> bool {
231        self.fragments.remove(&block_id).is_some()
232    }
233
234    /// Returns the fragmentation ratio: blocks smaller than `target_segment_bytes / 2`
235    /// divided by total registered blocks.
236    ///
237    /// Returns `0.0` if no fragments are registered.
238    pub fn fragmentation_ratio(&self) -> f64 {
239        let total = self.fragments.len();
240        if total == 0 {
241            return 0.0;
242        }
243        let threshold = self.config.target_segment_bytes / 2;
244        let small_count = self
245            .fragments
246            .values()
247            .filter(|f| f.size_bytes < threshold)
248            .count();
249        small_count as f64 / total as f64
250    }
251
252    /// Returns a reference to the accumulated statistics.
253    pub fn stats(&self) -> &CompactorStats {
254        &self.stats
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn default_compactor() -> StorageBlockCompactor {
263        StorageBlockCompactor::new(CompactorConfig::default())
264    }
265
266    // ── register_block ───────────────────────────────────────────────────────
267
268    #[test]
269    fn test_register_small_block_accepted() {
270        let mut c = default_compactor();
271        c.register_block(1, "cid1".into(), 1024, 10);
272        assert_eq!(c.fragments.len(), 1);
273    }
274
275    #[test]
276    fn test_register_block_at_boundary_excluded() {
277        let mut c = default_compactor();
278        // size == min_block_size_to_compact → excluded
279        c.register_block(1, "cid1".into(), 65_536, 10);
280        assert!(c.fragments.is_empty());
281    }
282
283    #[test]
284    fn test_register_large_block_not_registered() {
285        let mut c = default_compactor();
286        c.register_block(99, "cid99".into(), 1_000_000, 5);
287        assert!(c.fragments.is_empty());
288    }
289
290    #[test]
291    fn test_register_block_stores_fields() {
292        let mut c = default_compactor();
293        c.register_block(42, "bafycid".into(), 512, 100);
294        let frag = c.fragments.get(&42).expect("fragment should exist");
295        assert_eq!(frag.block_id, 42);
296        assert_eq!(frag.cid, "bafycid");
297        assert_eq!(frag.size_bytes, 512);
298        assert_eq!(frag.created_at_tick, 100);
299        assert_eq!(frag.last_accessed_tick, 100);
300    }
301
302    #[test]
303    fn test_register_multiple_small_blocks() {
304        let mut c = default_compactor();
305        for i in 0..10_u64 {
306            c.register_block(i, format!("cid{i}"), 1024 * (i + 1), i);
307        }
308        assert_eq!(c.fragments.len(), 10);
309    }
310
311    // ── touch ────────────────────────────────────────────────────────────────
312
313    #[test]
314    fn test_touch_updates_last_accessed_tick() {
315        let mut c = default_compactor();
316        c.register_block(1, "cid1".into(), 512, 5);
317        let updated = c.touch(1, 99);
318        assert!(updated);
319        let frag = c.fragments.get(&1).expect("fragment should exist");
320        assert_eq!(frag.last_accessed_tick, 99);
321        assert_eq!(frag.created_at_tick, 5); // created_at unchanged
322    }
323
324    #[test]
325    fn test_touch_returns_false_for_missing_block() {
326        let mut c = default_compactor();
327        assert!(!c.touch(999, 10));
328    }
329
330    #[test]
331    fn test_touch_does_not_change_created_at() {
332        let mut c = default_compactor();
333        c.register_block(7, "cid7".into(), 256, 1);
334        c.touch(7, 500);
335        let frag = c.fragments.get(&7).unwrap();
336        assert_eq!(frag.created_at_tick, 1);
337    }
338
339    // ── remove_block ─────────────────────────────────────────────────────────
340
341    #[test]
342    fn test_remove_existing_block_returns_true() {
343        let mut c = default_compactor();
344        c.register_block(5, "cid5".into(), 1000, 0);
345        assert!(c.remove_block(5));
346        assert!(c.fragments.is_empty());
347    }
348
349    #[test]
350    fn test_remove_missing_block_returns_false() {
351        let mut c = default_compactor();
352        assert!(!c.remove_block(404));
353    }
354
355    // ── CompactionSegment helpers ─────────────────────────────────────────────
356
357    #[test]
358    fn test_fill_ratio_normal() {
359        let seg = CompactionSegment {
360            segment_id: 0,
361            block_ids: vec![1, 2],
362            total_bytes: 2_097_152,
363            target_size_bytes: 4_194_304,
364        };
365        let ratio = seg.fill_ratio();
366        assert!((ratio - 0.5).abs() < 1e-9);
367    }
368
369    #[test]
370    fn test_fill_ratio_zero_target() {
371        let seg = CompactionSegment {
372            segment_id: 0,
373            block_ids: vec![1],
374            total_bytes: 1024,
375            target_size_bytes: 0,
376        };
377        assert_eq!(seg.fill_ratio(), 0.0);
378    }
379
380    #[test]
381    fn test_is_full_true() {
382        let seg = CompactionSegment {
383            segment_id: 0,
384            block_ids: vec![1, 2],
385            total_bytes: 4_194_304,
386            target_size_bytes: 4_194_304,
387        };
388        assert!(seg.is_full());
389    }
390
391    #[test]
392    fn test_is_full_false() {
393        let seg = CompactionSegment {
394            segment_id: 0,
395            block_ids: vec![1, 2],
396            total_bytes: 1024,
397            target_size_bytes: 4_194_304,
398        };
399        assert!(!seg.is_full());
400    }
401
402    // ── plan_compaction ───────────────────────────────────────────────────────
403
404    #[test]
405    fn test_plan_groups_small_blocks_into_segment() {
406        let mut c = default_compactor();
407        for i in 0..5_u64 {
408            c.register_block(i, format!("cid{i}"), 8_192, i);
409        }
410        let plan = c.plan_compaction();
411        assert!(!plan.segments.is_empty());
412        assert_eq!(plan.blocks_compacted, 5);
413    }
414
415    #[test]
416    fn test_plan_excludes_singleton_segment() {
417        // Only one block registered — can't form a segment of >= 2.
418        let mut c = default_compactor();
419        c.register_block(1, "cid1".into(), 1024, 0);
420        let plan = c.plan_compaction();
421        assert_eq!(plan.segment_count(), 0);
422        assert_eq!(plan.blocks_compacted, 0);
423        assert_eq!(plan.bytes_compacted, 0);
424        assert_eq!(plan.estimated_savings_bytes, 0);
425    }
426
427    #[test]
428    fn test_plan_blocks_compacted_total() {
429        let mut c = default_compactor();
430        for i in 0..10_u64 {
431            c.register_block(i, format!("cid{i}"), 4096, i);
432        }
433        let plan = c.plan_compaction();
434        assert_eq!(plan.blocks_compacted, 10);
435    }
436
437    #[test]
438    fn test_plan_bytes_compacted_total() {
439        let mut c = default_compactor();
440        for i in 0..4_u64 {
441            c.register_block(i, format!("cid{i}"), 1000, 0);
442        }
443        let plan = c.plan_compaction();
444        assert_eq!(plan.bytes_compacted, 4000);
445    }
446
447    #[test]
448    fn test_estimated_savings_bytes() {
449        let mut c = default_compactor();
450        for i in 0..6_u64 {
451            c.register_block(i, format!("cid{i}"), 512, 0);
452        }
453        let plan = c.plan_compaction();
454        assert_eq!(
455            plan.estimated_savings_bytes,
456            64 * plan.blocks_compacted as u64
457        );
458    }
459
460    #[test]
461    fn test_segment_count() {
462        let mut c = default_compactor();
463        // Fill more than one segment worth of blocks (target 4 MB, each block 1 MB-ish).
464        // Use 65_535 bytes per block (just under the 64 KB threshold).
465        // 4_194_304 / 65_535 ≈ 64 blocks to fill one segment.
466        for i in 0..130_u64 {
467            c.register_block(i, format!("cid{i}"), 65_535, i);
468        }
469        let plan = c.plan_compaction();
470        assert!(plan.segment_count() >= 2);
471    }
472
473    #[test]
474    fn test_max_blocks_per_segment_cap() {
475        let config = CompactorConfig {
476            max_blocks_per_segment: 4,
477            target_segment_bytes: 4_194_304,
478            ..CompactorConfig::default()
479        };
480        let mut c = StorageBlockCompactor::new(config);
481        for i in 0..10_u64 {
482            c.register_block(i, format!("cid{i}"), 256, 0);
483        }
484        let plan = c.plan_compaction();
485        for seg in &plan.segments {
486            assert!(seg.block_ids.len() <= 4);
487        }
488    }
489
490    #[test]
491    fn test_segment_block_ids_sorted_ascending() {
492        let mut c = default_compactor();
493        // Insert in reverse order to ensure sorting happens.
494        for i in (0_u64..5).rev() {
495            c.register_block(i, format!("cid{i}"), 1024, 0);
496        }
497        let plan = c.plan_compaction();
498        for seg in &plan.segments {
499            let sorted = {
500                let mut ids = seg.block_ids.clone();
501                ids.sort_unstable();
502                ids
503            };
504            assert_eq!(seg.block_ids, sorted);
505        }
506    }
507
508    #[test]
509    fn test_plan_with_no_fragments_returns_empty_plan() {
510        let mut c = default_compactor();
511        let plan = c.plan_compaction();
512        assert_eq!(plan.segment_count(), 0);
513        assert_eq!(plan.blocks_compacted, 0);
514        assert_eq!(plan.bytes_compacted, 0);
515        assert_eq!(plan.estimated_savings_bytes, 0);
516    }
517
518    // ── fragmentation_ratio ───────────────────────────────────────────────────
519
520    #[test]
521    fn test_fragmentation_ratio_no_fragments() {
522        let c = default_compactor();
523        assert_eq!(c.fragmentation_ratio(), 0.0);
524    }
525
526    #[test]
527    fn test_fragmentation_ratio_all_small() {
528        let mut c = default_compactor();
529        // target/2 = 2_097_152; register blocks well below that.
530        for i in 0..5_u64 {
531            c.register_block(i, format!("cid{i}"), 1024, 0);
532        }
533        let ratio = c.fragmentation_ratio();
534        assert!((ratio - 1.0).abs() < 1e-9);
535    }
536
537    #[test]
538    fn test_fragmentation_ratio_mixed() {
539        let config = CompactorConfig {
540            target_segment_bytes: 4_000,
541            min_block_size_to_compact: 10_000,
542            max_blocks_per_segment: 128,
543        };
544        let mut c = StorageBlockCompactor::new(config);
545        // threshold = 4000 / 2 = 2000
546        // 3 blocks below 2000, 2 blocks at or above 2000 but below 10000
547        c.register_block(1, "a".into(), 500, 0); // < 2000
548        c.register_block(2, "b".into(), 1000, 0); // < 2000
549        c.register_block(3, "c".into(), 1500, 0); // < 2000
550        c.register_block(4, "d".into(), 2000, 0); // >= 2000
551        c.register_block(5, "e".into(), 3000, 0); // >= 2000
552        let ratio = c.fragmentation_ratio();
553        assert!((ratio - 3.0 / 5.0).abs() < 1e-9);
554    }
555
556    // ── stats accumulation ────────────────────────────────────────────────────
557
558    #[test]
559    fn test_stats_accumulate_across_plans() {
560        let mut c = default_compactor();
561        for i in 0..4_u64 {
562            c.register_block(i, format!("cid{i}"), 1024, 0);
563        }
564        c.plan_compaction();
565        c.plan_compaction(); // second plan on same fragments
566
567        let s = c.stats();
568        assert_eq!(s.total_plans_generated, 2);
569        // Each plan covers 4 blocks (fragments are not removed by planning).
570        assert_eq!(s.total_blocks_compacted, 8);
571    }
572
573    #[test]
574    fn test_stats_total_segments_created() {
575        let mut c = default_compactor();
576        for i in 0..4_u64 {
577            c.register_block(i, format!("cid{i}"), 512, 0);
578        }
579        let plan = c.plan_compaction();
580        let seg_count = plan.segment_count() as u64;
581        assert_eq!(c.stats().total_segments_created, seg_count);
582    }
583
584    #[test]
585    fn test_stats_bytes_compacted_accumulate() {
586        let mut c = default_compactor();
587        for i in 0..4_u64 {
588            c.register_block(i, format!("cid{i}"), 1000, 0);
589        }
590        c.plan_compaction(); // 4000 bytes
591        c.plan_compaction(); // 4000 bytes again
592
593        assert_eq!(c.stats().total_bytes_compacted, 8000);
594    }
595
596    #[test]
597    fn test_stats_initial_zero() {
598        let c = default_compactor();
599        let s = c.stats();
600        assert_eq!(s.total_plans_generated, 0);
601        assert_eq!(s.total_blocks_compacted, 0);
602        assert_eq!(s.total_bytes_compacted, 0);
603        assert_eq!(s.total_segments_created, 0);
604    }
605}