1#[derive(Debug, Clone, PartialEq)]
8pub enum RetentionAction {
9 Keep,
11 Archive,
13 Delete,
15 Warn { reason: String },
17}
18
19#[derive(Debug, Clone)]
21pub struct RetentionRule {
22 pub rule_id: u64,
24 pub name: String,
26 pub max_age_secs: Option<u64>,
28 pub max_size_bytes: Option<u64>,
30 pub required_tag: Option<String>,
32 pub action: RetentionAction,
34 pub priority: u32,
36}
37
38#[derive(Debug, Clone)]
40pub struct BlockRecord {
41 pub cid: String,
43 pub size_bytes: u64,
45 pub created_at_secs: u64,
47 pub last_accessed_secs: u64,
49 pub tags: Vec<String>,
51 pub pinned: bool,
53}
54
55#[derive(Debug, Clone, PartialEq)]
57pub struct PolicyDecision {
58 pub cid: String,
60 pub action: RetentionAction,
62 pub matched_rule_id: Option<u64>,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct RetentionStats {
69 pub total_evaluated: usize,
71 pub keep_count: usize,
73 pub archive_count: usize,
75 pub delete_count: usize,
77 pub warn_count: usize,
79}
80
81#[derive(Debug, Clone)]
87pub struct StorageRetentionPolicyEngine {
88 pub rules: Vec<RetentionRule>,
90}
91
92impl StorageRetentionPolicyEngine {
93 pub fn new() -> Self {
95 Self { rules: Vec::new() }
96 }
97
98 pub fn add_rule(&mut self, rule: RetentionRule) {
103 let pos = self
106 .rules
107 .iter()
108 .position(|r| r.priority < rule.priority)
109 .unwrap_or(self.rules.len());
110 self.rules.insert(pos, rule);
111 }
112
113 pub fn remove_rule(&mut self, rule_id: u64) -> bool {
117 if let Some(pos) = self.rules.iter().position(|r| r.rule_id == rule_id) {
118 self.rules.remove(pos);
119 true
120 } else {
121 false
122 }
123 }
124
125 pub fn evaluate(&self, block: &BlockRecord, now_secs: u64) -> PolicyDecision {
132 if block.pinned {
133 return PolicyDecision {
134 cid: block.cid.clone(),
135 action: RetentionAction::Keep,
136 matched_rule_id: None,
137 };
138 }
139
140 for rule in &self.rules {
141 if self.rule_matches(rule, block, now_secs) {
142 return PolicyDecision {
143 cid: block.cid.clone(),
144 action: rule.action.clone(),
145 matched_rule_id: Some(rule.rule_id),
146 };
147 }
148 }
149
150 PolicyDecision {
152 cid: block.cid.clone(),
153 action: RetentionAction::Keep,
154 matched_rule_id: None,
155 }
156 }
157
158 pub fn evaluate_all(&self, blocks: &[BlockRecord], now_secs: u64) -> Vec<PolicyDecision> {
160 blocks.iter().map(|b| self.evaluate(b, now_secs)).collect()
161 }
162
163 pub fn stats(&self, decisions: &[PolicyDecision]) -> RetentionStats {
165 let mut keep_count = 0usize;
166 let mut archive_count = 0usize;
167 let mut delete_count = 0usize;
168 let mut warn_count = 0usize;
169
170 for decision in decisions {
171 match &decision.action {
172 RetentionAction::Keep => keep_count += 1,
173 RetentionAction::Archive => archive_count += 1,
174 RetentionAction::Delete => delete_count += 1,
175 RetentionAction::Warn { .. } => warn_count += 1,
176 }
177 }
178
179 RetentionStats {
180 total_evaluated: decisions.len(),
181 keep_count,
182 archive_count,
183 delete_count,
184 warn_count,
185 }
186 }
187
188 fn rule_matches(&self, rule: &RetentionRule, block: &BlockRecord, now_secs: u64) -> bool {
193 if let Some(max_age) = rule.max_age_secs {
195 let age = now_secs.saturating_sub(block.created_at_secs);
196 if age <= max_age {
197 return false;
198 }
199 }
200
201 if let Some(max_size) = rule.max_size_bytes {
203 if block.size_bytes <= max_size {
204 return false;
205 }
206 }
207
208 if let Some(ref tag) = rule.required_tag {
210 if !block.tags.contains(tag) {
211 return false;
212 }
213 }
214
215 true
216 }
217}
218
219impl Default for StorageRetentionPolicyEngine {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225use std::collections::HashMap;
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum TickRetentionAction {
234 Keep,
236 Archive,
238 Delete,
240}
241
242#[derive(Debug, Clone)]
244pub struct TickRetentionRule {
245 pub name: String,
247 pub max_age_ticks: Option<u64>,
249 pub max_size_bytes: Option<u64>,
251 pub action: TickRetentionAction,
253 pub priority: u32,
255}
256
257#[derive(Debug, Clone)]
259pub struct RetentionEntry {
260 pub block_cid: String,
262 pub size_bytes: u64,
264 pub created_tick: u64,
266 pub last_accessed_tick: u64,
268 pub pinned: bool,
270}
271
272#[derive(Debug, Clone)]
274pub struct RetentionPolicyStats {
275 pub entry_count: usize,
277 pub rule_count: usize,
279 pub total_deleted: u64,
281 pub total_archived: u64,
283 pub bytes_freed: u64,
285}
286
287pub struct StorageRetentionPolicy {
293 rules: Vec<TickRetentionRule>,
294 entries: HashMap<String, RetentionEntry>,
295 current_tick: u64,
296 total_deleted: u64,
297 total_archived: u64,
298 bytes_freed: u64,
299}
300
301impl StorageRetentionPolicy {
302 pub fn new() -> Self {
304 Self {
305 rules: Vec::new(),
306 entries: HashMap::new(),
307 current_tick: 0,
308 total_deleted: 0,
309 total_archived: 0,
310 bytes_freed: 0,
311 }
312 }
313
314 pub fn add_rule(&mut self, rule: TickRetentionRule) {
317 self.rules.retain(|r| r.name != rule.name);
319 let pos = self
320 .rules
321 .iter()
322 .position(|r| r.priority < rule.priority)
323 .unwrap_or(self.rules.len());
324 self.rules.insert(pos, rule);
325 }
326
327 pub fn remove_rule(&mut self, name: &str) -> bool {
330 let before = self.rules.len();
331 self.rules.retain(|r| r.name != name);
332 self.rules.len() < before
333 }
334
335 pub fn add_entry(&mut self, block_cid: &str, size_bytes: u64, created_tick: u64) {
337 let entry = RetentionEntry {
338 block_cid: block_cid.to_string(),
339 size_bytes,
340 created_tick,
341 last_accessed_tick: created_tick,
342 pinned: false,
343 };
344 self.entries.insert(block_cid.to_string(), entry);
345 }
346
347 pub fn pin(&mut self, block_cid: &str) -> bool {
350 if let Some(entry) = self.entries.get_mut(block_cid) {
351 entry.pinned = true;
352 true
353 } else {
354 false
355 }
356 }
357
358 pub fn unpin(&mut self, block_cid: &str) -> bool {
361 if let Some(entry) = self.entries.get_mut(block_cid) {
362 entry.pinned = false;
363 true
364 } else {
365 false
366 }
367 }
368
369 pub fn evaluate(&self, block_cid: &str) -> TickRetentionAction {
375 let entry = match self.entries.get(block_cid) {
376 Some(e) => e,
377 None => return TickRetentionAction::Keep,
378 };
379
380 if entry.pinned {
381 return TickRetentionAction::Keep;
382 }
383
384 let total_size = self.total_size_bytes();
385
386 for rule in &self.rules {
387 if self.tick_rule_matches(rule, entry, total_size) {
388 return rule.action;
389 }
390 }
391
392 TickRetentionAction::Keep
393 }
394
395 pub fn enforce(&mut self) -> Vec<(String, TickRetentionAction)> {
401 let total_size = self.total_size_bytes();
402 let cids: Vec<String> = self.entries.keys().cloned().collect();
403
404 let mut actions = Vec::new();
405
406 for cid in &cids {
407 if let Some(entry) = self.entries.get(cid) {
408 if entry.pinned {
409 continue;
410 }
411
412 let mut matched_action = TickRetentionAction::Keep;
413 for rule in &self.rules {
414 if self.tick_rule_matches(rule, entry, total_size) {
415 matched_action = rule.action;
416 break;
417 }
418 }
419
420 if matched_action != TickRetentionAction::Keep {
421 actions.push((cid.clone(), matched_action));
422 }
423 }
424 }
425
426 for (cid, action) in &actions {
428 match action {
429 TickRetentionAction::Delete => {
430 if let Some(removed) = self.entries.remove(cid) {
431 self.total_deleted += 1;
432 self.bytes_freed += removed.size_bytes;
433 }
434 }
435 TickRetentionAction::Archive => {
436 self.total_archived += 1;
437 }
440 TickRetentionAction::Keep => {}
441 }
442 }
443
444 actions
445 }
446
447 pub fn access(&mut self, block_cid: &str) {
449 if let Some(entry) = self.entries.get_mut(block_cid) {
450 entry.last_accessed_tick = self.current_tick;
451 }
452 }
453
454 pub fn tick(&mut self) {
456 self.current_tick += 1;
457 }
458
459 pub fn entry_count(&self) -> usize {
461 self.entries.len()
462 }
463
464 pub fn total_size_bytes(&self) -> u64 {
466 self.entries.values().map(|e| e.size_bytes).sum()
467 }
468
469 pub fn stats(&self) -> RetentionPolicyStats {
471 RetentionPolicyStats {
472 entry_count: self.entries.len(),
473 rule_count: self.rules.len(),
474 total_deleted: self.total_deleted,
475 total_archived: self.total_archived,
476 bytes_freed: self.bytes_freed,
477 }
478 }
479
480 pub fn current_tick(&self) -> u64 {
482 self.current_tick
483 }
484
485 fn tick_rule_matches(
489 &self,
490 rule: &TickRetentionRule,
491 entry: &RetentionEntry,
492 total_size: u64,
493 ) -> bool {
494 if let Some(max_age) = rule.max_age_ticks {
496 let age = self.current_tick.saturating_sub(entry.created_tick);
497 if age <= max_age {
498 return false;
499 }
500 }
501
502 if let Some(max_size) = rule.max_size_bytes {
504 if total_size <= max_size {
505 return false;
506 }
507 }
508
509 if rule.max_age_ticks.is_none() && rule.max_size_bytes.is_none() {
511 return false;
512 }
513
514 true
515 }
516}
517
518impl Default for StorageRetentionPolicy {
519 fn default() -> Self {
520 Self::new()
521 }
522}
523
524#[cfg(test)]
527mod tests {
528 use super::*;
529
530 fn make_block(
533 cid: &str,
534 size_bytes: u64,
535 created_at_secs: u64,
536 tags: Vec<&str>,
537 pinned: bool,
538 ) -> BlockRecord {
539 BlockRecord {
540 cid: cid.to_string(),
541 size_bytes,
542 created_at_secs,
543 last_accessed_secs: created_at_secs,
544 tags: tags.into_iter().map(|t| t.to_string()).collect(),
545 pinned,
546 }
547 }
548
549 fn age_rule(
550 rule_id: u64,
551 max_age_secs: u64,
552 action: RetentionAction,
553 priority: u32,
554 ) -> RetentionRule {
555 RetentionRule {
556 rule_id,
557 name: format!("age-rule-{rule_id}"),
558 max_age_secs: Some(max_age_secs),
559 max_size_bytes: None,
560 required_tag: None,
561 action,
562 priority,
563 }
564 }
565
566 fn size_rule(
567 rule_id: u64,
568 max_size_bytes: u64,
569 action: RetentionAction,
570 priority: u32,
571 ) -> RetentionRule {
572 RetentionRule {
573 rule_id,
574 name: format!("size-rule-{rule_id}"),
575 max_age_secs: None,
576 max_size_bytes: Some(max_size_bytes),
577 required_tag: None,
578 action,
579 priority,
580 }
581 }
582
583 fn tag_rule(
584 rule_id: u64,
585 required_tag: &str,
586 action: RetentionAction,
587 priority: u32,
588 ) -> RetentionRule {
589 RetentionRule {
590 rule_id,
591 name: format!("tag-rule-{rule_id}"),
592 max_age_secs: None,
593 max_size_bytes: None,
594 required_tag: Some(required_tag.to_string()),
595 action,
596 priority,
597 }
598 }
599
600 #[test]
603 fn new_starts_with_no_rules() {
604 let engine = StorageRetentionPolicyEngine::new();
605 assert!(engine.rules.is_empty());
606 }
607
608 #[test]
611 fn add_rule_single_rule() {
612 let mut engine = StorageRetentionPolicyEngine::new();
613 engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 10));
614 assert_eq!(engine.rules.len(), 1);
615 }
616
617 #[test]
618 fn add_rule_maintains_priority_order_descending() {
619 let mut engine = StorageRetentionPolicyEngine::new();
620 engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 5));
621 engine.add_rule(age_rule(2, 7200, RetentionAction::Archive, 20));
622 engine.add_rule(age_rule(3, 1800, RetentionAction::Delete, 10));
623
624 assert_eq!(engine.rules[0].priority, 20);
626 assert_eq!(engine.rules[1].priority, 10);
627 assert_eq!(engine.rules[2].priority, 5);
628 }
629
630 #[test]
631 fn add_rule_equal_priorities_stable() {
632 let mut engine = StorageRetentionPolicyEngine::new();
633 engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 10));
634 engine.add_rule(age_rule(2, 7200, RetentionAction::Archive, 10));
635 assert_eq!(engine.rules[0].rule_id, 1);
637 assert_eq!(engine.rules[1].rule_id, 2);
638 }
639
640 #[test]
643 fn remove_rule_returns_true_when_found() {
644 let mut engine = StorageRetentionPolicyEngine::new();
645 engine.add_rule(age_rule(42, 3600, RetentionAction::Delete, 10));
646 assert!(engine.remove_rule(42));
647 assert!(engine.rules.is_empty());
648 }
649
650 #[test]
651 fn remove_rule_returns_false_when_not_found() {
652 let mut engine = StorageRetentionPolicyEngine::new();
653 assert!(!engine.remove_rule(99));
654 }
655
656 #[test]
657 fn remove_rule_leaves_others_intact() {
658 let mut engine = StorageRetentionPolicyEngine::new();
659 engine.add_rule(age_rule(1, 3600, RetentionAction::Delete, 10));
660 engine.add_rule(age_rule(2, 7200, RetentionAction::Archive, 5));
661 engine.remove_rule(1);
662 assert_eq!(engine.rules.len(), 1);
663 assert_eq!(engine.rules[0].rule_id, 2);
664 }
665
666 #[test]
669 fn pinned_block_always_keep_even_with_delete_rule() {
670 let mut engine = StorageRetentionPolicyEngine::new();
671 engine.add_rule(age_rule(1, 0, RetentionAction::Delete, 100));
673
674 let block = make_block("QmPin", 99_999, 0, vec![], true);
675 let decision = engine.evaluate(&block, 1_000_000);
676
677 assert_eq!(decision.action, RetentionAction::Keep);
678 assert_eq!(decision.matched_rule_id, None);
679 }
680
681 #[test]
682 fn pinned_block_keep_with_no_rules() {
683 let engine = StorageRetentionPolicyEngine::new();
684 let block = make_block("QmPinNoRule", 100, 0, vec![], true);
685 let decision = engine.evaluate(&block, 1000);
686 assert_eq!(decision.action, RetentionAction::Keep);
687 assert_eq!(decision.matched_rule_id, None);
688 }
689
690 #[test]
693 fn no_rules_returns_default_keep() {
694 let engine = StorageRetentionPolicyEngine::new();
695 let block = make_block("QmNoRule", 512, 1000, vec![], false);
696 let decision = engine.evaluate(&block, 2000);
697 assert_eq!(decision.action, RetentionAction::Keep);
698 assert_eq!(decision.matched_rule_id, None);
699 }
700
701 #[test]
704 fn age_rule_matches_when_old_enough() {
705 let mut engine = StorageRetentionPolicyEngine::new();
706 engine.add_rule(age_rule(1, 100, RetentionAction::Delete, 10));
708
709 let block = make_block("QmOld", 512, 0, vec![], false);
710 let decision = engine.evaluate(&block, 200);
711
712 assert_eq!(decision.action, RetentionAction::Delete);
713 assert_eq!(decision.matched_rule_id, Some(1));
714 }
715
716 #[test]
717 fn age_rule_does_not_match_when_too_young() {
718 let mut engine = StorageRetentionPolicyEngine::new();
719 engine.add_rule(age_rule(1, 1000, RetentionAction::Delete, 10));
721
722 let block = make_block("QmYoung", 512, 1000, vec![], false);
723 let decision = engine.evaluate(&block, 1050);
724
725 assert_eq!(decision.action, RetentionAction::Keep);
726 assert_eq!(decision.matched_rule_id, None);
727 }
728
729 #[test]
730 fn age_rule_does_not_match_exactly_at_boundary() {
731 let mut engine = StorageRetentionPolicyEngine::new();
732 engine.add_rule(age_rule(1, 100, RetentionAction::Delete, 10));
734
735 let block = make_block("QmBoundary", 512, 0, vec![], false);
736 let decision = engine.evaluate(&block, 100);
737
738 assert_eq!(decision.action, RetentionAction::Keep);
739 }
740
741 #[test]
744 fn size_rule_matches_when_large_enough() {
745 let mut engine = StorageRetentionPolicyEngine::new();
746 engine.add_rule(size_rule(2, 1000, RetentionAction::Archive, 10));
748
749 let block = make_block("QmBig", 2000, 0, vec![], false);
750 let decision = engine.evaluate(&block, 0);
751
752 assert_eq!(decision.action, RetentionAction::Archive);
753 assert_eq!(decision.matched_rule_id, Some(2));
754 }
755
756 #[test]
757 fn size_rule_does_not_match_when_small() {
758 let mut engine = StorageRetentionPolicyEngine::new();
759 engine.add_rule(size_rule(2, 1000, RetentionAction::Archive, 10));
761
762 let block = make_block("QmSmall", 500, 0, vec![], false);
763 let decision = engine.evaluate(&block, 0);
764
765 assert_eq!(decision.action, RetentionAction::Keep);
766 assert_eq!(decision.matched_rule_id, None);
767 }
768
769 #[test]
770 fn size_rule_does_not_match_exactly_at_boundary() {
771 let mut engine = StorageRetentionPolicyEngine::new();
772 engine.add_rule(size_rule(2, 1000, RetentionAction::Archive, 10));
774
775 let block = make_block("QmExact", 1000, 0, vec![], false);
776 let decision = engine.evaluate(&block, 0);
777
778 assert_eq!(decision.action, RetentionAction::Keep);
779 }
780
781 #[test]
784 fn tag_rule_matches_correctly_when_tag_present() {
785 let mut engine = StorageRetentionPolicyEngine::new();
786 engine.add_rule(tag_rule(3, "archive", RetentionAction::Archive, 10));
787
788 let block = make_block("QmTagged", 512, 0, vec!["archive", "index"], false);
789 let decision = engine.evaluate(&block, 0);
790
791 assert_eq!(decision.action, RetentionAction::Archive);
792 assert_eq!(decision.matched_rule_id, Some(3));
793 }
794
795 #[test]
796 fn tag_rule_no_match_when_tag_absent() {
797 let mut engine = StorageRetentionPolicyEngine::new();
798 engine.add_rule(tag_rule(3, "archive", RetentionAction::Archive, 10));
799
800 let block = make_block("QmUntagged", 512, 0, vec!["index"], false);
801 let decision = engine.evaluate(&block, 0);
802
803 assert_eq!(decision.action, RetentionAction::Keep);
804 assert_eq!(decision.matched_rule_id, None);
805 }
806
807 #[test]
810 fn first_matching_rule_wins_by_priority() {
811 let mut engine = StorageRetentionPolicyEngine::new();
812 engine.add_rule(age_rule(10, 100, RetentionAction::Delete, 50));
814 engine.add_rule(age_rule(20, 100, RetentionAction::Archive, 10));
816
817 let block = make_block("QmOld", 512, 0, vec![], false);
818 let decision = engine.evaluate(&block, 200);
819
820 assert_eq!(decision.action, RetentionAction::Delete);
822 assert_eq!(decision.matched_rule_id, Some(10));
823 }
824
825 #[test]
826 fn lower_priority_rule_not_applied_when_higher_matches() {
827 let mut engine = StorageRetentionPolicyEngine::new();
828 engine.add_rule(age_rule(1, 0, RetentionAction::Keep, 100));
829 engine.add_rule(age_rule(2, 0, RetentionAction::Delete, 1));
830
831 let block = make_block("QmBlock", 512, 0, vec![], false);
832 let decision = engine.evaluate(&block, 1);
834
835 assert_eq!(decision.action, RetentionAction::Keep);
836 assert_eq!(decision.matched_rule_id, Some(1));
837 }
838
839 #[test]
842 fn archive_action_returned_correctly() {
843 let mut engine = StorageRetentionPolicyEngine::new();
844 engine.add_rule(size_rule(5, 100, RetentionAction::Archive, 10));
845
846 let block = make_block("QmArchive", 200, 0, vec![], false);
847 let decision = engine.evaluate(&block, 0);
848
849 assert_eq!(decision.action, RetentionAction::Archive);
850 }
851
852 #[test]
853 fn delete_action_returned_correctly() {
854 let mut engine = StorageRetentionPolicyEngine::new();
855 engine.add_rule(size_rule(6, 100, RetentionAction::Delete, 10));
856
857 let block = make_block("QmDelete", 200, 0, vec![], false);
858 let decision = engine.evaluate(&block, 0);
859
860 assert_eq!(decision.action, RetentionAction::Delete);
861 }
862
863 #[test]
864 fn warn_action_returned_correctly() {
865 let mut engine = StorageRetentionPolicyEngine::new();
866 engine.add_rule(RetentionRule {
867 rule_id: 7,
868 name: "warn-big".to_string(),
869 max_age_secs: None,
870 max_size_bytes: Some(100),
871 required_tag: None,
872 action: RetentionAction::Warn {
873 reason: "block too large".to_string(),
874 },
875 priority: 10,
876 });
877
878 let block = make_block("QmWarn", 200, 0, vec![], false);
879 let decision = engine.evaluate(&block, 0);
880
881 assert_eq!(
882 decision.action,
883 RetentionAction::Warn {
884 reason: "block too large".to_string()
885 }
886 );
887 assert_eq!(decision.matched_rule_id, Some(7));
888 }
889
890 #[test]
893 fn evaluate_all_processes_multiple_blocks() {
894 let mut engine = StorageRetentionPolicyEngine::new();
895 engine.add_rule(size_rule(1, 1000, RetentionAction::Delete, 10));
896
897 let blocks = vec![
898 make_block("QmA", 500, 0, vec![], false), make_block("QmB", 2000, 0, vec![], false), make_block("QmC", 1500, 0, vec![], true), ];
902 let decisions = engine.evaluate_all(&blocks, 0);
903
904 assert_eq!(decisions.len(), 3);
905 assert_eq!(decisions[0].action, RetentionAction::Keep);
906 assert_eq!(decisions[1].action, RetentionAction::Delete);
907 assert_eq!(decisions[2].action, RetentionAction::Keep);
908 }
909
910 #[test]
913 fn stats_keep_count_correct() {
914 let engine = StorageRetentionPolicyEngine::new();
915 let blocks = vec![
916 make_block("QmA", 100, 0, vec![], false),
917 make_block("QmB", 200, 0, vec![], false),
918 ];
919 let decisions = engine.evaluate_all(&blocks, 0);
920 let stats = engine.stats(&decisions);
921 assert_eq!(stats.keep_count, 2);
922 assert_eq!(stats.total_evaluated, 2);
923 }
924
925 #[test]
926 fn stats_archive_count_correct() {
927 let mut engine = StorageRetentionPolicyEngine::new();
928 engine.add_rule(size_rule(1, 100, RetentionAction::Archive, 10));
929
930 let blocks = vec![
931 make_block("QmA", 50, 0, vec![], false), make_block("QmB", 200, 0, vec![], false), make_block("QmC", 300, 0, vec![], false), ];
935 let decisions = engine.evaluate_all(&blocks, 0);
936 let stats = engine.stats(&decisions);
937
938 assert_eq!(stats.archive_count, 2);
939 assert_eq!(stats.keep_count, 1);
940 }
941
942 #[test]
943 fn stats_delete_count_correct() {
944 let mut engine = StorageRetentionPolicyEngine::new();
945 engine.add_rule(size_rule(1, 100, RetentionAction::Delete, 10));
946
947 let blocks = vec![
948 make_block("QmA", 200, 0, vec![], false), make_block("QmB", 50, 0, vec![], false), ];
951 let decisions = engine.evaluate_all(&blocks, 0);
952 let stats = engine.stats(&decisions);
953
954 assert_eq!(stats.delete_count, 1);
955 assert_eq!(stats.keep_count, 1);
956 }
957
958 #[test]
959 fn stats_warn_count_correct() {
960 let mut engine = StorageRetentionPolicyEngine::new();
961 engine.add_rule(RetentionRule {
962 rule_id: 1,
963 name: "warn-old".to_string(),
964 max_age_secs: Some(100),
965 max_size_bytes: None,
966 required_tag: None,
967 action: RetentionAction::Warn {
968 reason: "stale".to_string(),
969 },
970 priority: 10,
971 });
972
973 let blocks = vec![
974 make_block("QmA", 100, 0, vec![], false), make_block("QmB", 100, 0, vec![], false), ];
977 let decisions = engine.evaluate_all(&blocks, 200);
978 let stats = engine.stats(&decisions);
979
980 assert_eq!(stats.warn_count, 2);
981 assert_eq!(stats.total_evaluated, 2);
982 }
983
984 #[test]
987 fn matched_rule_id_set_when_rule_matches() {
988 let mut engine = StorageRetentionPolicyEngine::new();
989 engine.add_rule(age_rule(99, 100, RetentionAction::Delete, 10));
990
991 let block = make_block("QmMatch", 512, 0, vec![], false);
992 let decision = engine.evaluate(&block, 200);
993
994 assert_eq!(decision.matched_rule_id, Some(99));
995 }
996
997 #[test]
998 fn matched_rule_id_none_when_no_rule_matches() {
999 let mut engine = StorageRetentionPolicyEngine::new();
1000 engine.add_rule(tag_rule(10, "special", RetentionAction::Delete, 10));
1002
1003 let block = make_block("QmNoMatch", 512, 0, vec![], false);
1004 let decision = engine.evaluate(&block, 1000);
1005
1006 assert_eq!(decision.action, RetentionAction::Keep);
1007 assert_eq!(decision.matched_rule_id, None);
1008 }
1009
1010 #[test]
1013 fn rule_with_all_conditions_requires_all_to_match() {
1014 let mut engine = StorageRetentionPolicyEngine::new();
1015 engine.add_rule(RetentionRule {
1017 rule_id: 77,
1018 name: "combined".to_string(),
1019 max_age_secs: Some(100),
1020 max_size_bytes: Some(1000),
1021 required_tag: Some("hot".to_string()),
1022 action: RetentionAction::Archive,
1023 priority: 10,
1024 });
1025
1026 let now = 300u64;
1027
1028 let full_match = make_block("QmFull", 2000, 0, vec!["hot"], false);
1030 assert_eq!(
1031 engine.evaluate(&full_match, now).action,
1032 RetentionAction::Archive
1033 );
1034
1035 let no_tag = make_block("QmNoTag", 2000, 0, vec![], false);
1037 assert_eq!(engine.evaluate(&no_tag, now).action, RetentionAction::Keep);
1038
1039 let small = make_block("QmSmall", 500, 0, vec!["hot"], false);
1041 assert_eq!(engine.evaluate(&small, now).action, RetentionAction::Keep);
1042
1043 let young = make_block("QmYoung", 2000, 250, vec!["hot"], false);
1045 assert_eq!(engine.evaluate(&young, now).action, RetentionAction::Keep);
1046 }
1047
1048 fn tick_age_rule(
1053 name: &str,
1054 max_age: u64,
1055 action: TickRetentionAction,
1056 priority: u32,
1057 ) -> TickRetentionRule {
1058 TickRetentionRule {
1059 name: name.to_string(),
1060 max_age_ticks: Some(max_age),
1061 max_size_bytes: None,
1062 action,
1063 priority,
1064 }
1065 }
1066
1067 fn tick_size_rule(
1068 name: &str,
1069 max_size: u64,
1070 action: TickRetentionAction,
1071 priority: u32,
1072 ) -> TickRetentionRule {
1073 TickRetentionRule {
1074 name: name.to_string(),
1075 max_age_ticks: None,
1076 max_size_bytes: Some(max_size),
1077 action,
1078 priority,
1079 }
1080 }
1081
1082 #[test]
1085 fn tick_new_starts_empty() {
1086 let policy = StorageRetentionPolicy::new();
1087 assert_eq!(policy.entry_count(), 0);
1088 assert_eq!(policy.total_size_bytes(), 0);
1089 assert_eq!(policy.current_tick(), 0);
1090 }
1091
1092 #[test]
1093 fn tick_default_is_equivalent_to_new() {
1094 let policy = StorageRetentionPolicy::default();
1095 assert_eq!(policy.entry_count(), 0);
1096 assert_eq!(policy.current_tick(), 0);
1097 }
1098
1099 #[test]
1102 fn tick_add_entry_increases_count() {
1103 let mut policy = StorageRetentionPolicy::new();
1104 policy.add_entry("QmA", 100, 0);
1105 assert_eq!(policy.entry_count(), 1);
1106 policy.add_entry("QmB", 200, 0);
1107 assert_eq!(policy.entry_count(), 2);
1108 }
1109
1110 #[test]
1111 fn tick_total_size_bytes_sums_entries() {
1112 let mut policy = StorageRetentionPolicy::new();
1113 policy.add_entry("QmA", 100, 0);
1114 policy.add_entry("QmB", 250, 0);
1115 assert_eq!(policy.total_size_bytes(), 350);
1116 }
1117
1118 #[test]
1119 fn tick_add_entry_overwrites_same_cid() {
1120 let mut policy = StorageRetentionPolicy::new();
1121 policy.add_entry("QmA", 100, 0);
1122 policy.add_entry("QmA", 999, 5);
1123 assert_eq!(policy.entry_count(), 1);
1124 assert_eq!(policy.total_size_bytes(), 999);
1125 }
1126
1127 #[test]
1130 fn tick_pin_returns_true_when_entry_exists() {
1131 let mut policy = StorageRetentionPolicy::new();
1132 policy.add_entry("QmA", 100, 0);
1133 assert!(policy.pin("QmA"));
1134 }
1135
1136 #[test]
1137 fn tick_pin_returns_false_when_entry_missing() {
1138 let mut policy = StorageRetentionPolicy::new();
1139 assert!(!policy.pin("QmNone"));
1140 }
1141
1142 #[test]
1143 fn tick_unpin_returns_true_when_entry_exists() {
1144 let mut policy = StorageRetentionPolicy::new();
1145 policy.add_entry("QmA", 100, 0);
1146 policy.pin("QmA");
1147 assert!(policy.unpin("QmA"));
1148 }
1149
1150 #[test]
1151 fn tick_unpin_returns_false_when_entry_missing() {
1152 let mut policy = StorageRetentionPolicy::new();
1153 assert!(!policy.unpin("QmNone"));
1154 }
1155
1156 #[test]
1159 fn tick_age_rule_deletes_old_blocks() {
1160 let mut policy = StorageRetentionPolicy::new();
1161 policy.add_rule(tick_age_rule(
1162 "delete-old",
1163 5,
1164 TickRetentionAction::Delete,
1165 10,
1166 ));
1167 policy.add_entry("QmOld", 100, 0);
1168
1169 for _ in 0..6 {
1171 policy.tick();
1172 }
1173 assert_eq!(policy.evaluate("QmOld"), TickRetentionAction::Delete);
1174 }
1175
1176 #[test]
1177 fn tick_age_rule_keeps_young_blocks() {
1178 let mut policy = StorageRetentionPolicy::new();
1179 policy.add_rule(tick_age_rule(
1180 "delete-old",
1181 10,
1182 TickRetentionAction::Delete,
1183 10,
1184 ));
1185 policy.add_entry("QmYoung", 100, 0);
1186
1187 for _ in 0..3 {
1189 policy.tick();
1190 }
1191 assert_eq!(policy.evaluate("QmYoung"), TickRetentionAction::Keep);
1192 }
1193
1194 #[test]
1195 fn tick_age_rule_boundary_keeps_at_exact_age() {
1196 let mut policy = StorageRetentionPolicy::new();
1197 policy.add_rule(tick_age_rule(
1198 "delete-old",
1199 5,
1200 TickRetentionAction::Delete,
1201 10,
1202 ));
1203 policy.add_entry("QmBoundary", 100, 0);
1204
1205 for _ in 0..5 {
1207 policy.tick();
1208 }
1209 assert_eq!(policy.evaluate("QmBoundary"), TickRetentionAction::Keep);
1210 }
1211
1212 #[test]
1215 fn tick_size_rule_deletes_when_total_exceeds_limit() {
1216 let mut policy = StorageRetentionPolicy::new();
1217 policy.add_rule(tick_size_rule(
1218 "size-limit",
1219 500,
1220 TickRetentionAction::Delete,
1221 10,
1222 ));
1223 policy.add_entry("QmA", 300, 0);
1224 policy.add_entry("QmB", 300, 0);
1225
1226 assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Delete);
1228 }
1229
1230 #[test]
1231 fn tick_size_rule_keeps_when_total_within_limit() {
1232 let mut policy = StorageRetentionPolicy::new();
1233 policy.add_rule(tick_size_rule(
1234 "size-limit",
1235 1000,
1236 TickRetentionAction::Delete,
1237 10,
1238 ));
1239 policy.add_entry("QmA", 100, 0);
1240
1241 assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Keep);
1243 }
1244
1245 #[test]
1246 fn tick_size_rule_boundary_keeps_at_exact_size() {
1247 let mut policy = StorageRetentionPolicy::new();
1248 policy.add_rule(tick_size_rule(
1249 "size-limit",
1250 500,
1251 TickRetentionAction::Delete,
1252 10,
1253 ));
1254 policy.add_entry("QmA", 250, 0);
1255 policy.add_entry("QmB", 250, 0);
1256
1257 assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Keep);
1259 }
1260
1261 #[test]
1264 fn tick_pinned_block_never_deleted() {
1265 let mut policy = StorageRetentionPolicy::new();
1266 policy.add_rule(tick_age_rule(
1267 "delete-old",
1268 0,
1269 TickRetentionAction::Delete,
1270 100,
1271 ));
1272 policy.add_entry("QmPinned", 100, 0);
1273 policy.pin("QmPinned");
1274 policy.tick();
1275
1276 assert_eq!(policy.evaluate("QmPinned"), TickRetentionAction::Keep);
1277 }
1278
1279 #[test]
1280 fn tick_pinned_block_survives_enforce() {
1281 let mut policy = StorageRetentionPolicy::new();
1282 policy.add_rule(tick_age_rule(
1283 "delete-old",
1284 0,
1285 TickRetentionAction::Delete,
1286 100,
1287 ));
1288 policy.add_entry("QmPinned", 100, 0);
1289 policy.pin("QmPinned");
1290 policy.tick();
1291
1292 let actions = policy.enforce();
1293 assert!(actions.is_empty());
1294 assert_eq!(policy.entry_count(), 1);
1295 }
1296
1297 #[test]
1298 fn tick_unpin_allows_deletion() {
1299 let mut policy = StorageRetentionPolicy::new();
1300 policy.add_rule(tick_age_rule(
1301 "delete-old",
1302 0,
1303 TickRetentionAction::Delete,
1304 100,
1305 ));
1306 policy.add_entry("QmBlock", 100, 0);
1307 policy.pin("QmBlock");
1308 policy.tick();
1309
1310 assert_eq!(policy.evaluate("QmBlock"), TickRetentionAction::Keep);
1312
1313 policy.unpin("QmBlock");
1315 assert_eq!(policy.evaluate("QmBlock"), TickRetentionAction::Delete);
1316 }
1317
1318 #[test]
1321 fn tick_higher_priority_rule_wins() {
1322 let mut policy = StorageRetentionPolicy::new();
1323 policy.add_rule(tick_age_rule(
1325 "archive-rule",
1326 0,
1327 TickRetentionAction::Archive,
1328 50,
1329 ));
1330 policy.add_rule(tick_age_rule(
1332 "delete-rule",
1333 0,
1334 TickRetentionAction::Delete,
1335 10,
1336 ));
1337
1338 policy.add_entry("QmBlock", 100, 0);
1339 policy.tick();
1340
1341 assert_eq!(policy.evaluate("QmBlock"), TickRetentionAction::Archive);
1343 }
1344
1345 #[test]
1346 fn tick_add_rule_replaces_same_name() {
1347 let mut policy = StorageRetentionPolicy::new();
1348 policy.add_rule(tick_age_rule(
1349 "my-rule",
1350 100,
1351 TickRetentionAction::Delete,
1352 10,
1353 ));
1354 policy.add_rule(tick_age_rule(
1355 "my-rule",
1356 5,
1357 TickRetentionAction::Archive,
1358 20,
1359 ));
1360
1361 let stats = policy.stats();
1363 assert_eq!(stats.rule_count, 1);
1364 }
1365
1366 #[test]
1369 fn tick_enforce_removes_deleted_entries() {
1370 let mut policy = StorageRetentionPolicy::new();
1371 policy.add_rule(tick_age_rule(
1372 "delete-old",
1373 5,
1374 TickRetentionAction::Delete,
1375 10,
1376 ));
1377 policy.add_entry("QmOld", 200, 0);
1378 policy.add_entry("QmNew", 100, 10);
1379
1380 for _ in 0..10 {
1382 policy.tick();
1383 }
1384
1385 let actions = policy.enforce();
1386
1387 assert!(actions
1389 .iter()
1390 .any(|(c, a)| c == "QmOld" && *a == TickRetentionAction::Delete));
1391 assert_eq!(policy.entry_count(), 1);
1392 }
1393
1394 #[test]
1395 fn tick_enforce_returns_actions_taken() {
1396 let mut policy = StorageRetentionPolicy::new();
1397 policy.add_rule(tick_age_rule("del", 0, TickRetentionAction::Delete, 10));
1398 policy.add_entry("QmA", 50, 0);
1399 policy.add_entry("QmB", 75, 0);
1400 policy.tick();
1401
1402 let actions = policy.enforce();
1403 assert_eq!(actions.len(), 2);
1404 for (_, a) in &actions {
1405 assert_eq!(*a, TickRetentionAction::Delete);
1406 }
1407 }
1408
1409 #[test]
1412 fn tick_access_updates_last_accessed_tick() {
1413 let mut policy = StorageRetentionPolicy::new();
1414 policy.add_entry("QmA", 100, 0);
1415
1416 for _ in 0..5 {
1417 policy.tick();
1418 }
1419 policy.access("QmA");
1420
1421 assert_eq!(policy.entry_count(), 1);
1424 let stats = policy.stats();
1427 assert_eq!(stats.entry_count, 1);
1428 }
1429
1430 #[test]
1431 fn tick_access_nonexistent_is_noop() {
1432 let mut policy = StorageRetentionPolicy::new();
1433 policy.tick();
1434 policy.access("QmGhost");
1435 assert_eq!(policy.entry_count(), 0);
1437 }
1438
1439 #[test]
1442 fn tick_archive_action_does_not_remove_entry() {
1443 let mut policy = StorageRetentionPolicy::new();
1444 policy.add_rule(tick_age_rule(
1445 "archive",
1446 2,
1447 TickRetentionAction::Archive,
1448 10,
1449 ));
1450 policy.add_entry("QmA", 100, 0);
1451
1452 for _ in 0..5 {
1453 policy.tick();
1454 }
1455
1456 let actions = policy.enforce();
1457 assert!(actions
1458 .iter()
1459 .any(|(c, a)| c == "QmA" && *a == TickRetentionAction::Archive));
1460 assert_eq!(policy.entry_count(), 1);
1462 }
1463
1464 #[test]
1467 fn tick_multiple_rules_evaluated_in_order() {
1468 let mut policy = StorageRetentionPolicy::new();
1469 policy.add_rule(tick_size_rule(
1471 "archive-big",
1472 50,
1473 TickRetentionAction::Archive,
1474 30,
1475 ));
1476 policy.add_rule(tick_age_rule(
1478 "delete-old",
1479 3,
1480 TickRetentionAction::Delete,
1481 20,
1482 ));
1483
1484 policy.add_entry("QmA", 100, 0);
1485 for _ in 0..5 {
1486 policy.tick();
1487 }
1488
1489 assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Archive);
1491 }
1492
1493 #[test]
1494 fn tick_only_matching_rule_applied() {
1495 let mut policy = StorageRetentionPolicy::new();
1496 policy.add_rule(tick_age_rule(
1498 "delete-old",
1499 100,
1500 TickRetentionAction::Delete,
1501 50,
1502 ));
1503 policy.add_rule(tick_size_rule(
1505 "archive-big",
1506 50,
1507 TickRetentionAction::Archive,
1508 10,
1509 ));
1510
1511 policy.add_entry("QmA", 200, 0);
1512
1513 assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Archive);
1514 }
1515
1516 #[test]
1519 fn tick_stats_initial_zeroes() {
1520 let policy = StorageRetentionPolicy::new();
1521 let stats = policy.stats();
1522 assert_eq!(stats.entry_count, 0);
1523 assert_eq!(stats.rule_count, 0);
1524 assert_eq!(stats.total_deleted, 0);
1525 assert_eq!(stats.total_archived, 0);
1526 assert_eq!(stats.bytes_freed, 0);
1527 }
1528
1529 #[test]
1530 fn tick_stats_tracks_deleted_count_and_bytes() {
1531 let mut policy = StorageRetentionPolicy::new();
1532 policy.add_rule(tick_age_rule("del", 0, TickRetentionAction::Delete, 10));
1533 policy.add_entry("QmA", 100, 0);
1534 policy.add_entry("QmB", 250, 0);
1535 policy.tick();
1536 policy.enforce();
1537
1538 let stats = policy.stats();
1539 assert_eq!(stats.total_deleted, 2);
1540 assert_eq!(stats.bytes_freed, 350);
1541 }
1542
1543 #[test]
1544 fn tick_stats_tracks_archived_count() {
1545 let mut policy = StorageRetentionPolicy::new();
1546 policy.add_rule(tick_age_rule("arch", 0, TickRetentionAction::Archive, 10));
1547 policy.add_entry("QmA", 100, 0);
1548 policy.tick();
1549 policy.enforce();
1550
1551 let stats = policy.stats();
1552 assert_eq!(stats.total_archived, 1);
1553 assert_eq!(stats.bytes_freed, 0); }
1555
1556 #[test]
1557 fn tick_stats_rule_count() {
1558 let mut policy = StorageRetentionPolicy::new();
1559 policy.add_rule(tick_age_rule("r1", 10, TickRetentionAction::Delete, 10));
1560 policy.add_rule(tick_size_rule("r2", 500, TickRetentionAction::Archive, 5));
1561 assert_eq!(policy.stats().rule_count, 2);
1562 }
1563
1564 #[test]
1565 fn tick_stats_accumulate_across_enforcements() {
1566 let mut policy = StorageRetentionPolicy::new();
1567 policy.add_rule(tick_age_rule("del", 0, TickRetentionAction::Delete, 10));
1568
1569 policy.add_entry("QmA", 100, 0);
1571 policy.tick();
1572 policy.enforce();
1573
1574 let created = policy.current_tick();
1576 policy.add_entry("QmB", 200, created);
1577 policy.tick();
1578 policy.enforce();
1579
1580 let stats = policy.stats();
1581 assert_eq!(stats.total_deleted, 2);
1582 assert_eq!(stats.bytes_freed, 300);
1583 }
1584
1585 #[test]
1588 fn tick_empty_policy_no_rules_keeps_everything() {
1589 let mut policy = StorageRetentionPolicy::new();
1590 policy.add_entry("QmA", 100, 0);
1591 policy.add_entry("QmB", 200, 0);
1592
1593 for _ in 0..100 {
1594 policy.tick();
1595 }
1596
1597 let actions = policy.enforce();
1598 assert!(actions.is_empty());
1599 assert_eq!(policy.entry_count(), 2);
1600 }
1601
1602 #[test]
1603 fn tick_empty_policy_enforce_returns_empty() {
1604 let mut policy = StorageRetentionPolicy::new();
1605 let actions = policy.enforce();
1606 assert!(actions.is_empty());
1607 }
1608
1609 #[test]
1612 fn tick_remove_rule_returns_true_when_found() {
1613 let mut policy = StorageRetentionPolicy::new();
1614 policy.add_rule(tick_age_rule(
1615 "my-rule",
1616 10,
1617 TickRetentionAction::Delete,
1618 10,
1619 ));
1620 assert!(policy.remove_rule("my-rule"));
1621 assert_eq!(policy.stats().rule_count, 0);
1622 }
1623
1624 #[test]
1625 fn tick_remove_rule_returns_false_when_not_found() {
1626 let mut policy = StorageRetentionPolicy::new();
1627 assert!(!policy.remove_rule("nonexistent"));
1628 }
1629
1630 #[test]
1633 fn tick_evaluate_nonexistent_returns_keep() {
1634 let policy = StorageRetentionPolicy::new();
1635 assert_eq!(policy.evaluate("QmNope"), TickRetentionAction::Keep);
1636 }
1637
1638 #[test]
1641 fn tick_advances_clock() {
1642 let mut policy = StorageRetentionPolicy::new();
1643 assert_eq!(policy.current_tick(), 0);
1644 policy.tick();
1645 assert_eq!(policy.current_tick(), 1);
1646 policy.tick();
1647 policy.tick();
1648 assert_eq!(policy.current_tick(), 3);
1649 }
1650
1651 #[test]
1654 fn tick_rule_without_conditions_never_matches() {
1655 let mut policy = StorageRetentionPolicy::new();
1656 policy.add_rule(TickRetentionRule {
1657 name: "empty-rule".to_string(),
1658 max_age_ticks: None,
1659 max_size_bytes: None,
1660 action: TickRetentionAction::Delete,
1661 priority: 100,
1662 });
1663 policy.add_entry("QmA", 100, 0);
1664 policy.tick();
1665
1666 assert_eq!(policy.evaluate("QmA"), TickRetentionAction::Keep);
1667 }
1668}