Skip to main content

reifydb_core/lifecycle/
class.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::fmt;
5
6use reifydb_value::value::datetime::DateTime;
7
8use crate::common::CommitVersion;
9
10#[derive(Copy, Clone, Debug, PartialEq, Eq)]
11pub enum Floor {
12	Version(CommitVersion),
13	Instant(DateTime),
14}
15
16impl Floor {
17	pub fn monotonic_key(&self) -> u64 {
18		match self {
19			Self::Version(version) => version.0,
20			Self::Instant(instant) => instant.to_nanos(),
21		}
22	}
23
24	pub fn version(&self) -> Option<CommitVersion> {
25		match self {
26			Self::Version(version) => Some(*version),
27			Self::Instant(_) => None,
28		}
29	}
30
31	pub fn instant(&self) -> Option<DateTime> {
32		match self {
33			Self::Instant(instant) => Some(*instant),
34			Self::Version(_) => None,
35		}
36	}
37
38	pub fn is_same_domain(&self, other: &Self) -> bool {
39		matches!((self, other), (Self::Version(_), Self::Version(_)) | (Self::Instant(_), Self::Instant(_)))
40	}
41}
42
43impl fmt::Display for Floor {
44	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45		match self {
46			Self::Version(version) => write!(f, "v{}", version.0),
47			Self::Instant(instant) => write!(f, "{instant}"),
48		}
49	}
50}
51
52#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
53pub enum FloorTerm {
54	RowExpiry,
55
56	QueryDoneUntil,
57
58	LeaseMin,
59
60	ConsumerCheckpoint,
61
62	ConsumerPosition,
63
64	RetentionHorizon,
65}
66
67impl FloorTerm {
68	pub fn protects(&self) -> &'static str {
69		match self {
70			Self::RowExpiry => "rows younger than their declared ttl",
71			Self::QueryDoneUntil => "an in-flight query reading at its snapshot version",
72			Self::LeaseMin => "a held operator-state lease",
73			Self::ConsumerCheckpoint => "a CDC log consumer that has not yet consumed the version",
74			Self::ConsumerPosition => "a live flow that has not yet consumed the version",
75			Self::RetentionHorizon => "epoch samples still needed to resolve the longest declared ttl",
76		}
77	}
78
79	pub fn is_clock_driven(&self) -> bool {
80		match self {
81			Self::RowExpiry => true,
82			Self::QueryDoneUntil
83			| Self::LeaseMin
84			| Self::ConsumerCheckpoint
85			| Self::ConsumerPosition
86			| Self::RetentionHorizon => false,
87		}
88	}
89
90	pub fn all() -> &'static [Self] {
91		&[
92			Self::RowExpiry,
93			Self::QueryDoneUntil,
94			Self::LeaseMin,
95			Self::ConsumerCheckpoint,
96			Self::ConsumerPosition,
97			Self::RetentionHorizon,
98		]
99	}
100
101	pub fn index(&self) -> usize {
102		Self::all().iter().position(|term| term == self).expect("every term is listed in FloorTerm::all")
103	}
104
105	pub fn from_index(index: usize) -> Option<Self> {
106		Self::all().get(index).copied()
107	}
108}
109
110impl fmt::Display for FloorTerm {
111	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112		match self {
113			Self::RowExpiry => write!(f, "row-expiry"),
114			Self::QueryDoneUntil => write!(f, "query-done-until"),
115			Self::LeaseMin => write!(f, "lease-min"),
116			Self::ConsumerCheckpoint => write!(f, "consumer-checkpoint"),
117			Self::ConsumerPosition => write!(f, "consumer-position"),
118			Self::RetentionHorizon => write!(f, "retention-horizon"),
119		}
120	}
121}
122
123#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
124pub enum RetentionClass {
125	RowTtl,
126
127	BufferHistoricalGc,
128
129	PersistentFlush,
130
131	QueueLeaseReap,
132
133	QueueRetention,
134
135	CdcTruncate,
136
137	EpochLog,
138}
139
140impl RetentionClass {
141	pub fn all() -> &'static [Self] {
142		&[
143			Self::RowTtl,
144			Self::BufferHistoricalGc,
145			Self::PersistentFlush,
146			Self::QueueLeaseReap,
147			Self::QueueRetention,
148			Self::CdcTruncate,
149			Self::EpochLog,
150		]
151	}
152
153	pub fn name(&self) -> &'static str {
154		match self {
155			Self::RowTtl => "row-ttl-silent",
156			Self::BufferHistoricalGc => "buffer-historical-gc",
157			Self::PersistentFlush => "persistent-flush",
158			Self::QueueLeaseReap => "queue-lease-reap",
159			Self::QueueRetention => "queue-retention",
160			Self::CdcTruncate => "cdc-truncate",
161			Self::EpochLog => "epoch-log",
162		}
163	}
164
165	pub fn reclaims_versioned_data(&self) -> bool {
166		match self {
167			Self::RowTtl
168			| Self::BufferHistoricalGc
169			| Self::PersistentFlush
170			| Self::CdcTruncate
171			| Self::EpochLog
172			| Self::QueueRetention => true,
173			Self::QueueLeaseReap => false,
174		}
175	}
176
177	pub fn floor_terms(&self) -> &'static [FloorTerm] {
178		match self {
179			Self::RowTtl => &[FloorTerm::RowExpiry],
180			Self::BufferHistoricalGc => &[FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin],
181			Self::PersistentFlush => {
182				&[FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin, FloorTerm::ConsumerPosition]
183			}
184			Self::QueueLeaseReap => &[],
185			Self::QueueRetention => &[FloorTerm::RowExpiry],
186			Self::CdcTruncate => &[FloorTerm::ConsumerCheckpoint],
187			Self::EpochLog => &[FloorTerm::RetentionHorizon],
188		}
189	}
190
191	pub fn constrained_by(&self, term: FloorTerm) -> bool {
192		self.floor_terms().contains(&term)
193	}
194}
195
196impl fmt::Display for RetentionClass {
197	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198		write!(f, "{}", self.name())
199	}
200}
201
202#[cfg(test)]
203mod tests {
204	use super::{FloorTerm, RetentionClass};
205
206	#[test]
207	fn every_class_declares_a_floor_term_for_exactly_the_data_it_reclaims() {
208		// A class reclaiming versioned data with no floor term deletes at head version. The converse
209		// pins the exemption: a class touching no row, version or tombstone has no honest term to
210		// name, and deleting versioned data under that exemption is what this direction catches.
211		for class in RetentionClass::all() {
212			if class.reclaims_versioned_data() {
213				assert!(
214					!class.floor_terms().is_empty(),
215					"{class} declares no floor term, so nothing bounds what it deletes"
216				);
217			} else {
218				assert!(
219					class.floor_terms().is_empty(),
220					"{class} reclaims no versioned data, so a version floor cannot be what bounds it"
221				);
222			}
223		}
224	}
225
226	#[test]
227	fn class_names_are_unique_so_metrics_and_reports_cannot_collide() {
228		let mut names: Vec<&str> = RetentionClass::all().iter().map(|c| c.name()).collect();
229		let total = names.len();
230		names.sort_unstable();
231		names.dedup();
232
233		assert_eq!(names.len(), total, "two classes share a name; their metrics and report lines would merge");
234	}
235
236	#[test]
237	fn row_expiry_classes_are_not_hostage_to_readers_of_other_data() {
238		// Row expiry is protected by MVCC-transactional discovery, not by these readers. Sharing one
239		// watermark with them lets a wedged CDC consumer or a leaked query lease freeze it.
240		for class in [RetentionClass::RowTtl] {
241			assert!(
242				!class.constrained_by(FloorTerm::ConsumerCheckpoint),
243				"{class} must not be pinned by a CDC consumer; it reclaims rows no consumer reads"
244			);
245			assert!(
246				!class.constrained_by(FloorTerm::LeaseMin),
247				"{class} must not be pinned by an operator-state lease"
248			);
249			assert!(
250				!class.constrained_by(FloorTerm::QueryDoneUntil),
251				"{class} must not be pinned by an in-flight query; transactional discovery protects it"
252			);
253		}
254	}
255
256	#[test]
257	fn version_history_classes_respect_every_reader_of_a_snapshot() {
258		// Buffer history is what a live reader resolves against, so an in-flight query and a held
259		// lease must both be present. A lagging subscription rides LeaseMin through its batch lease
260		// rather than holding a term of its own.
261		let class = RetentionClass::BufferHistoricalGc;
262
263		for term in [FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin] {
264			assert!(
265				class.constrained_by(term),
266				"{class} must keep the {term} term: it protects {}",
267				term.protects()
268			);
269		}
270	}
271
272	#[test]
273	fn a_lagging_subscription_must_not_pin_buffer_history_between_batches() {
274		// An ephemeral reader protects its in-flight batch with a lease and is otherwise overtaken
275		// loudly: a failed acquire triggers a resync, never a silent read of reclaimed history. A
276		// term of its own would let a lagging worker pin buffer history without bound.
277		assert!(
278			!RetentionClass::BufferHistoricalGc.constrained_by(FloorTerm::ConsumerCheckpoint),
279			"a CDC log consumer reads cdc.db, not buffer history, and must not pin it"
280		);
281		assert!(
282			RetentionClass::BufferHistoricalGc.constrained_by(FloorTerm::LeaseMin),
283			"an in-flight subscription batch protects its reads through its lease, so LeaseMin must stay"
284		);
285	}
286
287	#[test]
288	fn the_flush_floor_tracks_live_positions_while_cdc_truncation_tracks_durable_checkpoints() {
289		// The commit buffer is RAM and empty after a restart, so only a live reader can be harmed by
290		// flushing it. cdc.db is the opposite: a flow resumes from its durable checkpoint, so CDC below
291		// that must survive even with no live reader there. Collapsing the terms stalls buffer drain.
292		assert!(
293			RetentionClass::PersistentFlush.constrained_by(FloorTerm::ConsumerPosition),
294			"flushing the in-memory buffer may only be held back by a reader that is live now"
295		);
296		assert!(
297			!RetentionClass::PersistentFlush.constrained_by(FloorTerm::ConsumerCheckpoint),
298			"a throttled durable checkpoint lags the real read position and must not pin the buffer"
299		);
300
301		assert!(
302			RetentionClass::CdcTruncate.constrained_by(FloorTerm::ConsumerCheckpoint),
303			"cdc.db must retain everything a consumer would replay from after a crash"
304		);
305		assert!(
306			!RetentionClass::CdcTruncate.constrained_by(FloorTerm::ConsumerPosition),
307			"a live position is lost on restart, so it cannot govern durable CDC truncation"
308		);
309	}
310
311	#[test]
312	fn cdc_truncation_is_pinned_only_by_its_consumers() {
313		// CDC must respect the slowest consumer, but inheriting query or lease terms would let an
314		// unrelated stuck query stop cdc.db from ever shrinking.
315		let class = RetentionClass::CdcTruncate;
316
317		assert!(
318			class.constrained_by(FloorTerm::ConsumerCheckpoint),
319			"the slowest CDC log consumer legitimately pins CDC"
320		);
321		assert!(
322			!class.constrained_by(FloorTerm::QueryDoneUntil),
323			"an in-flight query does not read the CDC log"
324		);
325		assert!(
326			!class.constrained_by(FloorTerm::LeaseMin),
327			"an operator-state lease does not read the CDC log"
328		);
329	}
330
331	#[test]
332	fn the_epoch_log_is_bounded_by_the_longest_ttl_it_must_still_answer() {
333		// Pruning epoch samples below the longest declared ttl makes that ttl unresolvable: the cutoff
334		// silently becomes none and the data it governs never expires.
335		assert!(
336			RetentionClass::EpochLog.constrained_by(FloorTerm::RetentionHorizon),
337			"pruning epoch samples inside the retention horizon would make long ttls unresolvable"
338		);
339	}
340}