Skip to main content

ipfrs_storage/
compactor.rs

1//! Storage Compactor
2//!
3//! Background compaction manager with fragmentation detection. Tracks storage
4//! regions (offset, size, used/free), analyses fragmentation ratios, and merges
5//! adjacent free regions to reclaim wasted space.
6
7/// The current lifecycle state of the compactor.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum CompactionState {
10    /// No compaction activity.
11    Idle,
12    /// Fragmentation analysis in progress.
13    Analyzing,
14    /// Active compaction running.
15    Compacting,
16    /// Last compaction finished successfully.
17    Completed,
18    /// Last compaction failed.
19    Failed,
20}
21
22/// Configuration knobs for [`StorageCompactor`].
23#[derive(Debug, Clone)]
24pub struct CompactorConfig {
25    /// Fragmentation ratio above which compaction is warranted (0.0–1.0).
26    /// Default: 0.3 (30%).
27    pub fragmentation_threshold: f64,
28    /// Maximum bytes the compactor will process in a single run.
29    /// Default: 100 MB.
30    pub max_budget_bytes: u64,
31    /// Minimum number of ticks between compaction runs.
32    /// Default: 100.
33    pub min_interval_ticks: u64,
34}
35
36impl Default for CompactorConfig {
37    fn default() -> Self {
38        Self {
39            fragmentation_threshold: 0.3,
40            max_budget_bytes: 100 * 1024 * 1024, // 100 MB
41            min_interval_ticks: 100,
42        }
43    }
44}
45
46/// Result of a fragmentation analysis pass.
47#[derive(Debug, Clone)]
48pub struct FragmentationReport {
49    /// Total bytes spanned by all regions.
50    pub total_bytes: u64,
51    /// Bytes occupied by used regions.
52    pub used_bytes: u64,
53    /// Ratio of free space to total space: `(total - used) / total`.
54    pub fragmentation_ratio: f64,
55    /// Number of distinct free (unused) regions.
56    pub fragmented_regions: usize,
57}
58
59/// Result of a single compaction run.
60#[derive(Debug, Clone)]
61pub struct CompactionResult {
62    /// Number of region-merge operations performed.
63    pub regions_merged: usize,
64    /// Total bytes freed by merging adjacent free regions.
65    pub bytes_reclaimed: u64,
66    /// Ticks elapsed during this compaction (always 1 for synchronous runs).
67    pub duration_ticks: u64,
68}
69
70/// Cumulative statistics for the compactor.
71#[derive(Debug, Clone)]
72pub struct CompactorStats {
73    /// Current lifecycle state.
74    pub state: CompactionState,
75    /// Number of compaction runs completed.
76    pub runs_completed: u64,
77    /// Lifetime bytes reclaimed.
78    pub bytes_reclaimed_total: u64,
79    /// Fragmentation ratio from the most recent analysis.
80    pub current_fragmentation: f64,
81}
82
83/// Background compaction manager.
84///
85/// Maintains a set of storage regions described by `(offset, size, is_used)`
86/// tuples and can analyse/compact them on demand.
87pub struct StorageCompactor {
88    config: CompactorConfig,
89    state: CompactionState,
90    last_run_tick: u64,
91    current_tick: u64,
92    runs_completed: u64,
93    bytes_reclaimed_total: u64,
94    /// Storage regions: (offset, size, is_used).
95    regions: Vec<(u64, u64, bool)>,
96    /// Cached fragmentation ratio from the most recent `analyze()`.
97    last_fragmentation: f64,
98}
99
100impl StorageCompactor {
101    /// Create a new compactor with the given configuration.
102    pub fn new(config: CompactorConfig) -> Self {
103        Self {
104            config,
105            state: CompactionState::Idle,
106            last_run_tick: 0,
107            current_tick: 0,
108            runs_completed: 0,
109            bytes_reclaimed_total: 0,
110            regions: Vec::new(),
111            last_fragmentation: 0.0,
112        }
113    }
114
115    /// Register a storage region.
116    pub fn add_region(&mut self, offset: u64, size: u64, is_used: bool) {
117        self.regions.push((offset, size, is_used));
118    }
119
120    /// Remove the region at the given offset.
121    /// Returns `true` if a region was found and removed.
122    pub fn remove_region(&mut self, offset: u64) -> bool {
123        if let Some(pos) = self.regions.iter().position(|r| r.0 == offset) {
124            self.regions.remove(pos);
125            true
126        } else {
127            false
128        }
129    }
130
131    /// Analyse the current region set and return a fragmentation report.
132    ///
133    /// Sets the compactor state to [`CompactionState::Analyzing`] during the
134    /// call and back to [`CompactionState::Idle`] afterwards (unless the
135    /// compactor was already in a terminal state).
136    pub fn analyze(&mut self) -> FragmentationReport {
137        self.state = CompactionState::Analyzing;
138
139        let total_bytes: u64 = self.regions.iter().map(|r| r.1).sum();
140        let used_bytes: u64 = self.regions.iter().filter(|r| r.2).map(|r| r.1).sum();
141        let fragmented_regions = self.regions.iter().filter(|r| !r.2).count();
142
143        let fragmentation_ratio = if total_bytes == 0 {
144            0.0
145        } else {
146            (total_bytes - used_bytes) as f64 / total_bytes as f64
147        };
148
149        self.last_fragmentation = fragmentation_ratio;
150
151        // Return to idle after analysis
152        self.state = CompactionState::Idle;
153
154        FragmentationReport {
155            total_bytes,
156            used_bytes,
157            fragmentation_ratio,
158            fragmented_regions,
159        }
160    }
161
162    /// Returns `true` if compaction should be triggered based on the current
163    /// fragmentation ratio and the configured interval.
164    pub fn should_compact(&self) -> bool {
165        self.last_fragmentation > self.config.fragmentation_threshold
166            && (self.current_tick.saturating_sub(self.last_run_tick)
167                >= self.config.min_interval_ticks)
168    }
169
170    /// Run compaction: sort regions by offset, merge adjacent free regions.
171    ///
172    /// Returns an error if the compactor is already in the `Compacting` state.
173    pub fn compact(&mut self) -> Result<CompactionResult, String> {
174        if self.state == CompactionState::Compacting {
175            return Err("compaction already in progress".to_string());
176        }
177
178        self.state = CompactionState::Compacting;
179
180        // Sort by offset
181        self.regions.sort_by_key(|r| r.0);
182
183        let mut merged: Vec<(u64, u64, bool)> = Vec::with_capacity(self.regions.len());
184        let mut regions_merged: usize = 0;
185        let mut bytes_reclaimed: u64 = 0;
186        let mut budget_remaining = self.config.max_budget_bytes;
187
188        for &region in &self.regions {
189            if let Some(last) = merged.last_mut() {
190                // Two adjacent free regions can be merged if: both free AND
191                // the second starts right where the first ends.
192                let adjacent = last.0 + last.1 == region.0;
193                let both_free = !last.2 && !region.2;
194
195                if adjacent && both_free && budget_remaining >= region.1 {
196                    // Merge: grow the last region, count the merge.
197                    bytes_reclaimed += region.1;
198                    budget_remaining = budget_remaining.saturating_sub(region.1);
199                    last.1 += region.1;
200                    regions_merged += 1;
201                    continue;
202                }
203            }
204            merged.push(region);
205        }
206
207        self.regions = merged;
208        self.bytes_reclaimed_total += bytes_reclaimed;
209        self.runs_completed += 1;
210        self.last_run_tick = self.current_tick;
211        self.state = CompactionState::Completed;
212
213        Ok(CompactionResult {
214            regions_merged,
215            bytes_reclaimed,
216            duration_ticks: 1,
217        })
218    }
219
220    /// Advance the logical clock by one tick.
221    pub fn tick(&mut self) {
222        self.current_tick += 1;
223    }
224
225    /// Total bytes reclaimed across all compaction runs.
226    pub fn reclaimed_bytes(&self) -> u64 {
227        self.bytes_reclaimed_total
228    }
229
230    /// Return a snapshot of the compactor's cumulative statistics.
231    pub fn stats(&self) -> CompactorStats {
232        CompactorStats {
233            state: self.state,
234            runs_completed: self.runs_completed,
235            bytes_reclaimed_total: self.bytes_reclaimed_total,
236            current_fragmentation: self.last_fragmentation,
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn default_compactor() -> StorageCompactor {
246        StorageCompactor::new(CompactorConfig::default())
247    }
248
249    // --- Empty compactor ---
250
251    #[test]
252    fn empty_compactor_zero_fragmentation() {
253        let mut c = default_compactor();
254        let report = c.analyze();
255        assert_eq!(report.total_bytes, 0);
256        assert_eq!(report.used_bytes, 0);
257        assert!((report.fragmentation_ratio - 0.0).abs() < f64::EPSILON);
258        assert_eq!(report.fragmented_regions, 0);
259    }
260
261    #[test]
262    fn empty_compactor_state_idle() {
263        let c = default_compactor();
264        assert_eq!(c.state, CompactionState::Idle);
265    }
266
267    #[test]
268    fn empty_compactor_stats() {
269        let c = default_compactor();
270        let s = c.stats();
271        assert_eq!(s.runs_completed, 0);
272        assert_eq!(s.bytes_reclaimed_total, 0);
273        assert!((s.current_fragmentation - 0.0).abs() < f64::EPSILON);
274    }
275
276    // --- Add regions / analyse ---
277
278    #[test]
279    fn add_regions_and_analyze_all_used() {
280        let mut c = default_compactor();
281        c.add_region(0, 1000, true);
282        c.add_region(1000, 2000, true);
283        let report = c.analyze();
284        assert_eq!(report.total_bytes, 3000);
285        assert_eq!(report.used_bytes, 3000);
286        assert!((report.fragmentation_ratio - 0.0).abs() < f64::EPSILON);
287        assert_eq!(report.fragmented_regions, 0);
288    }
289
290    #[test]
291    fn add_regions_and_analyze_half_free() {
292        let mut c = default_compactor();
293        c.add_region(0, 500, true);
294        c.add_region(500, 500, false);
295        let report = c.analyze();
296        assert_eq!(report.total_bytes, 1000);
297        assert_eq!(report.used_bytes, 500);
298        assert!((report.fragmentation_ratio - 0.5).abs() < f64::EPSILON);
299        assert_eq!(report.fragmented_regions, 1);
300    }
301
302    #[test]
303    fn analyze_multiple_free_regions() {
304        let mut c = default_compactor();
305        c.add_region(0, 100, false);
306        c.add_region(100, 200, true);
307        c.add_region(300, 100, false);
308        c.add_region(400, 100, false);
309        let report = c.analyze();
310        assert_eq!(report.total_bytes, 500);
311        assert_eq!(report.used_bytes, 200);
312        assert_eq!(report.fragmented_regions, 3);
313        let expected = 300.0 / 500.0;
314        assert!((report.fragmentation_ratio - expected).abs() < f64::EPSILON);
315    }
316
317    // --- should_compact ---
318
319    #[test]
320    fn should_compact_below_threshold() {
321        let mut c = StorageCompactor::new(CompactorConfig {
322            fragmentation_threshold: 0.5,
323            min_interval_ticks: 0,
324            ..CompactorConfig::default()
325        });
326        c.add_region(0, 800, true);
327        c.add_region(800, 200, false);
328        c.analyze(); // ratio = 0.2 < 0.5
329        assert!(!c.should_compact());
330    }
331
332    #[test]
333    fn should_compact_above_threshold() {
334        let mut c = StorageCompactor::new(CompactorConfig {
335            fragmentation_threshold: 0.1,
336            min_interval_ticks: 0,
337            ..CompactorConfig::default()
338        });
339        c.add_region(0, 200, true);
340        c.add_region(200, 800, false);
341        c.analyze(); // ratio = 0.8 > 0.1
342        assert!(c.should_compact());
343    }
344
345    #[test]
346    fn should_compact_respects_interval() {
347        let mut c = StorageCompactor::new(CompactorConfig {
348            fragmentation_threshold: 0.1,
349            min_interval_ticks: 50,
350            ..CompactorConfig::default()
351        });
352        c.add_region(0, 100, true);
353        c.add_region(100, 900, false);
354        c.analyze();
355        // Haven't advanced enough ticks since last_run_tick=0 and current_tick=0
356        // Actually current_tick=0, last_run_tick=0, diff=0 < 50
357        assert!(!c.should_compact());
358
359        for _ in 0..50 {
360            c.tick();
361        }
362        assert!(c.should_compact());
363    }
364
365    #[test]
366    fn should_compact_not_after_recent_run() {
367        let mut c = StorageCompactor::new(CompactorConfig {
368            fragmentation_threshold: 0.1,
369            min_interval_ticks: 10,
370            ..CompactorConfig::default()
371        });
372        c.add_region(0, 100, false);
373        c.add_region(100, 100, false);
374        for _ in 0..20 {
375            c.tick();
376        }
377        c.analyze();
378        assert!(c.should_compact());
379        let _ = c.compact();
380        // Re-analyse with new regions
381        c.add_region(200, 100, false);
382        c.analyze();
383        // Only 0 ticks since last run
384        assert!(!c.should_compact());
385    }
386
387    // --- compact ---
388
389    #[test]
390    fn compact_merges_adjacent_free_regions() {
391        let mut c = default_compactor();
392        c.add_region(0, 100, false);
393        c.add_region(100, 200, false);
394        c.add_region(300, 300, false);
395        let result = c.compact().expect("compaction should succeed");
396        assert_eq!(result.regions_merged, 2);
397        assert_eq!(result.bytes_reclaimed, 500); // 200 + 300
398                                                 // All three merged into one region starting at 0 with size 600
399        assert_eq!(c.regions.len(), 1);
400        assert_eq!(c.regions[0], (0, 600, false));
401    }
402
403    #[test]
404    fn compact_does_not_merge_used_regions() {
405        let mut c = default_compactor();
406        c.add_region(0, 100, true);
407        c.add_region(100, 200, true);
408        let result = c.compact().expect("compaction should succeed");
409        assert_eq!(result.regions_merged, 0);
410        assert_eq!(result.bytes_reclaimed, 0);
411        assert_eq!(c.regions.len(), 2);
412    }
413
414    #[test]
415    fn compact_non_adjacent_free_regions_stay_separate() {
416        let mut c = default_compactor();
417        c.add_region(0, 100, false);
418        c.add_region(200, 100, false); // gap at offset 100..200
419        let result = c.compact().expect("compaction should succeed");
420        assert_eq!(result.regions_merged, 0);
421        assert_eq!(result.bytes_reclaimed, 0);
422        assert_eq!(c.regions.len(), 2);
423    }
424
425    #[test]
426    fn compact_mixed_regions() {
427        let mut c = default_compactor();
428        // free, free, used, free, free
429        c.add_region(0, 100, false);
430        c.add_region(100, 100, false);
431        c.add_region(200, 100, true);
432        c.add_region(300, 100, false);
433        c.add_region(400, 100, false);
434        let result = c.compact().expect("compaction should succeed");
435        // First two free merge, last two free merge
436        assert_eq!(result.regions_merged, 2);
437        assert_eq!(result.bytes_reclaimed, 200); // 100 + 100
438        assert_eq!(c.regions.len(), 3);
439    }
440
441    #[test]
442    fn compact_free_used_alternating() {
443        let mut c = default_compactor();
444        c.add_region(0, 50, false);
445        c.add_region(50, 50, true);
446        c.add_region(100, 50, false);
447        c.add_region(150, 50, true);
448        c.add_region(200, 50, false);
449        let result = c.compact().expect("compaction should succeed");
450        assert_eq!(result.regions_merged, 0);
451        assert_eq!(c.regions.len(), 5);
452    }
453
454    // --- State transitions ---
455
456    #[test]
457    fn state_idle_to_analyzing_to_idle() {
458        let mut c = default_compactor();
459        assert_eq!(c.state, CompactionState::Idle);
460        // analyze sets Analyzing then returns to Idle
461        c.add_region(0, 100, true);
462        let _ = c.analyze();
463        assert_eq!(c.state, CompactionState::Idle);
464    }
465
466    #[test]
467    fn state_compacting_to_completed() {
468        let mut c = default_compactor();
469        c.add_region(0, 100, false);
470        let _ = c.compact();
471        assert_eq!(c.state, CompactionState::Completed);
472    }
473
474    #[test]
475    fn cannot_compact_while_compacting() {
476        // We need to test this by manipulating state directly since compact()
477        // always finishes synchronously. We'll verify the guard works.
478        let mut c = default_compactor();
479        c.state = CompactionState::Compacting;
480        let result = c.compact();
481        assert!(result.is_err());
482        assert_eq!(
483            result.expect_err("should be error"),
484            "compaction already in progress"
485        );
486    }
487
488    // --- remove_region ---
489
490    #[test]
491    fn remove_region_existing() {
492        let mut c = default_compactor();
493        c.add_region(0, 100, true);
494        c.add_region(100, 200, false);
495        assert!(c.remove_region(0));
496        assert_eq!(c.regions.len(), 1);
497        assert_eq!(c.regions[0].0, 100);
498    }
499
500    #[test]
501    fn remove_region_nonexistent() {
502        let mut c = default_compactor();
503        c.add_region(0, 100, true);
504        assert!(!c.remove_region(999));
505        assert_eq!(c.regions.len(), 1);
506    }
507
508    // --- Bytes reclaimed tracking ---
509
510    #[test]
511    fn reclaimed_bytes_zero_initially() {
512        let c = default_compactor();
513        assert_eq!(c.reclaimed_bytes(), 0);
514    }
515
516    #[test]
517    fn reclaimed_bytes_after_compact() {
518        let mut c = default_compactor();
519        c.add_region(0, 100, false);
520        c.add_region(100, 200, false);
521        let _ = c.compact();
522        assert_eq!(c.reclaimed_bytes(), 200);
523    }
524
525    // --- Multiple compaction runs ---
526
527    #[test]
528    fn multiple_runs_accumulate_stats() {
529        let mut c = default_compactor();
530        // First run: two adjacent free
531        c.add_region(0, 100, false);
532        c.add_region(100, 100, false);
533        let r1 = c.compact().expect("run 1");
534        assert_eq!(r1.bytes_reclaimed, 100);
535        assert_eq!(c.runs_completed, 1);
536
537        // Add more free regions — these are adjacent to the merged (0,200) region
538        c.add_region(200, 50, false);
539        c.add_region(250, 50, false);
540        let r2 = c.compact().expect("run 2");
541        // All three free regions merge: (0,200)+(200,50)+(250,50) → reclaims 100
542        assert_eq!(r2.bytes_reclaimed, 100);
543        assert_eq!(c.runs_completed, 2);
544        assert_eq!(c.reclaimed_bytes(), 200);
545    }
546
547    #[test]
548    fn stats_reflect_multiple_runs() {
549        let mut c = default_compactor();
550        c.add_region(0, 100, false);
551        c.add_region(100, 100, false);
552        let _ = c.compact(); // merges → (0,200), reclaimed=100
553                             // Add non-adjacent free regions (with a used gap)
554        c.add_region(500, 100, false);
555        c.add_region(600, 100, false);
556        let _ = c.compact(); // merges (500,100)+(600,100), reclaimed=100
557
558        let s = c.stats();
559        assert_eq!(s.runs_completed, 2);
560        assert_eq!(s.bytes_reclaimed_total, 200);
561        assert_eq!(s.state, CompactionState::Completed);
562    }
563
564    // --- Tick ---
565
566    #[test]
567    fn tick_increments() {
568        let mut c = default_compactor();
569        assert_eq!(c.current_tick, 0);
570        c.tick();
571        c.tick();
572        c.tick();
573        assert_eq!(c.current_tick, 3);
574    }
575
576    // --- Budget limit ---
577
578    #[test]
579    fn compact_respects_budget() {
580        let mut c = StorageCompactor::new(CompactorConfig {
581            max_budget_bytes: 150,
582            ..CompactorConfig::default()
583        });
584        // Three adjacent free regions of 100 each
585        c.add_region(0, 100, false);
586        c.add_region(100, 100, false);
587        c.add_region(200, 100, false);
588        let result = c.compact().expect("compaction should succeed");
589        // Budget = 150, first merge costs 100, second costs 100 => second exceeds 50 remaining
590        assert_eq!(result.regions_merged, 1);
591        assert_eq!(result.bytes_reclaimed, 100);
592    }
593
594    // --- Edge cases ---
595
596    #[test]
597    fn compact_single_free_region() {
598        let mut c = default_compactor();
599        c.add_region(0, 500, false);
600        let result = c.compact().expect("compaction should succeed");
601        assert_eq!(result.regions_merged, 0);
602        assert_eq!(result.bytes_reclaimed, 0);
603        assert_eq!(c.regions.len(), 1);
604    }
605
606    #[test]
607    fn compact_single_used_region() {
608        let mut c = default_compactor();
609        c.add_region(0, 500, true);
610        let result = c.compact().expect("compaction should succeed");
611        assert_eq!(result.regions_merged, 0);
612        assert_eq!(c.regions.len(), 1);
613    }
614
615    #[test]
616    fn analyze_only_free_regions() {
617        let mut c = default_compactor();
618        c.add_region(0, 1000, false);
619        let report = c.analyze();
620        assert_eq!(report.fragmentation_ratio, 1.0);
621        assert_eq!(report.fragmented_regions, 1);
622    }
623
624    #[test]
625    fn compact_many_adjacent_free() {
626        let mut c = default_compactor();
627        for i in 0..10 {
628            c.add_region(i * 100, 100, false);
629        }
630        let result = c.compact().expect("compaction should succeed");
631        assert_eq!(result.regions_merged, 9);
632        assert_eq!(result.bytes_reclaimed, 900);
633        assert_eq!(c.regions.len(), 1);
634        assert_eq!(c.regions[0], (0, 1000, false));
635    }
636
637    #[test]
638    fn compact_unsorted_regions() {
639        let mut c = default_compactor();
640        // Add in reverse order — compact should sort first
641        c.add_region(200, 100, false);
642        c.add_region(0, 100, false);
643        c.add_region(100, 100, false);
644        let result = c.compact().expect("compaction should succeed");
645        assert_eq!(result.regions_merged, 2);
646        assert_eq!(c.regions.len(), 1);
647        assert_eq!(c.regions[0], (0, 300, false));
648    }
649
650    #[test]
651    fn default_config_values() {
652        let cfg = CompactorConfig::default();
653        assert!((cfg.fragmentation_threshold - 0.3).abs() < f64::EPSILON);
654        assert_eq!(cfg.max_budget_bytes, 100 * 1024 * 1024);
655        assert_eq!(cfg.min_interval_ticks, 100);
656    }
657
658    #[test]
659    fn compaction_result_fields() {
660        let mut c = default_compactor();
661        c.add_region(0, 64, false);
662        c.add_region(64, 64, false);
663        let result = c.compact().expect("compaction should succeed");
664        assert_eq!(result.duration_ticks, 1);
665        assert_eq!(result.regions_merged, 1);
666        assert_eq!(result.bytes_reclaimed, 64);
667    }
668
669    #[test]
670    fn state_transitions_full_cycle() {
671        let mut c = default_compactor();
672        assert_eq!(c.state, CompactionState::Idle);
673        c.add_region(0, 100, false);
674        c.add_region(100, 100, false);
675        let _ = c.analyze();
676        assert_eq!(c.state, CompactionState::Idle);
677        let _ = c.compact();
678        assert_eq!(c.state, CompactionState::Completed);
679    }
680
681    #[test]
682    fn remove_region_then_analyze() {
683        let mut c = default_compactor();
684        c.add_region(0, 100, true);
685        c.add_region(100, 100, false);
686        c.remove_region(100);
687        let report = c.analyze();
688        assert_eq!(report.total_bytes, 100);
689        assert_eq!(report.used_bytes, 100);
690        assert_eq!(report.fragmented_regions, 0);
691    }
692
693    #[test]
694    fn compact_free_between_used() {
695        let mut c = default_compactor();
696        c.add_region(0, 100, true);
697        c.add_region(100, 100, false);
698        c.add_region(200, 100, true);
699        let result = c.compact().expect("compaction should succeed");
700        assert_eq!(result.regions_merged, 0);
701        assert_eq!(c.regions.len(), 3);
702    }
703}