Skip to main content

ipfrs_storage/
retention_engine.rs

1//! Retention Policy Engine for IPFRS block storage.
2//!
3//! Evaluates which blocks to retain or expire based on configurable retention policies,
4//! supporting pinning, TTLs, access-frequency thresholds, and size limits.
5
6/// Decision made by the retention policy engine for a block.
7#[derive(Debug, Clone, PartialEq)]
8pub enum RetentionDecision {
9    /// Keep the block with the given reason.
10    Keep { reason: String },
11    /// Expire (delete) the block with the given reason.
12    Expire { reason: String },
13    /// Defer the decision until the given Unix timestamp (seconds).
14    Defer { until_secs: u64 },
15}
16
17/// Record describing a stored block and its metadata.
18#[derive(Debug, Clone)]
19pub struct BlockRecord {
20    /// Content Identifier of the block.
21    pub cid: String,
22    /// Size of the block in bytes.
23    pub size_bytes: u64,
24    /// Unix timestamp (seconds) when the block was created.
25    pub created_at_secs: u64,
26    /// Unix timestamp (seconds) when the block was last accessed.
27    pub last_accessed_secs: u64,
28    /// Number of times this block has been accessed.
29    pub access_count: u64,
30    /// Whether this block is pinned (protected from GC).
31    pub is_pinned: bool,
32    /// Arbitrary tags attached to this block (e.g., "index", "checkpoint").
33    pub tags: Vec<String>,
34}
35
36impl BlockRecord {
37    /// Returns the age of the block in seconds relative to `now_secs`.
38    /// Uses saturating subtraction so it never underflows.
39    pub fn age_secs(&self, now_secs: u64) -> u64 {
40        now_secs.saturating_sub(self.created_at_secs)
41    }
42}
43
44/// A single retention rule that can match a block and produce a decision.
45#[derive(Debug, Clone)]
46pub enum RetentionRule {
47    /// Keep any block that is pinned.
48    PinProtected,
49    /// Expire blocks whose age exceeds the given number of seconds.
50    MaxAge { secs: u64 },
51    /// Expire blocks whose access count is below the threshold AND that are older than 3600 s.
52    MinAccessCount { count: u64 },
53    /// Expire blocks that exceed the given size in bytes.
54    MaxSize { bytes: u64 },
55    /// Keep if the block has the required tag; expire otherwise.
56    TagRequires { tag: String },
57    /// Expire if the block has the excluded tag; no match (continue) otherwise.
58    TagExcludes { tag: String },
59}
60
61/// Configuration for the `RetentionPolicyEngine`.
62#[derive(Debug, Clone)]
63pub struct PolicyConfig {
64    /// Ordered list of rules; the first matching rule wins.
65    pub rules: Vec<RetentionRule>,
66    /// Decision to return when no rule matches.
67    pub default_decision: RetentionDecision,
68    /// How far into the future (seconds) a `Defer` decision should defer to.
69    pub defer_window_secs: u64,
70}
71
72impl Default for PolicyConfig {
73    fn default() -> Self {
74        Self {
75            rules: Vec::new(),
76            default_decision: RetentionDecision::Keep {
77                reason: "default".to_string(),
78            },
79            defer_window_secs: 3600,
80        }
81    }
82}
83
84/// Aggregate statistics for the retention engine since last reset.
85#[derive(Debug, Clone, Default)]
86pub struct RetentionEngineStats {
87    /// Total number of blocks evaluated.
88    pub evaluated: u64,
89    /// Number of blocks that received a `Keep` decision.
90    pub kept: u64,
91    /// Number of blocks that received an `Expire` decision.
92    pub expired: u64,
93    /// Number of blocks that received a `Defer` decision.
94    pub deferred: u64,
95}
96
97/// Engine that evaluates block retention decisions against a `PolicyConfig`.
98#[derive(Debug)]
99pub struct RetentionPolicyEngine {
100    /// Policy configuration driving all evaluations.
101    pub config: PolicyConfig,
102    /// Running statistics since the last `reset_stats()`.
103    pub stats: RetentionEngineStats,
104}
105
106impl RetentionPolicyEngine {
107    /// Create a new engine with the given configuration.
108    pub fn new(config: PolicyConfig) -> Self {
109        Self {
110            config,
111            stats: RetentionEngineStats::default(),
112        }
113    }
114
115    /// Evaluate the retention decision for a single `BlockRecord`.
116    ///
117    /// Rules are evaluated in order; the first matching rule wins.
118    /// If no rule matches, `config.default_decision` is returned.
119    /// Statistics are updated after every call.
120    pub fn evaluate(&mut self, record: &BlockRecord, now_secs: u64) -> RetentionDecision {
121        let decision = self.evaluate_inner(record, now_secs);
122        self.stats.evaluated += 1;
123        match &decision {
124            RetentionDecision::Keep { .. } => self.stats.kept += 1,
125            RetentionDecision::Expire { .. } => self.stats.expired += 1,
126            RetentionDecision::Defer { .. } => self.stats.deferred += 1,
127        }
128        decision
129    }
130
131    /// Inner evaluation logic (does not touch stats).
132    fn evaluate_inner(&self, record: &BlockRecord, now_secs: u64) -> RetentionDecision {
133        let age = record.age_secs(now_secs);
134
135        for rule in &self.config.rules {
136            match rule {
137                RetentionRule::PinProtected => {
138                    if record.is_pinned {
139                        return RetentionDecision::Keep {
140                            reason: "pinned".to_string(),
141                        };
142                    }
143                    // Not pinned — rule does not match; continue.
144                }
145
146                RetentionRule::MaxAge { secs } => {
147                    if age > *secs {
148                        return RetentionDecision::Expire {
149                            reason: "max_age exceeded".to_string(),
150                        };
151                    }
152                    // Young enough — rule does not match; continue.
153                }
154
155                RetentionRule::MinAccessCount { count } => {
156                    if record.access_count < *count && age > 3600 {
157                        return RetentionDecision::Expire {
158                            reason: "low access count".to_string(),
159                        };
160                    }
161                    // Either access_count is sufficient or block is too young — no match; continue.
162                }
163
164                RetentionRule::MaxSize { bytes } => {
165                    if record.size_bytes > *bytes {
166                        return RetentionDecision::Expire {
167                            reason: "oversized block".to_string(),
168                        };
169                    }
170                    // Within size limit — no match; continue.
171                }
172
173                RetentionRule::TagRequires { tag } => {
174                    // This rule always matches (either keep or expire).
175                    if record.tags.contains(tag) {
176                        return RetentionDecision::Keep {
177                            reason: "has required tag".to_string(),
178                        };
179                    } else {
180                        return RetentionDecision::Expire {
181                            reason: "missing required tag".to_string(),
182                        };
183                    }
184                }
185
186                RetentionRule::TagExcludes { tag } => {
187                    if record.tags.contains(tag) {
188                        return RetentionDecision::Expire {
189                            reason: "excluded tag".to_string(),
190                        };
191                    }
192                    // Does not have the excluded tag — no match; continue.
193                }
194            }
195        }
196
197        // No rule matched.
198        self.config.default_decision.clone()
199    }
200
201    /// Evaluate all records in `records` and return `(cid, decision)` pairs.
202    pub fn evaluate_batch(
203        &mut self,
204        records: &[BlockRecord],
205        now_secs: u64,
206    ) -> Vec<(String, RetentionDecision)> {
207        records
208            .iter()
209            .map(|r| {
210                let decision = self.evaluate(r, now_secs);
211                (r.cid.clone(), decision)
212            })
213            .collect()
214    }
215
216    /// Return references to all blocks that should be expired.
217    pub fn blocks_to_expire<'a>(
218        &mut self,
219        records: &'a [BlockRecord],
220        now_secs: u64,
221    ) -> Vec<&'a BlockRecord> {
222        records
223            .iter()
224            .filter(|r| matches!(self.evaluate(r, now_secs), RetentionDecision::Expire { .. }))
225            .collect()
226    }
227
228    /// Return a reference to the current statistics.
229    pub fn stats(&self) -> &RetentionEngineStats {
230        &self.stats
231    }
232
233    /// Reset all statistics counters to zero.
234    pub fn reset_stats(&mut self) {
235        self.stats = RetentionEngineStats::default();
236    }
237}
238
239// ── Tests ────────────────────────────────────────────────────────────────────
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    /// Convenience builder for `BlockRecord` with sensible defaults.
246    fn make_record(cid: &str) -> BlockRecord {
247        BlockRecord {
248            cid: cid.to_string(),
249            size_bytes: 1024,
250            created_at_secs: 0,
251            last_accessed_secs: 0,
252            access_count: 10,
253            is_pinned: false,
254            tags: Vec::new(),
255        }
256    }
257
258    // ── new() ────────────────────────────────────────────────────────────────
259
260    #[test]
261    fn test_new_with_config() {
262        let config = PolicyConfig::default();
263        let engine = RetentionPolicyEngine::new(config);
264        assert_eq!(engine.stats().evaluated, 0);
265        assert_eq!(engine.stats().kept, 0);
266        assert_eq!(engine.stats().expired, 0);
267        assert_eq!(engine.stats().deferred, 0);
268    }
269
270    // ── PinProtected ─────────────────────────────────────────────────────────
271
272    #[test]
273    fn test_pin_protected_keeps_pinned_block() {
274        let config = PolicyConfig {
275            rules: vec![RetentionRule::PinProtected],
276            default_decision: RetentionDecision::Expire {
277                reason: "default".to_string(),
278            },
279            defer_window_secs: 3600,
280        };
281        let mut engine = RetentionPolicyEngine::new(config);
282        let mut record = make_record("cid-pinned");
283        record.is_pinned = true;
284
285        let decision = engine.evaluate(&record, 9999);
286        assert!(matches!(decision, RetentionDecision::Keep { .. }));
287        if let RetentionDecision::Keep { reason } = decision {
288            assert_eq!(reason, "pinned");
289        }
290    }
291
292    #[test]
293    fn test_pin_protected_skips_non_pinned() {
294        let config = PolicyConfig {
295            rules: vec![RetentionRule::PinProtected],
296            default_decision: RetentionDecision::Expire {
297                reason: "default expire".to_string(),
298            },
299            defer_window_secs: 3600,
300        };
301        let mut engine = RetentionPolicyEngine::new(config);
302        let record = make_record("cid-unpinned"); // is_pinned == false
303
304        let decision = engine.evaluate(&record, 9999);
305        // PinProtected does not match → falls through to default (Expire)
306        assert!(matches!(decision, RetentionDecision::Expire { .. }));
307    }
308
309    // ── MaxAge ────────────────────────────────────────────────────────────────
310
311    #[test]
312    fn test_max_age_expires_old_block() {
313        let config = PolicyConfig {
314            rules: vec![RetentionRule::MaxAge { secs: 3600 }],
315            default_decision: RetentionDecision::Keep {
316                reason: "default".to_string(),
317            },
318            defer_window_secs: 3600,
319        };
320        let mut engine = RetentionPolicyEngine::new(config);
321        let mut record = make_record("cid-old");
322        record.created_at_secs = 0;
323
324        let now = 7201; // age = 7201 > 3600
325        let decision = engine.evaluate(&record, now);
326        assert!(matches!(decision, RetentionDecision::Expire { .. }));
327        if let RetentionDecision::Expire { reason } = decision {
328            assert_eq!(reason, "max_age exceeded");
329        }
330    }
331
332    #[test]
333    fn test_max_age_keeps_young_block() {
334        let config = PolicyConfig {
335            rules: vec![RetentionRule::MaxAge { secs: 3600 }],
336            default_decision: RetentionDecision::Keep {
337                reason: "default".to_string(),
338            },
339            defer_window_secs: 3600,
340        };
341        let mut engine = RetentionPolicyEngine::new(config);
342        let mut record = make_record("cid-young");
343        record.created_at_secs = 1000;
344
345        let now = 2000; // age = 1000 <= 3600
346        let decision = engine.evaluate(&record, now);
347        // MaxAge does not match → falls through to Keep default
348        assert!(matches!(decision, RetentionDecision::Keep { .. }));
349    }
350
351    // ── MinAccessCount ────────────────────────────────────────────────────────
352
353    #[test]
354    fn test_min_access_count_expires_low_access_old_block() {
355        let config = PolicyConfig {
356            rules: vec![RetentionRule::MinAccessCount { count: 5 }],
357            default_decision: RetentionDecision::Keep {
358                reason: "default".to_string(),
359            },
360            defer_window_secs: 3600,
361        };
362        let mut engine = RetentionPolicyEngine::new(config);
363        let mut record = make_record("cid-low-access");
364        record.access_count = 2; // < 5
365        record.created_at_secs = 0;
366
367        let now = 7200; // age = 7200 > 3600
368        let decision = engine.evaluate(&record, now);
369        assert!(matches!(decision, RetentionDecision::Expire { .. }));
370        if let RetentionDecision::Expire { reason } = decision {
371            assert_eq!(reason, "low access count");
372        }
373    }
374
375    #[test]
376    fn test_min_access_count_keeps_recently_created_block() {
377        let config = PolicyConfig {
378            rules: vec![RetentionRule::MinAccessCount { count: 5 }],
379            default_decision: RetentionDecision::Keep {
380                reason: "default".to_string(),
381            },
382            defer_window_secs: 3600,
383        };
384        let mut engine = RetentionPolicyEngine::new(config);
385        let mut record = make_record("cid-new");
386        record.access_count = 2; // < 5, but age <= 3600
387        record.created_at_secs = 500;
388
389        let now = 1000; // age = 500 <= 3600
390        let decision = engine.evaluate(&record, now);
391        // Rule does not match (age <= 3600) → default Keep
392        assert!(matches!(decision, RetentionDecision::Keep { .. }));
393    }
394
395    // ── MaxSize ───────────────────────────────────────────────────────────────
396
397    #[test]
398    fn test_max_size_expires_large_block() {
399        let config = PolicyConfig {
400            rules: vec![RetentionRule::MaxSize { bytes: 512 }],
401            default_decision: RetentionDecision::Keep {
402                reason: "default".to_string(),
403            },
404            defer_window_secs: 3600,
405        };
406        let mut engine = RetentionPolicyEngine::new(config);
407        let mut record = make_record("cid-large");
408        record.size_bytes = 1024; // > 512
409
410        let decision = engine.evaluate(&record, 9999);
411        assert!(matches!(decision, RetentionDecision::Expire { .. }));
412        if let RetentionDecision::Expire { reason } = decision {
413            assert_eq!(reason, "oversized block");
414        }
415    }
416
417    // ── TagRequires ───────────────────────────────────────────────────────────
418
419    #[test]
420    fn test_tag_requires_keeps_matching_block() {
421        let config = PolicyConfig {
422            rules: vec![RetentionRule::TagRequires {
423                tag: "checkpoint".to_string(),
424            }],
425            default_decision: RetentionDecision::Expire {
426                reason: "default".to_string(),
427            },
428            defer_window_secs: 3600,
429        };
430        let mut engine = RetentionPolicyEngine::new(config);
431        let mut record = make_record("cid-checkpoint");
432        record.tags = vec!["checkpoint".to_string(), "index".to_string()];
433
434        let decision = engine.evaluate(&record, 9999);
435        assert!(matches!(decision, RetentionDecision::Keep { .. }));
436        if let RetentionDecision::Keep { reason } = decision {
437            assert_eq!(reason, "has required tag");
438        }
439    }
440
441    #[test]
442    fn test_tag_requires_expires_non_matching() {
443        let config = PolicyConfig {
444            rules: vec![RetentionRule::TagRequires {
445                tag: "checkpoint".to_string(),
446            }],
447            default_decision: RetentionDecision::Keep {
448                reason: "default".to_string(),
449            },
450            defer_window_secs: 3600,
451        };
452        let mut engine = RetentionPolicyEngine::new(config);
453        let record = make_record("cid-no-checkpoint"); // no tags
454
455        let decision = engine.evaluate(&record, 9999);
456        assert!(matches!(decision, RetentionDecision::Expire { .. }));
457        if let RetentionDecision::Expire { reason } = decision {
458            assert_eq!(reason, "missing required tag");
459        }
460    }
461
462    // ── TagExcludes ───────────────────────────────────────────────────────────
463
464    #[test]
465    fn test_tag_excludes_expires_matching_block() {
466        let config = PolicyConfig {
467            rules: vec![RetentionRule::TagExcludes {
468                tag: "temp".to_string(),
469            }],
470            default_decision: RetentionDecision::Keep {
471                reason: "default".to_string(),
472            },
473            defer_window_secs: 3600,
474        };
475        let mut engine = RetentionPolicyEngine::new(config);
476        let mut record = make_record("cid-temp");
477        record.tags = vec!["temp".to_string()];
478
479        let decision = engine.evaluate(&record, 9999);
480        assert!(matches!(decision, RetentionDecision::Expire { .. }));
481        if let RetentionDecision::Expire { reason } = decision {
482            assert_eq!(reason, "excluded tag");
483        }
484    }
485
486    #[test]
487    fn test_tag_excludes_skips_non_matching_continues() {
488        let config = PolicyConfig {
489            rules: vec![
490                RetentionRule::TagExcludes {
491                    tag: "temp".to_string(),
492                },
493                RetentionRule::PinProtected,
494            ],
495            default_decision: RetentionDecision::Expire {
496                reason: "default".to_string(),
497            },
498            defer_window_secs: 3600,
499        };
500        let mut engine = RetentionPolicyEngine::new(config);
501        let mut record = make_record("cid-pinned-no-temp");
502        record.is_pinned = true;
503        // No "temp" tag → TagExcludes does not match → PinProtected matches → Keep
504
505        let decision = engine.evaluate(&record, 9999);
506        assert!(matches!(decision, RetentionDecision::Keep { .. }));
507    }
508
509    // ── No rules / default ────────────────────────────────────────────────────
510
511    #[test]
512    fn test_no_rules_returns_default_decision() {
513        let config = PolicyConfig {
514            rules: Vec::new(),
515            default_decision: RetentionDecision::Defer { until_secs: 99999 },
516            defer_window_secs: 3600,
517        };
518        let mut engine = RetentionPolicyEngine::new(config);
519        let record = make_record("cid-any");
520
521        let decision = engine.evaluate(&record, 0);
522        assert!(matches!(
523            decision,
524            RetentionDecision::Defer { until_secs: 99999 }
525        ));
526    }
527
528    // ── Rule order ────────────────────────────────────────────────────────────
529
530    #[test]
531    fn test_rule_order_first_match_wins() {
532        // PinProtected appears before MaxSize; pinned block should be Kept, not Expired.
533        let config = PolicyConfig {
534            rules: vec![
535                RetentionRule::PinProtected,
536                RetentionRule::MaxSize { bytes: 0 }, // would expire everything
537            ],
538            default_decision: RetentionDecision::Expire {
539                reason: "default".to_string(),
540            },
541            defer_window_secs: 3600,
542        };
543        let mut engine = RetentionPolicyEngine::new(config);
544        let mut record = make_record("cid-pinned-large");
545        record.is_pinned = true;
546        record.size_bytes = 99999;
547
548        let decision = engine.evaluate(&record, 9999);
549        assert!(matches!(decision, RetentionDecision::Keep { .. }));
550    }
551
552    // ── evaluate_batch ────────────────────────────────────────────────────────
553
554    #[test]
555    fn test_evaluate_batch_returns_correct_pairs() {
556        let config = PolicyConfig {
557            rules: vec![RetentionRule::MaxSize { bytes: 500 }],
558            default_decision: RetentionDecision::Keep {
559                reason: "default".to_string(),
560            },
561            defer_window_secs: 3600,
562        };
563        let mut engine = RetentionPolicyEngine::new(config);
564
565        let mut small = make_record("small");
566        small.size_bytes = 100;
567        let mut large = make_record("large");
568        large.size_bytes = 1000;
569
570        let results = engine.evaluate_batch(&[small, large], 9999);
571        assert_eq!(results.len(), 2);
572
573        let small_result = results.iter().find(|(cid, _)| cid == "small").unwrap();
574        assert!(matches!(small_result.1, RetentionDecision::Keep { .. }));
575
576        let large_result = results.iter().find(|(cid, _)| cid == "large").unwrap();
577        assert!(matches!(large_result.1, RetentionDecision::Expire { .. }));
578    }
579
580    // ── blocks_to_expire ──────────────────────────────────────────────────────
581
582    #[test]
583    fn test_blocks_to_expire_filters_correctly() {
584        let config = PolicyConfig {
585            rules: vec![RetentionRule::MaxSize { bytes: 500 }],
586            default_decision: RetentionDecision::Keep {
587                reason: "default".to_string(),
588            },
589            defer_window_secs: 3600,
590        };
591        let mut engine = RetentionPolicyEngine::new(config);
592
593        let mut small = make_record("small");
594        small.size_bytes = 100;
595        let mut large = make_record("large");
596        large.size_bytes = 1000;
597
598        let records = vec![small, large];
599        let to_expire = engine.blocks_to_expire(&records, 9999);
600        assert_eq!(to_expire.len(), 1);
601        assert_eq!(to_expire[0].cid, "large");
602    }
603
604    // ── Stats ─────────────────────────────────────────────────────────────────
605
606    #[test]
607    fn test_stats_updated_correctly() {
608        let config = PolicyConfig {
609            rules: vec![
610                RetentionRule::PinProtected,
611                RetentionRule::MaxSize { bytes: 500 },
612            ],
613            default_decision: RetentionDecision::Defer { until_secs: 9999 },
614            defer_window_secs: 3600,
615        };
616        let mut engine = RetentionPolicyEngine::new(config);
617
618        // Block 1: pinned → Keep
619        let mut pinned = make_record("cid-pinned");
620        pinned.is_pinned = true;
621        engine.evaluate(&pinned, 0);
622
623        // Block 2: large → Expire
624        let mut large = make_record("cid-large");
625        large.size_bytes = 1000;
626        engine.evaluate(&large, 0);
627
628        // Block 3: small, not pinned → Defer (default)
629        let mut small = make_record("cid-small");
630        small.size_bytes = 100;
631        engine.evaluate(&small, 0);
632
633        let stats = engine.stats();
634        assert_eq!(stats.evaluated, 3);
635        assert_eq!(stats.kept, 1);
636        assert_eq!(stats.expired, 1);
637        assert_eq!(stats.deferred, 1);
638    }
639
640    #[test]
641    fn test_reset_stats_zeroes_counters() {
642        let config = PolicyConfig {
643            rules: vec![RetentionRule::PinProtected],
644            default_decision: RetentionDecision::Keep {
645                reason: "default".to_string(),
646            },
647            defer_window_secs: 3600,
648        };
649        let mut engine = RetentionPolicyEngine::new(config);
650
651        let record = make_record("cid-any");
652        engine.evaluate(&record, 0);
653        engine.evaluate(&record, 0);
654
655        assert_eq!(engine.stats().evaluated, 2);
656
657        engine.reset_stats();
658
659        let stats = engine.stats();
660        assert_eq!(stats.evaluated, 0);
661        assert_eq!(stats.kept, 0);
662        assert_eq!(stats.expired, 0);
663        assert_eq!(stats.deferred, 0);
664    }
665}