Skip to main content

ipfrs_storage/
gc_planner.rs

1//! GC Planner — analyzes block liveness and produces ordered deletion plans.
2//!
3//! [`StorageGCPlanner`] examines a set of [`GCCandidate`] blocks, filters out
4//! pinned blocks, referenced blocks, and blocks that are too young, then builds
5//! an ordered [`GCPlan`] that can be handed to the actual deletion engine.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ipfrs_storage::gc_planner::{GCCandidate, GCConfig, StorageGCPlanner};
11//!
12//! let config = GCConfig::default();
13//! let planner = StorageGCPlanner::new(config);
14//!
15//! let candidates = vec![
16//!     GCCandidate {
17//!         cid: "bafy1".to_string(),
18//!         size_bytes: 1024,
19//!         ref_count: 0,
20//!         last_accessed_secs: 0,
21//!         pinned: false,
22//!     },
23//! ];
24//!
25//! let now = 7200u64;
26//! let (plan, stats) = planner.plan(&candidates, now);
27//! assert_eq!(stats.selected_count, 1);
28//! ```
29
30/// A single block that is a candidate for garbage collection.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct GCCandidate {
33    /// Content identifier of the block.
34    pub cid: String,
35    /// Size of the block in bytes.
36    pub size_bytes: u64,
37    /// Number of references from other blocks / roots.
38    /// A block with `ref_count > 0` must not be collected.
39    pub ref_count: u32,
40    /// Unix timestamp (seconds) of the most recent access.
41    pub last_accessed_secs: u64,
42    /// Whether the block is explicitly pinned and must never be collected.
43    pub pinned: bool,
44}
45
46/// An ordered list of blocks selected for deletion together with aggregate
47/// metadata about the planned run.
48#[derive(Debug, Clone, Default)]
49pub struct GCPlan {
50    /// Blocks selected for deletion, sorted by `last_accessed_secs` ascending
51    /// (oldest-first so the least-recently-used data is evicted first).
52    pub candidates: Vec<GCCandidate>,
53    /// Sum of `size_bytes` across all selected candidates.
54    pub estimated_freed_bytes: u64,
55}
56
57impl GCPlan {
58    /// Number of blocks in the plan.
59    pub fn candidate_count(&self) -> usize {
60        self.candidates.len()
61    }
62
63    /// Returns `true` when the plan contains no blocks.
64    pub fn is_empty(&self) -> bool {
65        self.candidates.is_empty()
66    }
67}
68
69/// Configuration knobs for the GC planner.
70#[derive(Debug, Clone)]
71pub struct GCConfig {
72    /// Only collect blocks whose age (now − last_accessed_secs) is **strictly
73    /// greater than** this value (seconds).  Default: 3 600 (1 hour).
74    pub min_age_secs: u64,
75    /// Stop collecting once this many bytes have been earmarked for deletion.
76    /// Default: 1 073 741 824 (1 GiB).
77    pub target_free_bytes: u64,
78    /// Hard upper bound on the number of candidates in a single plan.
79    /// Default: 1 000.
80    pub max_candidates: usize,
81}
82
83impl Default for GCConfig {
84    fn default() -> Self {
85        Self {
86            min_age_secs: 3_600,
87            target_free_bytes: 1_073_741_824,
88            max_candidates: 1_000,
89        }
90    }
91}
92
93/// Statistics produced alongside every [`GCPlan`] so callers can understand
94/// why particular blocks were included or excluded.
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct GCPlannerStats {
97    /// Total number of input candidates examined.
98    pub total_analyzed: usize,
99    /// Blocks skipped because `pinned == true`.
100    pub pinned_skipped: usize,
101    /// Blocks skipped because `ref_count > 0`.
102    pub referenced_skipped: usize,
103    /// Blocks skipped because their age was below `min_age_secs`.
104    pub too_young_skipped: usize,
105    /// Blocks that made it into the final plan.
106    pub selected_count: usize,
107    /// Sum of `size_bytes` for selected blocks (mirrors [`GCPlan::estimated_freed_bytes`]).
108    pub estimated_freed_bytes: u64,
109}
110
111/// Plans garbage-collection runs by analysing block liveness.
112///
113/// The planner is intentionally stateless beyond its [`GCConfig`]: feed it a
114/// snapshot of candidates and a wall-clock timestamp and it returns a
115/// ready-to-execute [`GCPlan`].
116#[derive(Debug, Clone)]
117pub struct StorageGCPlanner {
118    /// Configuration controlling collection behaviour.
119    pub config: GCConfig,
120}
121
122impl StorageGCPlanner {
123    /// Create a new planner with the given configuration.
124    pub fn new(config: GCConfig) -> Self {
125        Self { config }
126    }
127
128    /// Analyse `candidates` and produce a deletion plan.
129    ///
130    /// `now_secs` is the current Unix timestamp in seconds.  Using a parameter
131    /// rather than reading the system clock keeps the function pure and
132    /// deterministic for testing.
133    ///
134    /// # Algorithm
135    ///
136    /// 1. Iterate over every candidate, classifying it as *pinned*, *referenced*,
137    ///    *too young*, or *eligible*.
138    /// 2. Sort eligible candidates by `last_accessed_secs` ascending.
139    /// 3. Greedily add candidates to the plan until `estimated_freed_bytes >=
140    ///    target_free_bytes` **or** `candidate_count >= max_candidates`.
141    pub fn plan(&self, candidates: &[GCCandidate], now_secs: u64) -> (GCPlan, GCPlannerStats) {
142        let mut stats = GCPlannerStats {
143            total_analyzed: candidates.len(),
144            ..Default::default()
145        };
146
147        let mut eligible: Vec<&GCCandidate> = Vec::with_capacity(candidates.len());
148
149        for candidate in candidates {
150            if candidate.pinned {
151                stats.pinned_skipped += 1;
152                continue;
153            }
154            if candidate.ref_count > 0 {
155                stats.referenced_skipped += 1;
156                continue;
157            }
158            // Age is computed with saturating_sub so that
159            // last_accessed_secs > now_secs simply yields 0 (too young).
160            let age = now_secs.saturating_sub(candidate.last_accessed_secs);
161            if age < self.config.min_age_secs {
162                stats.too_young_skipped += 1;
163                continue;
164            }
165            eligible.push(candidate);
166        }
167
168        // Sort oldest-first (ascending last_accessed_secs).
169        eligible.sort_by_key(|c| c.last_accessed_secs);
170
171        let mut plan_candidates: Vec<GCCandidate> = Vec::new();
172        let mut freed_bytes: u64 = 0;
173
174        for candidate in eligible {
175            if plan_candidates.len() >= self.config.max_candidates {
176                break;
177            }
178            plan_candidates.push(candidate.clone());
179            freed_bytes = freed_bytes.saturating_add(candidate.size_bytes);
180            if freed_bytes >= self.config.target_free_bytes {
181                break;
182            }
183        }
184
185        stats.selected_count = plan_candidates.len();
186        stats.estimated_freed_bytes = freed_bytes;
187
188        let plan = GCPlan {
189            candidates: plan_candidates,
190            estimated_freed_bytes: freed_bytes,
191        };
192
193        (plan, stats)
194    }
195
196    /// Estimate how long executing `plan` might take in milliseconds.
197    ///
198    /// Simple linear model:
199    /// - 1 ms per candidate block.
200    /// - 1 ms per 10 MiB (10 485 760 bytes) of data (integer division, no floats).
201    pub fn estimate_run_time_ms(&self, plan: &GCPlan) -> u64 {
202        let per_candidate = plan.candidate_count() as u64;
203        let per_data = plan.estimated_freed_bytes / 10_485_760;
204        per_candidate + per_data
205    }
206}
207
208// ─── Tests ────────────────────────────────────────────────────────────────────
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    // ── helpers ──────────────────────────────────────────────────────────────
215
216    fn make_candidate(
217        cid: &str,
218        size_bytes: u64,
219        ref_count: u32,
220        last_accessed_secs: u64,
221        pinned: bool,
222    ) -> GCCandidate {
223        GCCandidate {
224            cid: cid.to_string(),
225            size_bytes,
226            ref_count,
227            last_accessed_secs,
228            pinned,
229        }
230    }
231
232    fn default_planner() -> StorageGCPlanner {
233        StorageGCPlanner::new(GCConfig::default())
234    }
235
236    // ── construction ─────────────────────────────────────────────────────────
237
238    #[test]
239    fn test_new_stores_config() {
240        let config = GCConfig {
241            min_age_secs: 7_200,
242            target_free_bytes: 512 * 1024 * 1024,
243            max_candidates: 500,
244        };
245        let planner = StorageGCPlanner::new(config.clone());
246        assert_eq!(planner.config.min_age_secs, 7_200);
247        assert_eq!(planner.config.target_free_bytes, 512 * 1024 * 1024);
248        assert_eq!(planner.config.max_candidates, 500);
249    }
250
251    #[test]
252    fn test_gc_config_default_values() {
253        let cfg = GCConfig::default();
254        assert_eq!(cfg.min_age_secs, 3_600);
255        assert_eq!(cfg.target_free_bytes, 1_073_741_824);
256        assert_eq!(cfg.max_candidates, 1_000);
257    }
258
259    // ── empty input ───────────────────────────────────────────────────────────
260
261    #[test]
262    fn test_empty_input_produces_empty_plan() {
263        let planner = default_planner();
264        let (plan, stats) = planner.plan(&[], 10_000);
265        assert!(plan.is_empty());
266        assert_eq!(plan.candidate_count(), 0);
267        assert_eq!(plan.estimated_freed_bytes, 0);
268        assert_eq!(stats.total_analyzed, 0);
269        assert_eq!(stats.selected_count, 0);
270    }
271
272    // ── skip reasons ─────────────────────────────────────────────────────────
273
274    #[test]
275    fn test_pinned_blocks_skipped() {
276        let planner = default_planner();
277        let candidates = vec![make_candidate("c1", 1024, 0, 0, true)];
278        let (plan, stats) = planner.plan(&candidates, 10_000);
279        assert!(plan.is_empty());
280        assert_eq!(stats.pinned_skipped, 1);
281        assert_eq!(stats.referenced_skipped, 0);
282        assert_eq!(stats.too_young_skipped, 0);
283    }
284
285    #[test]
286    fn test_referenced_blocks_skipped() {
287        let planner = default_planner();
288        let candidates = vec![make_candidate("c1", 1024, 3, 0, false)];
289        let (plan, stats) = planner.plan(&candidates, 10_000);
290        assert!(plan.is_empty());
291        assert_eq!(stats.referenced_skipped, 1);
292        assert_eq!(stats.pinned_skipped, 0);
293        assert_eq!(stats.too_young_skipped, 0);
294    }
295
296    #[test]
297    fn test_too_young_blocks_skipped() {
298        let planner = default_planner(); // min_age = 3600
299                                         // age = 10_000 − 7_000 = 3_000 < 3_600 → too young
300        let candidates = vec![make_candidate("c1", 1024, 0, 7_000, false)];
301        let (plan, stats) = planner.plan(&candidates, 10_000);
302        assert!(plan.is_empty());
303        assert_eq!(stats.too_young_skipped, 1);
304    }
305
306    #[test]
307    fn test_exactly_at_min_age_is_skipped() {
308        let planner = default_planner(); // min_age = 3600
309                                         // age = 10_000 − 6_400 = 3_600 == min_age_secs
310                                         // The skip condition is `age < min_age_secs`, so age == min_age_secs
311                                         // is NOT skipped — the block is eligible and enters the plan.
312        let candidates = vec![make_candidate("c1", 1024, 0, 6_400, false)];
313        let (plan, stats) = planner.plan(&candidates, 10_000);
314        assert_eq!(
315            plan.candidate_count(),
316            1,
317            "block at exactly min_age must be included (skip condition is strictly less than)"
318        );
319        assert_eq!(stats.too_young_skipped, 0);
320        assert_eq!(stats.selected_count, 1);
321    }
322
323    #[test]
324    fn test_eligible_block_included() {
325        let planner = default_planner(); // min_age = 3600
326                                         // age = 10_000 − 6_000 = 4_000 > 3_600 → eligible
327        let candidates = vec![make_candidate("c1", 1024, 0, 6_000, false)];
328        let (plan, stats) = planner.plan(&candidates, 10_000);
329        assert_eq!(plan.candidate_count(), 1);
330        assert_eq!(stats.selected_count, 1);
331        assert_eq!(plan.candidates[0].cid, "c1");
332    }
333
334    // ── ordering ─────────────────────────────────────────────────────────────
335
336    #[test]
337    fn test_plan_sorted_oldest_first() {
338        let planner = default_planner();
339        // All are old enough (accessed at t=0, t=100, t=200; now=10_000)
340        let candidates = vec![
341            make_candidate("newer", 512, 0, 200, false),
342            make_candidate("oldest", 512, 0, 0, false),
343            make_candidate("middle", 512, 0, 100, false),
344        ];
345        let (plan, _) = planner.plan(&candidates, 10_000);
346        assert_eq!(plan.candidate_count(), 3);
347        assert_eq!(plan.candidates[0].cid, "oldest");
348        assert_eq!(plan.candidates[1].cid, "middle");
349        assert_eq!(plan.candidates[2].cid, "newer");
350    }
351
352    // ── stopping criteria ─────────────────────────────────────────────────────
353
354    #[test]
355    fn test_target_free_bytes_stops_early() {
356        let config = GCConfig {
357            min_age_secs: 0,
358            target_free_bytes: 1_000,
359            max_candidates: 1_000,
360        };
361        let planner = StorageGCPlanner::new(config);
362        let candidates = vec![
363            make_candidate("c1", 600, 0, 0, false),
364            make_candidate("c2", 600, 0, 1, false),
365            make_candidate("c3", 600, 0, 2, false),
366        ];
367        let (plan, stats) = planner.plan(&candidates, 100);
368        // c1 adds 600, c2 pushes total to 1200 ≥ 1000 → stop after c2
369        assert_eq!(plan.candidate_count(), 2);
370        assert_eq!(stats.selected_count, 2);
371        assert!(plan.estimated_freed_bytes >= 1_000);
372    }
373
374    #[test]
375    fn test_max_candidates_stops_early() {
376        let config = GCConfig {
377            min_age_secs: 0,
378            target_free_bytes: u64::MAX,
379            max_candidates: 2,
380        };
381        let planner = StorageGCPlanner::new(config);
382        let candidates: Vec<GCCandidate> = (0..5_u64)
383            .map(|i| make_candidate(&format!("c{i}"), 100, 0, i, false))
384            .collect();
385        let (plan, stats) = planner.plan(&candidates, 1_000);
386        assert_eq!(plan.candidate_count(), 2);
387        assert_eq!(stats.selected_count, 2);
388    }
389
390    // ── aggregate metrics ─────────────────────────────────────────────────────
391
392    #[test]
393    fn test_estimated_freed_bytes_sum_correct() {
394        let config = GCConfig {
395            min_age_secs: 0,
396            target_free_bytes: u64::MAX,
397            max_candidates: 1_000,
398        };
399        let planner = StorageGCPlanner::new(config);
400        let candidates = vec![
401            make_candidate("c1", 1_000, 0, 0, false),
402            make_candidate("c2", 2_000, 0, 1, false),
403            make_candidate("c3", 3_000, 0, 2, false),
404        ];
405        let (plan, stats) = planner.plan(&candidates, 10_000);
406        assert_eq!(plan.estimated_freed_bytes, 6_000);
407        assert_eq!(stats.estimated_freed_bytes, 6_000);
408    }
409
410    #[test]
411    fn test_is_empty_true_when_no_candidates() {
412        let plan = GCPlan::default();
413        assert!(plan.is_empty());
414    }
415
416    #[test]
417    fn test_candidate_count_correct() {
418        let planner = StorageGCPlanner::new(GCConfig {
419            min_age_secs: 0,
420            target_free_bytes: u64::MAX,
421            max_candidates: 1_000,
422        });
423        let candidates: Vec<GCCandidate> = (0..7_u64)
424            .map(|i| make_candidate(&format!("c{i}"), 1, 0, i, false))
425            .collect();
426        let (plan, _) = planner.plan(&candidates, 1_000);
427        assert_eq!(plan.candidate_count(), 7);
428    }
429
430    // ── stats correctness ─────────────────────────────────────────────────────
431
432    #[test]
433    fn test_stats_total_analyzed_correct() {
434        let planner = default_planner();
435        let candidates: Vec<GCCandidate> = (0..10_u64)
436            .map(|i| make_candidate(&format!("c{i}"), 1, 0, 0, false))
437            .collect();
438        let (_, stats) = planner.plan(&candidates, 10_000);
439        assert_eq!(stats.total_analyzed, 10);
440    }
441
442    #[test]
443    fn test_stats_pinned_skipped_correct() {
444        let planner = default_planner();
445        let candidates = vec![
446            make_candidate("p1", 1, 0, 0, true),
447            make_candidate("p2", 1, 0, 0, true),
448            make_candidate("ok", 1, 0, 0, false),
449        ];
450        let (_, stats) = planner.plan(&candidates, 10_000);
451        assert_eq!(stats.pinned_skipped, 2);
452    }
453
454    #[test]
455    fn test_stats_referenced_skipped_correct() {
456        let planner = default_planner();
457        let candidates = vec![
458            make_candidate("r1", 1, 1, 0, false),
459            make_candidate("r2", 1, 5, 0, false),
460            make_candidate("ok", 1, 0, 0, false),
461        ];
462        let (_, stats) = planner.plan(&candidates, 10_000);
463        assert_eq!(stats.referenced_skipped, 2);
464    }
465
466    #[test]
467    fn test_stats_too_young_skipped_correct() {
468        let planner = default_planner(); // min_age = 3600
469        let candidates = vec![
470            // age = 10000 - 8000 = 2000 < 3600 → too young
471            make_candidate("y1", 1, 0, 8_000, false),
472            // age = 10000 - 7000 = 3000 < 3600 → too young
473            make_candidate("y2", 1, 0, 7_000, false),
474            // age = 10000 - 0 = 10000 > 3600 → eligible
475            make_candidate("ok", 1, 0, 0, false),
476        ];
477        let (_, stats) = planner.plan(&candidates, 10_000);
478        assert_eq!(stats.too_young_skipped, 2);
479    }
480
481    #[test]
482    fn test_stats_selected_count_correct() {
483        let planner = StorageGCPlanner::new(GCConfig {
484            min_age_secs: 0,
485            target_free_bytes: u64::MAX,
486            max_candidates: 1_000,
487        });
488        let candidates: Vec<GCCandidate> = (0..6_u64)
489            .map(|i| make_candidate(&format!("c{i}"), 1, 0, i, false))
490            .collect();
491        let (_, stats) = planner.plan(&candidates, 1_000);
492        assert_eq!(stats.selected_count, 6);
493    }
494
495    // ── combined skip reasons ─────────────────────────────────────────────────
496
497    #[test]
498    fn test_multiple_skip_reasons_combine_correctly() {
499        let planner = default_planner(); // min_age = 3600, now = 10_000
500        let candidates = vec![
501            make_candidate("pinned", 1, 0, 0, true),     // pinned_skipped
502            make_candidate("ref'd", 1, 2, 0, false),     // referenced_skipped
503            make_candidate("young", 1, 0, 8_000, false), // too_young_skipped (age=2000)
504            make_candidate("eligible", 1_024, 0, 0, false), // selected
505        ];
506        let (plan, stats) = planner.plan(&candidates, 10_000);
507        assert_eq!(stats.total_analyzed, 4);
508        assert_eq!(stats.pinned_skipped, 1);
509        assert_eq!(stats.referenced_skipped, 1);
510        assert_eq!(stats.too_young_skipped, 1);
511        assert_eq!(stats.selected_count, 1);
512        assert_eq!(plan.candidate_count(), 1);
513        assert_eq!(plan.candidates[0].cid, "eligible");
514    }
515
516    // ── estimate_run_time_ms ──────────────────────────────────────────────────
517
518    #[test]
519    fn test_estimate_run_time_ms_basic() {
520        let planner = default_planner();
521        // 3 candidates, 30 MiB total → 3 + 3 = 6 ms
522        let plan = GCPlan {
523            candidates: vec![
524                make_candidate("c1", 10_485_760, 0, 0, false),
525                make_candidate("c2", 10_485_760, 0, 1, false),
526                make_candidate("c3", 10_485_760, 0, 2, false),
527            ],
528            estimated_freed_bytes: 31_457_280, // 30 MiB
529        };
530        let ms = planner.estimate_run_time_ms(&plan);
531        assert_eq!(ms, 6); // 3 (candidates) + 3 (data)
532    }
533
534    #[test]
535    fn test_estimate_run_time_ms_empty_plan() {
536        let planner = default_planner();
537        let plan = GCPlan::default();
538        assert_eq!(planner.estimate_run_time_ms(&plan), 0);
539    }
540
541    #[test]
542    fn test_estimate_run_time_ms_large_data() {
543        let planner = default_planner();
544        // 1 candidate, 100 MiB (104_857_600 bytes) → 1 + 10 = 11 ms
545        let plan = GCPlan {
546            candidates: vec![make_candidate("big", 104_857_600, 0, 0, false)],
547            estimated_freed_bytes: 104_857_600,
548        };
549        let ms = planner.estimate_run_time_ms(&plan);
550        assert_eq!(ms, 11); // 1 (candidate) + 10 (10 × 10 MiB)
551    }
552
553    // ── edge cases ────────────────────────────────────────────────────────────
554
555    #[test]
556    fn test_future_last_accessed_treated_as_too_young() {
557        // last_accessed_secs > now_secs → saturating_sub gives 0 → age 0 < min_age
558        let planner = default_planner();
559        let candidates = vec![make_candidate("future", 1_024, 0, 99_999, false)];
560        let (plan, stats) = planner.plan(&candidates, 10_000);
561        assert!(plan.is_empty());
562        assert_eq!(stats.too_young_skipped, 1);
563    }
564
565    #[test]
566    fn test_ref_count_one_still_skipped() {
567        let planner = default_planner();
568        let candidates = vec![make_candidate("one_ref", 512, 1, 0, false)];
569        let (plan, stats) = planner.plan(&candidates, 10_000);
570        assert!(plan.is_empty());
571        assert_eq!(stats.referenced_skipped, 1);
572    }
573}