Skip to main content

ipfrs_storage/
compaction_advisor.rs

1//! Compaction advisor for data-driven storage compaction recommendations.
2//!
3//! Analyzes storage state and provides recommendations for when and how to
4//! compact, merge, or drop data, based on configurable thresholds.
5
6use serde::{Deserialize, Serialize};
7
8/// A recommended compaction or maintenance action.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub enum CompactionAction {
11    /// Flush the WAL and run sled compaction.
12    CompactSled,
13    /// Merge the listed SSTable files together.
14    MergeSSTables { table_ids: Vec<String> },
15    /// Move cold data out of the named tier to free up space.
16    EvictTier {
17        tier_name: String,
18        bytes_to_free: u64,
19    },
20    /// Garbage-collect unreferenced (orphan) blocks.
21    DropOrphanBlocks { cid_count: usize },
22    /// No action is needed at this time.
23    NoActionNeeded,
24}
25
26/// A snapshot of current storage system metrics used to drive advisory logic.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct StorageMetrics {
29    /// Bytes currently used in the sled database.
30    pub sled_bytes_used: u64,
31    /// Bytes currently free in the sled database.
32    pub sled_bytes_free: u64,
33    /// Number of WAL operations that have not yet been flushed.
34    pub wal_pending_ops: u64,
35    /// Number of blocks with no live references (orphans).
36    pub orphan_block_count: u64,
37    /// Total bytes occupied by orphan blocks.
38    pub orphan_block_bytes: u64,
39    /// Bytes stored in the hot tier.
40    pub hot_tier_bytes: u64,
41    /// Bytes stored in the warm tier.
42    pub warm_tier_bytes: u64,
43    /// Bytes stored in the cold tier.
44    pub cold_tier_bytes: u64,
45    /// Number of SSTable files currently present.
46    pub sstable_count: u64,
47    /// Fragmentation ratio in the range 0.0–1.0 (higher = more fragmented).
48    pub fragmentation_ratio: f64,
49}
50
51impl StorageMetrics {
52    /// Returns the total bytes across all tiers plus sled.
53    pub fn total_bytes(&self) -> u64 {
54        self.sled_bytes_used
55            .saturating_add(self.hot_tier_bytes)
56            .saturating_add(self.warm_tier_bytes)
57            .saturating_add(self.cold_tier_bytes)
58    }
59
60    /// Returns the usage ratio (used / (used + free)).
61    ///
62    /// Returns `0.0` when both used and free are zero.
63    pub fn usage_ratio(&self) -> f64 {
64        let used = self.sled_bytes_used;
65        let free = self.sled_bytes_free;
66        if used == 0 && free == 0 {
67            return 0.0;
68        }
69        used as f64 / (used as f64 + free as f64)
70    }
71}
72
73/// Configurable thresholds that drive the advisory logic.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct AdvisoryThresholds {
76    /// Flush the WAL when pending operations exceed this value (default: 1 000).
77    pub wal_flush_threshold: u64,
78    /// Run orphan GC when orphan block count exceeds this value (default: 100).
79    pub orphan_gc_threshold: u64,
80    /// Compact when fragmentation ratio exceeds this value (default: 0.3).
81    pub fragmentation_compact_threshold: f64,
82    /// Merge SSTables when the file count exceeds this value (default: 10).
83    pub sstable_merge_threshold: u64,
84    /// Evict cold data when the usage ratio exceeds this value (default: 0.8).
85    pub cold_tier_evict_ratio: f64,
86}
87
88impl Default for AdvisoryThresholds {
89    fn default() -> Self {
90        Self {
91            wal_flush_threshold: 1_000,
92            orphan_gc_threshold: 100,
93            fragmentation_compact_threshold: 0.3,
94            sstable_merge_threshold: 10,
95            cold_tier_evict_ratio: 0.8,
96        }
97    }
98}
99
100/// The result of an advisory analysis: a set of recommended actions with
101/// contextual metadata.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct CompactionAdvice {
104    /// Ordered list of recommended actions.
105    pub actions: Vec<CompactionAction>,
106    /// Urgency level: 0 = none, 1 = low, 2 = medium, 3 = high.
107    pub urgency: u8,
108    /// Human-readable summary of why these actions were chosen.
109    pub reason: String,
110    /// Estimated number of bytes that will be freed if all actions are applied.
111    pub estimated_bytes_freed: u64,
112}
113
114impl CompactionAdvice {
115    /// Returns `true` when urgency is medium or high (>= 2).
116    pub fn is_urgent(&self) -> bool {
117        self.urgency >= 2
118    }
119}
120
121/// Analyses a [`StorageMetrics`] snapshot against a set of [`AdvisoryThresholds`]
122/// and produces a [`CompactionAdvice`] describing what maintenance work should
123/// be performed.
124#[derive(Debug, Clone)]
125pub struct CompactionAdvisor {
126    /// The threshold configuration used for all advisory decisions.
127    pub thresholds: AdvisoryThresholds,
128}
129
130impl CompactionAdvisor {
131    /// Creates a new advisor with the provided thresholds.
132    pub fn new(thresholds: AdvisoryThresholds) -> Self {
133        Self { thresholds }
134    }
135
136    /// Analyses `metrics` and returns a [`CompactionAdvice`] describing any
137    /// recommended actions.
138    pub fn advise(&self, metrics: &StorageMetrics) -> CompactionAdvice {
139        let mut actions: Vec<CompactionAction> = Vec::new();
140        let mut reason_parts: Vec<String> = Vec::new();
141        let mut compact_sled_added = false;
142        let mut evict_bytes: u64 = 0;
143
144        // 1. WAL threshold check.
145        if metrics.wal_pending_ops > self.thresholds.wal_flush_threshold {
146            actions.push(CompactionAction::CompactSled);
147            compact_sled_added = true;
148            reason_parts.push(format!(
149                "WAL has {} pending ops (threshold {})",
150                metrics.wal_pending_ops, self.thresholds.wal_flush_threshold
151            ));
152        }
153
154        // 2. Orphan GC threshold check.
155        if metrics.orphan_block_count > self.thresholds.orphan_gc_threshold {
156            actions.push(CompactionAction::DropOrphanBlocks {
157                cid_count: metrics.orphan_block_count as usize,
158            });
159            reason_parts.push(format!(
160                "orphan block count {} exceeds threshold {}",
161                metrics.orphan_block_count, self.thresholds.orphan_gc_threshold
162            ));
163        }
164
165        // 3. Fragmentation threshold check (add CompactSled only if not already present).
166        if metrics.fragmentation_ratio > self.thresholds.fragmentation_compact_threshold
167            && !compact_sled_added
168        {
169            actions.push(CompactionAction::CompactSled);
170            // compact_sled_added = true; // not read again, but kept for correctness
171            reason_parts.push(format!(
172                "fragmentation ratio {:.3} exceeds threshold {:.3}",
173                metrics.fragmentation_ratio, self.thresholds.fragmentation_compact_threshold
174            ));
175        }
176
177        // 4. SSTable merge threshold check.
178        if metrics.sstable_count > self.thresholds.sstable_merge_threshold {
179            let table_ids: Vec<String> = (0..metrics.sstable_count as usize)
180                .map(|i| format!("table_{}", i))
181                .collect();
182            actions.push(CompactionAction::MergeSSTables { table_ids });
183            reason_parts.push(format!(
184                "SSTable count {} exceeds merge threshold {}",
185                metrics.sstable_count, self.thresholds.sstable_merge_threshold
186            ));
187        }
188
189        // 5. Cold-tier eviction threshold check.
190        if metrics.usage_ratio() > self.thresholds.cold_tier_evict_ratio {
191            let bytes_to_free = metrics.cold_tier_bytes / 4;
192            evict_bytes = bytes_to_free;
193            actions.push(CompactionAction::EvictTier {
194                tier_name: "cold".to_string(),
195                bytes_to_free,
196            });
197            reason_parts.push(format!(
198                "usage ratio {:.3} exceeds cold eviction threshold {:.3}",
199                metrics.usage_ratio(),
200                self.thresholds.cold_tier_evict_ratio
201            ));
202        }
203
204        // 6. If nothing triggered, signal no-op.
205        if actions.is_empty() {
206            actions.push(CompactionAction::NoActionNeeded);
207        }
208
209        // Compute urgency: exclude NoActionNeeded from the count.
210        let real_action_count = actions
211            .iter()
212            .filter(|a| !matches!(a, CompactionAction::NoActionNeeded))
213            .count();
214
215        let urgency = match real_action_count {
216            0 => 0,
217            1 => 1,
218            2 => 2,
219            _ => 3,
220        };
221
222        // Estimated bytes freed = orphan bytes + evict bytes.
223        let estimated_bytes_freed = metrics.orphan_block_bytes.saturating_add(evict_bytes);
224
225        let reason = if reason_parts.is_empty() {
226            "No issues detected; storage is healthy.".to_string()
227        } else {
228            reason_parts.join("; ")
229        };
230
231        CompactionAdvice {
232            actions,
233            urgency,
234            reason,
235            estimated_bytes_freed,
236        }
237    }
238
239    /// Returns one explanatory string per action describing what will be done
240    /// and why.
241    pub fn explain(&self, advice: &CompactionAdvice) -> Vec<String> {
242        advice
243            .actions
244            .iter()
245            .map(|action| match action {
246                CompactionAction::CompactSled => {
247                    "CompactSled: flush the WAL and run sled compaction \
248                     to reclaim fragmented space and reduce pending writes."
249                        .to_string()
250                }
251                CompactionAction::MergeSSTables { table_ids } => {
252                    format!(
253                        "MergeSSTables: merge {} SSTable files ({}) \
254                         to reduce read amplification and reclaim space.",
255                        table_ids.len(),
256                        table_ids.join(", ")
257                    )
258                }
259                CompactionAction::EvictTier {
260                    tier_name,
261                    bytes_to_free,
262                } => {
263                    format!(
264                        "EvictTier({}): evict approximately {} bytes of cold data \
265                         because storage usage exceeds the configured ratio.",
266                        tier_name, bytes_to_free
267                    )
268                }
269                CompactionAction::DropOrphanBlocks { cid_count } => {
270                    format!(
271                        "DropOrphanBlocks: garbage-collect {} unreferenced blocks \
272                         to reclaim storage occupied by data with no live references.",
273                        cid_count
274                    )
275                }
276                CompactionAction::NoActionNeeded => {
277                    "NoActionNeeded: all storage metrics are within healthy thresholds.".to_string()
278                }
279            })
280            .collect()
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Tests
286// ---------------------------------------------------------------------------
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    /// Helper that returns a healthy metrics snapshot (nothing over threshold).
293    fn healthy_metrics() -> StorageMetrics {
294        StorageMetrics {
295            sled_bytes_used: 100,
296            sled_bytes_free: 900,
297            wal_pending_ops: 10,
298            orphan_block_count: 5,
299            orphan_block_bytes: 1_024,
300            hot_tier_bytes: 200,
301            warm_tier_bytes: 100,
302            cold_tier_bytes: 400,
303            sstable_count: 3,
304            fragmentation_ratio: 0.05,
305        }
306    }
307
308    fn default_advisor() -> CompactionAdvisor {
309        CompactionAdvisor::new(AdvisoryThresholds::default())
310    }
311
312    // ------------------------------------------------------------------
313    // Construction
314    // ------------------------------------------------------------------
315
316    #[test]
317    fn test_new_with_custom_thresholds() {
318        let thresholds = AdvisoryThresholds {
319            wal_flush_threshold: 500,
320            orphan_gc_threshold: 50,
321            fragmentation_compact_threshold: 0.2,
322            sstable_merge_threshold: 5,
323            cold_tier_evict_ratio: 0.7,
324        };
325        let advisor = CompactionAdvisor::new(thresholds.clone());
326        assert_eq!(advisor.thresholds, thresholds);
327    }
328
329    // ------------------------------------------------------------------
330    // advise() – no issues
331    // ------------------------------------------------------------------
332
333    #[test]
334    fn test_advise_no_issues_returns_no_action_needed() {
335        let advisor = default_advisor();
336        let advice = advisor.advise(&healthy_metrics());
337        assert_eq!(advice.actions, vec![CompactionAction::NoActionNeeded]);
338        assert_eq!(advice.urgency, 0);
339    }
340
341    // ------------------------------------------------------------------
342    // advise() – individual threshold breaches
343    // ------------------------------------------------------------------
344
345    #[test]
346    fn test_advise_wal_over_threshold_adds_compact_sled() {
347        let advisor = default_advisor();
348        let mut metrics = healthy_metrics();
349        metrics.wal_pending_ops = 1_001;
350        let advice = advisor.advise(&metrics);
351        assert!(
352            advice.actions.contains(&CompactionAction::CompactSled),
353            "Expected CompactSled action"
354        );
355        assert!(!advice.actions.contains(&CompactionAction::NoActionNeeded));
356    }
357
358    #[test]
359    fn test_advise_orphans_over_threshold_adds_drop_orphan_blocks() {
360        let advisor = default_advisor();
361        let mut metrics = healthy_metrics();
362        metrics.orphan_block_count = 101;
363        let advice = advisor.advise(&metrics);
364        let has_orphan = advice.actions.iter().any(
365            |a| matches!(a, CompactionAction::DropOrphanBlocks { cid_count } if *cid_count == 101),
366        );
367        assert!(has_orphan, "Expected DropOrphanBlocks with cid_count=101");
368    }
369
370    #[test]
371    fn test_advise_fragmentation_over_threshold_adds_compact_sled() {
372        let advisor = default_advisor();
373        let mut metrics = healthy_metrics();
374        metrics.fragmentation_ratio = 0.5;
375        let advice = advisor.advise(&metrics);
376        assert!(
377            advice.actions.contains(&CompactionAction::CompactSled),
378            "Expected CompactSled due to fragmentation"
379        );
380    }
381
382    #[test]
383    fn test_advise_sstables_over_threshold_adds_merge_sstables_with_correct_ids() {
384        let advisor = default_advisor();
385        let mut metrics = healthy_metrics();
386        metrics.sstable_count = 12;
387        let advice = advisor.advise(&metrics);
388        let merge_action = advice.actions.iter().find_map(|a| {
389            if let CompactionAction::MergeSSTables { table_ids } = a {
390                Some(table_ids.clone())
391            } else {
392                None
393            }
394        });
395        let table_ids = merge_action.expect("Expected MergeSSTables action");
396        assert_eq!(table_ids.len(), 12);
397        assert_eq!(table_ids[0], "table_0");
398        assert_eq!(table_ids[11], "table_11");
399    }
400
401    #[test]
402    fn test_advise_cold_tier_eviction_triggered() {
403        let advisor = default_advisor();
404        let mut metrics = healthy_metrics();
405        // usage = 900 / (900 + 1) ≈ 0.999 → well above 0.8
406        metrics.sled_bytes_used = 900;
407        metrics.sled_bytes_free = 1;
408        metrics.cold_tier_bytes = 800;
409        let advice = advisor.advise(&metrics);
410        let evict_action = advice.actions.iter().find_map(|a| {
411            if let CompactionAction::EvictTier {
412                tier_name,
413                bytes_to_free,
414            } = a
415            {
416                Some((tier_name.clone(), *bytes_to_free))
417            } else {
418                None
419            }
420        });
421        let (tier, freed) = evict_action.expect("Expected EvictTier action");
422        assert_eq!(tier, "cold");
423        assert_eq!(freed, 200); // 800 / 4
424    }
425
426    // ------------------------------------------------------------------
427    // advise() – multiple conditions
428    // ------------------------------------------------------------------
429
430    #[test]
431    fn test_advise_multiple_conditions_all_actions_present() {
432        let advisor = default_advisor();
433        let mut metrics = healthy_metrics();
434        metrics.wal_pending_ops = 2_000; // triggers CompactSled
435        metrics.orphan_block_count = 200; // triggers DropOrphanBlocks
436        metrics.sstable_count = 15; // triggers MergeSSTables
437        let advice = advisor.advise(&metrics);
438        assert!(advice.actions.contains(&CompactionAction::CompactSled));
439        assert!(advice
440            .actions
441            .iter()
442            .any(|a| matches!(a, CompactionAction::DropOrphanBlocks { .. })));
443        assert!(advice
444            .actions
445            .iter()
446            .any(|a| matches!(a, CompactionAction::MergeSSTables { .. })));
447        assert!(!advice.actions.contains(&CompactionAction::NoActionNeeded));
448    }
449
450    // ------------------------------------------------------------------
451    // Urgency levels
452    // ------------------------------------------------------------------
453
454    #[test]
455    fn test_urgency_zero_for_no_actions() {
456        let advisor = default_advisor();
457        let advice = advisor.advise(&healthy_metrics());
458        assert_eq!(advice.urgency, 0);
459    }
460
461    #[test]
462    fn test_urgency_one_for_single_action() {
463        let advisor = default_advisor();
464        let mut metrics = healthy_metrics();
465        metrics.wal_pending_ops = 5_000; // exactly one action
466        let advice = advisor.advise(&metrics);
467        assert_eq!(advice.urgency, 1);
468    }
469
470    #[test]
471    fn test_urgency_two_for_two_actions() {
472        let advisor = default_advisor();
473        let mut metrics = healthy_metrics();
474        metrics.wal_pending_ops = 5_000; // CompactSled
475        metrics.orphan_block_count = 200; // DropOrphanBlocks
476        let advice = advisor.advise(&metrics);
477        assert_eq!(advice.urgency, 2);
478    }
479
480    #[test]
481    fn test_urgency_three_for_three_or_more_actions() {
482        let advisor = default_advisor();
483        let mut metrics = healthy_metrics();
484        metrics.wal_pending_ops = 5_000; // CompactSled
485        metrics.orphan_block_count = 200; // DropOrphanBlocks
486        metrics.sstable_count = 20; // MergeSSTables
487        let advice = advisor.advise(&metrics);
488        assert_eq!(advice.urgency, 3);
489    }
490
491    // ------------------------------------------------------------------
492    // is_urgent()
493    // ------------------------------------------------------------------
494
495    #[test]
496    fn test_is_urgent_true_for_urgency_two() {
497        let advice = CompactionAdvice {
498            actions: vec![CompactionAction::CompactSled],
499            urgency: 2,
500            reason: "test".to_string(),
501            estimated_bytes_freed: 0,
502        };
503        assert!(advice.is_urgent());
504    }
505
506    #[test]
507    fn test_is_urgent_false_for_urgency_one() {
508        let advice = CompactionAdvice {
509            actions: vec![CompactionAction::CompactSled],
510            urgency: 1,
511            reason: "test".to_string(),
512            estimated_bytes_freed: 0,
513        };
514        assert!(!advice.is_urgent());
515    }
516
517    #[test]
518    fn test_is_urgent_true_for_urgency_three() {
519        let advice = CompactionAdvice {
520            actions: vec![CompactionAction::CompactSled],
521            urgency: 3,
522            reason: "test".to_string(),
523            estimated_bytes_freed: 0,
524        };
525        assert!(advice.is_urgent());
526    }
527
528    // ------------------------------------------------------------------
529    // estimated_bytes_freed
530    // ------------------------------------------------------------------
531
532    #[test]
533    fn test_estimated_bytes_freed_calculation() {
534        let advisor = default_advisor();
535        let mut metrics = healthy_metrics();
536        metrics.orphan_block_count = 200;
537        metrics.orphan_block_bytes = 10_000;
538        // Also trigger eviction: usage > 0.8
539        metrics.sled_bytes_used = 900;
540        metrics.sled_bytes_free = 1;
541        metrics.cold_tier_bytes = 400; // evict 100
542        let advice = advisor.advise(&metrics);
543        // orphan_block_bytes (10_000) + evict bytes (400/4 = 100) = 10_100
544        assert_eq!(advice.estimated_bytes_freed, 10_100);
545    }
546
547    // ------------------------------------------------------------------
548    // explain()
549    // ------------------------------------------------------------------
550
551    #[test]
552    fn test_explain_returns_non_empty_strings_for_each_action() {
553        let advisor = default_advisor();
554        let mut metrics = healthy_metrics();
555        metrics.wal_pending_ops = 5_000;
556        metrics.orphan_block_count = 200;
557        let advice = advisor.advise(&metrics);
558        let explanations = advisor.explain(&advice);
559        assert_eq!(explanations.len(), advice.actions.len());
560        for explanation in &explanations {
561            assert!(
562                !explanation.is_empty(),
563                "Expected non-empty explanation string"
564            );
565        }
566    }
567
568    #[test]
569    fn test_explain_no_action_needed_has_description() {
570        let advisor = default_advisor();
571        let advice = advisor.advise(&healthy_metrics());
572        let explanations = advisor.explain(&advice);
573        assert_eq!(explanations.len(), 1);
574        assert!(explanations[0].contains("NoActionNeeded"));
575    }
576
577    // ------------------------------------------------------------------
578    // StorageMetrics helpers
579    // ------------------------------------------------------------------
580
581    #[test]
582    fn test_usage_ratio_zero_when_both_used_and_free_are_zero() {
583        let metrics = StorageMetrics {
584            sled_bytes_used: 0,
585            sled_bytes_free: 0,
586            wal_pending_ops: 0,
587            orphan_block_count: 0,
588            orphan_block_bytes: 0,
589            hot_tier_bytes: 0,
590            warm_tier_bytes: 0,
591            cold_tier_bytes: 0,
592            sstable_count: 0,
593            fragmentation_ratio: 0.0,
594        };
595        assert_eq!(metrics.usage_ratio(), 0.0);
596    }
597
598    #[test]
599    fn test_total_bytes_sums_all_tiers() {
600        let metrics = StorageMetrics {
601            sled_bytes_used: 100,
602            sled_bytes_free: 0,
603            wal_pending_ops: 0,
604            orphan_block_count: 0,
605            orphan_block_bytes: 0,
606            hot_tier_bytes: 200,
607            warm_tier_bytes: 300,
608            cold_tier_bytes: 400,
609            sstable_count: 0,
610            fragmentation_ratio: 0.0,
611        };
612        assert_eq!(metrics.total_bytes(), 1_000);
613    }
614
615    // ------------------------------------------------------------------
616    // Fragmentation does not duplicate CompactSled when WAL also triggers it
617    // ------------------------------------------------------------------
618
619    #[test]
620    fn test_fragmentation_does_not_duplicate_compact_sled_when_wal_also_triggers() {
621        let advisor = default_advisor();
622        let mut metrics = healthy_metrics();
623        metrics.wal_pending_ops = 5_000; // triggers CompactSled
624        metrics.fragmentation_ratio = 0.9; // would also trigger CompactSled
625        let advice = advisor.advise(&metrics);
626        let compact_count = advice
627            .actions
628            .iter()
629            .filter(|a| matches!(a, CompactionAction::CompactSled))
630            .count();
631        assert_eq!(compact_count, 1, "CompactSled should appear exactly once");
632    }
633}