Skip to main content

reifydb_core/lifecycle/
coverage.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use 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		// Coverage is declared by whoever executes it, wherever that lives: a covered set derived
66		// only from the lifecycle subsystem's own tasks would report an externally reclaimed class
67		// as unreclaimed on every boot - an error indistinguishable from a genuinely dead lane.
68		let coverage = RetentionCoverage::new();
69		coverage.cover(RetentionClass::RowTtl, "retention-evict-silent");
70		coverage.cover(RetentionClass::CdcTruncate, "cdc-subsystem");
71
72		assert_eq!(coverage.owner(RetentionClass::CdcTruncate), Some("cdc-subsystem"));
73		assert!(coverage.is_covered(RetentionClass::RowTtl));
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		// Registration order across subsystems is a builder detail; keeping the first registrant makes
83		// the reported owner stable when subsystems are reordered.
84		let coverage = RetentionCoverage::new();
85		coverage.cover(RetentionClass::CdcTruncate, "cdc-truncate");
86		coverage.cover(RetentionClass::CdcTruncate, "someone-else");
87
88		assert_eq!(coverage.owner(RetentionClass::CdcTruncate), Some("cdc-truncate"));
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		// Absence answers a different question than coverage: "nothing produces here" is not "something
106		// reclaims here". Recording it as a pseudo-owner would report a reason string where the report
107		// prints an executor name, and would fold the class into the covered set that liveness assertions
108		// read - a lane nothing ever runs would then be expected to record slices.
109		let coverage = RetentionCoverage::new();
110		coverage.absent(RetentionClass::CdcTruncate, "no cdc store registered");
111
112		assert_eq!(coverage.absence(RetentionClass::CdcTruncate), Some("no cdc store registered"));
113		assert!(
114			!coverage.is_covered(RetentionClass::CdcTruncate),
115			"an absent lane has no executor, so it must not count as covered"
116		);
117		assert_eq!(coverage.owner(RetentionClass::CdcTruncate), None);
118	}
119
120	#[test]
121	fn covering_a_class_never_declares_it_absent() {
122		// The opposite conflation: a covered class picking up an absence would downgrade its report line
123		// from "reclaimed by X" to "nothing produces here", hiding a live lane behind an excuse.
124		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		// Same stability contract as the owner: declaration order across subsystems is a builder detail,
136		// and a reason that changes with it makes the boot report unreproducible.
137		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}