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	FlushWatermark,
65
66	RetentionHorizon,
67}
68
69impl FloorTerm {
70	pub fn protects(&self) -> &'static str {
71		match self {
72			Self::RowExpiry => "rows younger than their declared ttl",
73			Self::QueryDoneUntil => "an in-flight query reading at its snapshot version",
74			Self::LeaseMin => "a held operator-state lease",
75			Self::ConsumerCheckpoint => "a CDC log consumer that has not yet consumed the version",
76			Self::ConsumerPosition => "a live flow that has not yet consumed the version",
77			Self::FlushWatermark => "a write that has not yet reached the persistent tier",
78			Self::RetentionHorizon => "epoch samples still needed to resolve the longest declared ttl",
79		}
80	}
81
82	pub fn is_clock_driven(&self) -> bool {
83		match self {
84			Self::RowExpiry => true,
85			Self::QueryDoneUntil
86			| Self::LeaseMin
87			| Self::ConsumerCheckpoint
88			| Self::ConsumerPosition
89			| Self::FlushWatermark
90			| Self::RetentionHorizon => false,
91		}
92	}
93
94	pub fn all() -> &'static [Self] {
95		&[
96			Self::RowExpiry,
97			Self::QueryDoneUntil,
98			Self::LeaseMin,
99			Self::ConsumerCheckpoint,
100			Self::ConsumerPosition,
101			Self::FlushWatermark,
102			Self::RetentionHorizon,
103		]
104	}
105
106	pub fn index(&self) -> usize {
107		Self::all().iter().position(|term| term == self).expect("every term is listed in FloorTerm::all")
108	}
109
110	pub fn from_index(index: usize) -> Option<Self> {
111		Self::all().get(index).copied()
112	}
113}
114
115impl fmt::Display for FloorTerm {
116	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117		match self {
118			Self::RowExpiry => write!(f, "row-expiry"),
119			Self::QueryDoneUntil => write!(f, "query-done-until"),
120			Self::LeaseMin => write!(f, "lease-min"),
121			Self::ConsumerCheckpoint => write!(f, "consumer-checkpoint"),
122			Self::ConsumerPosition => write!(f, "consumer-position"),
123			Self::FlushWatermark => write!(f, "flush-watermark"),
124			Self::RetentionHorizon => write!(f, "retention-horizon"),
125		}
126	}
127}
128
129#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
130pub enum RetentionClass {
131	RowTtl,
132
133	BufferHistoricalGc,
134
135	PersistentFlush,
136
137	TombstoneReap,
138
139	CdcTruncate,
140
141	EpochLog,
142}
143
144impl RetentionClass {
145	pub fn all() -> &'static [Self] {
146		&[
147			Self::RowTtl,
148			Self::BufferHistoricalGc,
149			Self::PersistentFlush,
150			Self::TombstoneReap,
151			Self::CdcTruncate,
152			Self::EpochLog,
153		]
154	}
155
156	pub fn name(&self) -> &'static str {
157		match self {
158			Self::RowTtl => "row-ttl-silent",
159			Self::BufferHistoricalGc => "buffer-historical-gc",
160			Self::PersistentFlush => "persistent-flush",
161			Self::TombstoneReap => "tombstone-reap",
162			Self::CdcTruncate => "cdc-truncate",
163			Self::EpochLog => "epoch-log",
164		}
165	}
166
167	pub fn reclaims_versioned_data(&self) -> bool {
168		match self {
169			Self::RowTtl
170			| Self::BufferHistoricalGc
171			| Self::PersistentFlush
172			| Self::TombstoneReap
173			| Self::CdcTruncate
174			| Self::EpochLog => true,
175		}
176	}
177
178	pub fn floor_terms(&self) -> &'static [FloorTerm] {
179		match self {
180			Self::RowTtl => &[FloorTerm::RowExpiry],
181			Self::BufferHistoricalGc => &[FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin],
182			Self::PersistentFlush => {
183				&[FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin, FloorTerm::ConsumerPosition]
184			}
185			Self::TombstoneReap => &[FloorTerm::FlushWatermark],
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}