Skip to main content

ipfrs_storage/
fragmentation_analyzer.rs

1//! Storage Fragmentation Analyzer
2//!
3//! Analyzes storage layout to detect fragmentation, recommend defragmentation
4//! targets, and estimate compaction savings.
5
6/// Represents a contiguous region of storage — either occupied by a block or free.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct StorageExtent {
9    /// Byte offset at which this extent begins.
10    pub start_offset: u64,
11    /// Length of this extent in bytes.
12    pub length_bytes: u64,
13    /// CID of the block stored here, or `None` if this is free space.
14    pub cid: Option<String>,
15}
16
17impl StorageExtent {
18    /// Returns `true` when this extent is unoccupied (free space).
19    #[inline]
20    pub fn is_free(&self) -> bool {
21        self.cid.is_none()
22    }
23
24    /// Returns the exclusive end offset of this extent.
25    #[inline]
26    pub fn end_offset(&self) -> u64 {
27        self.start_offset + self.length_bytes
28    }
29}
30
31/// Summary statistics produced by [`StorageFragmentationAnalyzer::analyze`].
32#[derive(Debug, Clone)]
33pub struct FragmentationReport {
34    /// Total addressable bytes (used + free).
35    pub total_bytes: u64,
36    /// Bytes occupied by live blocks.
37    pub used_bytes: u64,
38    /// Bytes that are currently unoccupied.
39    pub free_bytes: u64,
40    /// Number of distinct free extents.
41    pub free_extent_count: usize,
42    /// Size (in bytes) of the single largest free extent.
43    pub largest_free_extent: u64,
44    /// Fragmentation score in [0.0, 1.0].
45    ///
46    /// `0.0` means all free space is in one contiguous run (perfect).
47    /// `1.0` means every byte of free space is isolated (totally fragmented).
48    ///
49    /// Formula: `1.0 - (largest_free_extent / free_bytes)` when `free_bytes > 0`, else `0.0`.
50    pub fragmentation_score: f64,
51}
52
53impl FragmentationReport {
54    /// Fraction of total storage that is in use: `used_bytes / total_bytes.max(1)`.
55    pub fn utilization(&self) -> f64 {
56        self.used_bytes as f64 / self.total_bytes.max(1) as f64
57    }
58}
59
60/// A single block that would move during a compaction pass.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CompactionCandidate {
63    /// CID of the block to move.
64    pub cid: String,
65    /// Where the block lives today.
66    pub current_offset: u64,
67    /// Where the block would land after left-packing.
68    pub target_offset: u64,
69    /// Size of the block in bytes.
70    pub size_bytes: u64,
71}
72
73impl CompactionCandidate {
74    /// Bytes of gap eliminated by moving this block.
75    ///
76    /// Equivalent to `current_offset - target_offset`; saturating to avoid
77    /// underflow if the caller constructs a candidate where target ≥ current.
78    pub fn bytes_saved(&self) -> u64 {
79        self.current_offset.saturating_sub(self.target_offset)
80    }
81}
82
83/// Analyzes storage layout to detect fragmentation, recommend defragmentation
84/// targets, and estimate compaction savings.
85///
86/// Extents are maintained in ascending `start_offset` order at all times.
87pub struct StorageFragmentationAnalyzer {
88    /// Storage extents, always sorted by `start_offset`.
89    pub extents: Vec<StorageExtent>,
90}
91
92impl StorageFragmentationAnalyzer {
93    /// Create a new, empty analyzer.
94    pub fn new() -> Self {
95        Self {
96            extents: Vec::new(),
97        }
98    }
99
100    /// Add an extent, maintaining sorted order by `start_offset`.
101    pub fn add_extent(&mut self, extent: StorageExtent) {
102        // Binary search for the insertion position to keep the vec sorted.
103        let pos = self
104            .extents
105            .partition_point(|e| e.start_offset < extent.start_offset);
106        self.extents.insert(pos, extent);
107    }
108
109    /// Mark the extent whose CID matches `cid` as free space.
110    ///
111    /// If no extent matches, this is a no-op.
112    pub fn free_extent(&mut self, cid: &str) {
113        for extent in &mut self.extents {
114            if extent.cid.as_deref() == Some(cid) {
115                extent.cid = None;
116            }
117        }
118    }
119
120    /// Walk all extents and compute fragmentation statistics.
121    pub fn analyze(&self) -> FragmentationReport {
122        let mut total_bytes: u64 = 0;
123        let mut used_bytes: u64 = 0;
124        let mut free_bytes: u64 = 0;
125        let mut free_extent_count: usize = 0;
126        let mut largest_free_extent: u64 = 0;
127
128        for extent in &self.extents {
129            total_bytes += extent.length_bytes;
130            if extent.is_free() {
131                free_bytes += extent.length_bytes;
132                free_extent_count += 1;
133                if extent.length_bytes > largest_free_extent {
134                    largest_free_extent = extent.length_bytes;
135                }
136            } else {
137                used_bytes += extent.length_bytes;
138            }
139        }
140
141        let fragmentation_score = if free_bytes > 0 {
142            1.0 - (largest_free_extent as f64 / free_bytes as f64)
143        } else {
144            0.0
145        };
146
147        FragmentationReport {
148            total_bytes,
149            used_bytes,
150            free_bytes,
151            free_extent_count,
152            largest_free_extent,
153            fragmentation_score,
154        }
155    }
156
157    /// Compute which live blocks would need to move to achieve a fully
158    /// left-packed layout, ordered by descending `bytes_saved`.
159    ///
160    /// Only blocks whose `target_offset` is strictly less than
161    /// `current_offset` are included.
162    pub fn compaction_plan(&self) -> Vec<CompactionCandidate> {
163        let mut candidates: Vec<CompactionCandidate> = Vec::new();
164        let mut write_cursor: u64 = 0;
165
166        for extent in &self.extents {
167            if !extent.is_free() {
168                let target_offset = write_cursor;
169                let current_offset = extent.start_offset;
170
171                if target_offset < current_offset {
172                    candidates.push(CompactionCandidate {
173                        cid: extent.cid.clone().unwrap_or_default(),
174                        current_offset,
175                        target_offset,
176                        size_bytes: extent.length_bytes,
177                    });
178                }
179
180                write_cursor += extent.length_bytes;
181            }
182        }
183
184        // Sort by bytes_saved descending (most valuable moves first).
185        candidates.sort_by_key(|c| std::cmp::Reverse(c.bytes_saved()));
186        candidates
187    }
188
189    /// Merge adjacent free extents into a single larger free extent.
190    ///
191    /// After this call the extent list is still sorted by `start_offset`.
192    pub fn merge_free_extents(&mut self) {
193        if self.extents.is_empty() {
194            return;
195        }
196
197        let mut merged: Vec<StorageExtent> = Vec::with_capacity(self.extents.len());
198
199        for extent in self.extents.drain(..) {
200            if let Some(last) = merged.last_mut() {
201                // Merge only when both extents are free and contiguous.
202                if last.is_free() && extent.is_free() && last.end_offset() == extent.start_offset {
203                    last.length_bytes += extent.length_bytes;
204                    continue;
205                }
206            }
207            merged.push(extent);
208        }
209
210        self.extents = merged;
211    }
212
213    /// Return the total number of extents (used and free).
214    pub fn total_extents(&self) -> usize {
215        self.extents.len()
216    }
217}
218
219impl Default for StorageFragmentationAnalyzer {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225// ──────────────────────────────────────────────────────────────────────────────
226// Tests
227// ──────────────────────────────────────────────────────────────────────────────
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn used(start: u64, len: u64, cid: &str) -> StorageExtent {
234        StorageExtent {
235            start_offset: start,
236            length_bytes: len,
237            cid: Some(cid.to_string()),
238        }
239    }
240
241    fn free(start: u64, len: u64) -> StorageExtent {
242        StorageExtent {
243            start_offset: start,
244            length_bytes: len,
245            cid: None,
246        }
247    }
248
249    // ── StorageExtent helpers ─────────────────────────────────────────────────
250
251    #[test]
252    fn test_extent_is_free_true_for_none_cid() {
253        let e = free(0, 512);
254        assert!(e.is_free());
255    }
256
257    #[test]
258    fn test_extent_is_free_false_for_some_cid() {
259        let e = used(0, 512, "QmA");
260        assert!(!e.is_free());
261    }
262
263    #[test]
264    fn test_extent_end_offset() {
265        let e = used(100, 400, "QmA");
266        assert_eq!(e.end_offset(), 500);
267    }
268
269    // ── add_extent maintains sorted order ─────────────────────────────────────
270
271    #[test]
272    fn test_add_extent_maintains_sorted_order() {
273        let mut analyzer = StorageFragmentationAnalyzer::new();
274        // Insert out of order
275        analyzer.add_extent(used(1000, 256, "QmC"));
276        analyzer.add_extent(used(0, 512, "QmA"));
277        analyzer.add_extent(used(512, 488, "QmB"));
278
279        let offsets: Vec<u64> = analyzer.extents.iter().map(|e| e.start_offset).collect();
280        assert_eq!(offsets, vec![0, 512, 1000]);
281    }
282
283    #[test]
284    fn test_add_extent_single_element() {
285        let mut analyzer = StorageFragmentationAnalyzer::new();
286        analyzer.add_extent(used(42, 100, "QmX"));
287        assert_eq!(analyzer.total_extents(), 1);
288        assert_eq!(analyzer.extents[0].start_offset, 42);
289    }
290
291    // ── free_extent marks as free ─────────────────────────────────────────────
292
293    #[test]
294    fn test_free_extent_marks_as_free() {
295        let mut analyzer = StorageFragmentationAnalyzer::new();
296        analyzer.add_extent(used(0, 512, "QmA"));
297        analyzer.add_extent(used(512, 512, "QmB"));
298
299        analyzer.free_extent("QmA");
300
301        assert!(analyzer.extents[0].is_free());
302        assert!(!analyzer.extents[1].is_free());
303    }
304
305    #[test]
306    fn test_free_extent_noop_for_unknown_cid() {
307        let mut analyzer = StorageFragmentationAnalyzer::new();
308        analyzer.add_extent(used(0, 512, "QmA"));
309        analyzer.free_extent("QmZZZ");
310        // Should remain used
311        assert!(!analyzer.extents[0].is_free());
312    }
313
314    // ── analyze: fragmentation_score = 0 when contiguous ─────────────────────
315
316    #[test]
317    fn test_analyze_score_zero_when_no_free_space() {
318        let mut analyzer = StorageFragmentationAnalyzer::new();
319        analyzer.add_extent(used(0, 1024, "QmA"));
320        analyzer.add_extent(used(1024, 1024, "QmB"));
321
322        let report = analyzer.analyze();
323        assert_eq!(report.fragmentation_score, 0.0);
324        assert_eq!(report.free_extent_count, 0);
325    }
326
327    #[test]
328    fn test_analyze_score_zero_when_free_space_is_contiguous() {
329        let mut analyzer = StorageFragmentationAnalyzer::new();
330        // One used block followed by one big free block — all free space in one run.
331        analyzer.add_extent(used(0, 512, "QmA"));
332        analyzer.add_extent(free(512, 1024));
333
334        let report = analyzer.analyze();
335        assert_eq!(report.fragmentation_score, 0.0);
336        assert_eq!(report.free_extent_count, 1);
337        assert_eq!(report.largest_free_extent, 1024);
338    }
339
340    // ── analyze: score > 0 when fragmented ────────────────────────────────────
341
342    #[test]
343    fn test_analyze_score_positive_when_fragmented() {
344        let mut analyzer = StorageFragmentationAnalyzer::new();
345        // Alternating used/free — highly fragmented.
346        analyzer.add_extent(used(0, 100, "QmA"));
347        analyzer.add_extent(free(100, 100));
348        analyzer.add_extent(used(200, 100, "QmB"));
349        analyzer.add_extent(free(300, 100));
350        analyzer.add_extent(used(400, 100, "QmC"));
351        analyzer.add_extent(free(500, 100));
352
353        let report = analyzer.analyze();
354        // Three free extents of 100 bytes each; largest == 100 == 1/3 of free_bytes.
355        // score = 1 - (100/300) = 1 - 0.333... ≈ 0.666...
356        assert!(report.fragmentation_score > 0.0);
357        assert!(report.fragmentation_score < 1.0);
358        assert_eq!(report.free_extent_count, 3);
359    }
360
361    // ── analyze: largest_free_extent ─────────────────────────────────────────
362
363    #[test]
364    fn test_analyze_largest_free_extent() {
365        let mut analyzer = StorageFragmentationAnalyzer::new();
366        analyzer.add_extent(free(0, 50));
367        analyzer.add_extent(used(50, 100, "QmA"));
368        analyzer.add_extent(free(150, 200));
369        analyzer.add_extent(used(350, 100, "QmB"));
370        analyzer.add_extent(free(450, 30));
371
372        let report = analyzer.analyze();
373        assert_eq!(report.largest_free_extent, 200);
374        assert_eq!(report.free_bytes, 280); // 50 + 200 + 30
375        assert_eq!(report.free_extent_count, 3);
376    }
377
378    // ── analyze: utilization ──────────────────────────────────────────────────
379
380    #[test]
381    fn test_analyze_utilization() {
382        let mut analyzer = StorageFragmentationAnalyzer::new();
383        analyzer.add_extent(used(0, 750, "QmA"));
384        analyzer.add_extent(free(750, 250));
385
386        let report = analyzer.analyze();
387        assert!((report.utilization() - 0.75).abs() < 1e-10);
388    }
389
390    #[test]
391    fn test_analyze_utilization_full() {
392        let mut analyzer = StorageFragmentationAnalyzer::new();
393        analyzer.add_extent(used(0, 1024, "QmA"));
394
395        let report = analyzer.analyze();
396        assert!((report.utilization() - 1.0).abs() < 1e-10);
397    }
398
399    // ── compaction_plan ordering ──────────────────────────────────────────────
400
401    #[test]
402    fn test_compaction_plan_ordered_by_bytes_saved_desc() {
403        let mut analyzer = StorageFragmentationAnalyzer::new();
404        // Layout: [A:100][free:500][B:100][free:100][C:100]
405        //   A stays at 0 → no movement
406        //   B target = 100, current = 600 → saved = 500
407        //   C target = 200, current = 800 → saved = 600
408        analyzer.add_extent(used(0, 100, "QmA"));
409        analyzer.add_extent(free(100, 500));
410        analyzer.add_extent(used(600, 100, "QmB"));
411        analyzer.add_extent(free(700, 100));
412        analyzer.add_extent(used(800, 100, "QmC"));
413
414        let plan = analyzer.compaction_plan();
415        // QmC saves 600, QmB saves 500
416        assert_eq!(plan.len(), 2);
417        assert_eq!(plan[0].cid, "QmC");
418        assert_eq!(plan[0].bytes_saved(), 600);
419        assert_eq!(plan[1].cid, "QmB");
420        assert_eq!(plan[1].bytes_saved(), 500);
421    }
422
423    // ── compaction_plan: bytes_saved ─────────────────────────────────────────
424
425    #[test]
426    fn test_compaction_candidate_bytes_saved() {
427        let c = CompactionCandidate {
428            cid: "QmA".to_string(),
429            current_offset: 1000,
430            target_offset: 300,
431            size_bytes: 200,
432        };
433        assert_eq!(c.bytes_saved(), 700);
434    }
435
436    #[test]
437    fn test_compaction_candidate_bytes_saved_saturating() {
438        // target >= current should not underflow
439        let c = CompactionCandidate {
440            cid: "QmA".to_string(),
441            current_offset: 100,
442            target_offset: 200,
443            size_bytes: 50,
444        };
445        assert_eq!(c.bytes_saved(), 0);
446    }
447
448    // ── merge_free_extents ────────────────────────────────────────────────────
449
450    #[test]
451    fn test_merge_free_extents_combines_adjacent() {
452        let mut analyzer = StorageFragmentationAnalyzer::new();
453        // Two adjacent free extents
454        analyzer.add_extent(free(0, 100));
455        analyzer.add_extent(free(100, 200));
456        analyzer.add_extent(used(300, 50, "QmA"));
457
458        analyzer.merge_free_extents();
459
460        assert_eq!(analyzer.total_extents(), 2);
461        let first = &analyzer.extents[0];
462        assert!(first.is_free());
463        assert_eq!(first.length_bytes, 300); // merged
464        assert_eq!(first.start_offset, 0);
465    }
466
467    #[test]
468    fn test_merge_free_extents_does_not_merge_separated() {
469        let mut analyzer = StorageFragmentationAnalyzer::new();
470        analyzer.add_extent(free(0, 100));
471        analyzer.add_extent(used(100, 50, "QmA"));
472        analyzer.add_extent(free(150, 100));
473
474        analyzer.merge_free_extents();
475
476        // Free extents are separated by a used block — should stay as two.
477        assert_eq!(analyzer.total_extents(), 3);
478    }
479
480    #[test]
481    fn test_merge_free_extents_multiple_runs() {
482        let mut analyzer = StorageFragmentationAnalyzer::new();
483        // free|free|used|free|free|free
484        analyzer.add_extent(free(0, 50));
485        analyzer.add_extent(free(50, 50));
486        analyzer.add_extent(used(100, 100, "QmA"));
487        analyzer.add_extent(free(200, 60));
488        analyzer.add_extent(free(260, 40));
489        analyzer.add_extent(free(300, 100));
490
491        analyzer.merge_free_extents();
492
493        // Two merged free extents + one used = 3 extents
494        assert_eq!(analyzer.total_extents(), 3);
495        assert_eq!(analyzer.extents[0].length_bytes, 100); // 50+50
496        assert_eq!(analyzer.extents[2].length_bytes, 200); // 60+40+100
497    }
498
499    // ── total_extents ─────────────────────────────────────────────────────────
500
501    #[test]
502    fn test_total_extents_empty() {
503        let analyzer = StorageFragmentationAnalyzer::new();
504        assert_eq!(analyzer.total_extents(), 0);
505    }
506
507    #[test]
508    fn test_total_extents_counts_all() {
509        let mut analyzer = StorageFragmentationAnalyzer::new();
510        analyzer.add_extent(used(0, 100, "QmA"));
511        analyzer.add_extent(free(100, 50));
512        analyzer.add_extent(used(150, 200, "QmB"));
513        assert_eq!(analyzer.total_extents(), 3);
514    }
515
516    // ── free_bytes count ──────────────────────────────────────────────────────
517
518    #[test]
519    fn test_analyze_free_bytes_count() {
520        let mut analyzer = StorageFragmentationAnalyzer::new();
521        analyzer.add_extent(used(0, 400, "QmA"));
522        analyzer.add_extent(free(400, 100));
523        analyzer.add_extent(used(500, 400, "QmB"));
524        analyzer.add_extent(free(900, 100));
525
526        let report = analyzer.analyze();
527        assert_eq!(report.free_bytes, 200);
528        assert_eq!(report.used_bytes, 800);
529        assert_eq!(report.total_bytes, 1000);
530    }
531}