reifydb_core/lifecycle/
coverage.rs1use std::{collections::BTreeMap, sync::Arc};
5
6use reifydb_runtime::sync::mutex::Mutex;
7
8use crate::lifecycle::class::RetentionClass;
9
10#[derive(Clone)]
11pub struct RetentionCoverage {
12 owners: Arc<Mutex<BTreeMap<RetentionClass, &'static str>>>,
13 absences: Arc<Mutex<BTreeMap<RetentionClass, &'static str>>>,
14}
15
16impl RetentionCoverage {
17 pub fn new() -> Self {
18 Self {
19 owners: Arc::new(Mutex::new(BTreeMap::new())),
20 absences: Arc::new(Mutex::new(BTreeMap::new())),
21 }
22 }
23
24 pub fn cover(&self, class: RetentionClass, owner: &'static str) {
25 self.owners.lock().entry(class).or_insert(owner);
26 }
27
28 pub fn absent(&self, class: RetentionClass, reason: &'static str) {
29 self.absences.lock().entry(class).or_insert(reason);
30 }
31
32 pub fn owner(&self, class: RetentionClass) -> Option<&'static str> {
33 self.owners.lock().get(&class).copied()
34 }
35
36 pub fn absence(&self, class: RetentionClass) -> Option<&'static str> {
37 self.absences.lock().get(&class).copied()
38 }
39
40 pub fn is_covered(&self, class: RetentionClass) -> bool {
41 self.owners.lock().contains_key(&class)
42 }
43
44 pub fn len(&self) -> usize {
45 self.owners.lock().len()
46 }
47
48 pub fn is_empty(&self) -> bool {
49 self.owners.lock().is_empty()
50 }
51}
52
53impl Default for RetentionCoverage {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 #[test]
64 fn a_class_reclaimed_outside_the_lifecycle_subsystem_still_counts_as_covered() {
65 let coverage = RetentionCoverage::new();
69 coverage.cover(RetentionClass::TombstoneReap, "tombstone-reap");
70 coverage.cover(RetentionClass::CdcTruncate, "cdc-subsystem");
71
72 assert_eq!(coverage.owner(RetentionClass::CdcTruncate), Some("cdc-subsystem"));
73 assert!(coverage.is_covered(RetentionClass::TombstoneReap));
74 assert!(
75 !coverage.is_covered(RetentionClass::EpochLog),
76 "a class nobody claimed must stay uncovered so the report can still name it"
77 );
78 }
79
80 #[test]
81 fn the_first_owner_of_a_class_keeps_it() {
82 let coverage = RetentionCoverage::new();
85 coverage.cover(RetentionClass::TombstoneReap, "tombstone-reap");
86 coverage.cover(RetentionClass::TombstoneReap, "someone-else");
87
88 assert_eq!(coverage.owner(RetentionClass::TombstoneReap), Some("tombstone-reap"));
89 assert_eq!(coverage.len(), 1, "a second claim must not create a second entry");
90 }
91
92 #[test]
93 fn a_fresh_registry_claims_nothing() {
94 let coverage = RetentionCoverage::new();
95
96 assert!(coverage.is_empty());
97 for class in RetentionClass::all() {
98 assert!(!coverage.is_covered(*class), "{} must start uncovered", class.name());
99 assert!(coverage.absence(*class).is_none(), "{} must start with no absence", class.name());
100 }
101 }
102
103 #[test]
104 fn a_lane_declared_absent_is_explained_without_becoming_covered() {
105 let coverage = RetentionCoverage::new();
110 coverage.absent(RetentionClass::TombstoneReap, "store has no persistent tier");
111
112 assert_eq!(coverage.absence(RetentionClass::TombstoneReap), Some("store has no persistent tier"));
113 assert!(
114 !coverage.is_covered(RetentionClass::TombstoneReap),
115 "an absent lane has no executor, so it must not count as covered"
116 );
117 assert_eq!(coverage.owner(RetentionClass::TombstoneReap), None);
118 }
119
120 #[test]
121 fn covering_a_class_never_declares_it_absent() {
122 let coverage = RetentionCoverage::new();
125 coverage.cover(RetentionClass::EpochLog, "epoch-log");
126
127 assert!(
128 coverage.absence(RetentionClass::EpochLog).is_none(),
129 "a class with an executor has no absence to report"
130 );
131 }
132
133 #[test]
134 fn the_first_reason_a_lane_is_declared_absent_is_the_one_reported() {
135 let coverage = RetentionCoverage::new();
138 coverage.absent(RetentionClass::CdcTruncate, "no cdc store registered");
139 coverage.absent(RetentionClass::CdcTruncate, "some later excuse");
140
141 assert_eq!(coverage.absence(RetentionClass::CdcTruncate), Some("no cdc store registered"));
142 }
143}