1#[derive(Debug, Clone, PartialEq)]
8pub enum RetentionDecision {
9 Keep { reason: String },
11 Expire { reason: String },
13 Defer { until_secs: u64 },
15}
16
17#[derive(Debug, Clone)]
19pub struct BlockRecord {
20 pub cid: String,
22 pub size_bytes: u64,
24 pub created_at_secs: u64,
26 pub last_accessed_secs: u64,
28 pub access_count: u64,
30 pub is_pinned: bool,
32 pub tags: Vec<String>,
34}
35
36impl BlockRecord {
37 pub fn age_secs(&self, now_secs: u64) -> u64 {
40 now_secs.saturating_sub(self.created_at_secs)
41 }
42}
43
44#[derive(Debug, Clone)]
46pub enum RetentionRule {
47 PinProtected,
49 MaxAge { secs: u64 },
51 MinAccessCount { count: u64 },
53 MaxSize { bytes: u64 },
55 TagRequires { tag: String },
57 TagExcludes { tag: String },
59}
60
61#[derive(Debug, Clone)]
63pub struct PolicyConfig {
64 pub rules: Vec<RetentionRule>,
66 pub default_decision: RetentionDecision,
68 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#[derive(Debug, Clone, Default)]
86pub struct RetentionEngineStats {
87 pub evaluated: u64,
89 pub kept: u64,
91 pub expired: u64,
93 pub deferred: u64,
95}
96
97#[derive(Debug)]
99pub struct RetentionPolicyEngine {
100 pub config: PolicyConfig,
102 pub stats: RetentionEngineStats,
104}
105
106impl RetentionPolicyEngine {
107 pub fn new(config: PolicyConfig) -> Self {
109 Self {
110 config,
111 stats: RetentionEngineStats::default(),
112 }
113 }
114
115 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 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 }
145
146 RetentionRule::MaxAge { secs } => {
147 if age > *secs {
148 return RetentionDecision::Expire {
149 reason: "max_age exceeded".to_string(),
150 };
151 }
152 }
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 }
163
164 RetentionRule::MaxSize { bytes } => {
165 if record.size_bytes > *bytes {
166 return RetentionDecision::Expire {
167 reason: "oversized block".to_string(),
168 };
169 }
170 }
172
173 RetentionRule::TagRequires { tag } => {
174 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 }
194 }
195 }
196
197 self.config.default_decision.clone()
199 }
200
201 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 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 pub fn stats(&self) -> &RetentionEngineStats {
230 &self.stats
231 }
232
233 pub fn reset_stats(&mut self) {
235 self.stats = RetentionEngineStats::default();
236 }
237}
238
239#[cfg(test)]
242mod tests {
243 use super::*;
244
245 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 #[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 #[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"); let decision = engine.evaluate(&record, 9999);
305 assert!(matches!(decision, RetentionDecision::Expire { .. }));
307 }
308
309 #[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; 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; let decision = engine.evaluate(&record, now);
347 assert!(matches!(decision, RetentionDecision::Keep { .. }));
349 }
350
351 #[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; record.created_at_secs = 0;
366
367 let now = 7200; 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; record.created_at_secs = 500;
388
389 let now = 1000; let decision = engine.evaluate(&record, now);
391 assert!(matches!(decision, RetentionDecision::Keep { .. }));
393 }
394
395 #[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; 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 #[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"); 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 #[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 let decision = engine.evaluate(&record, 9999);
506 assert!(matches!(decision, RetentionDecision::Keep { .. }));
507 }
508
509 #[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 #[test]
531 fn test_rule_order_first_match_wins() {
532 let config = PolicyConfig {
534 rules: vec![
535 RetentionRule::PinProtected,
536 RetentionRule::MaxSize { bytes: 0 }, ],
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 #[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 #[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 #[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 let mut pinned = make_record("cid-pinned");
620 pinned.is_pinned = true;
621 engine.evaluate(&pinned, 0);
622
623 let mut large = make_record("cid-large");
625 large.size_bytes = 1000;
626 engine.evaluate(&large, 0);
627
628 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}