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_order(),
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	CommitMerge,
140}
141
142impl RetentionClass {
143	pub fn all() -> &'static [Self] {
144		&[
145			Self::RowTtl,
146			Self::BufferHistoricalGc,
147			Self::PersistentFlush,
148			Self::QueueLeaseReap,
149			Self::QueueRetention,
150			Self::CdcTruncate,
151			Self::EpochLog,
152			Self::CommitMerge,
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::QueueLeaseReap => "queue-lease-reap",
162			Self::QueueRetention => "queue-retention",
163			Self::CdcTruncate => "cdc-truncate",
164			Self::EpochLog => "epoch-log",
165			Self::CommitMerge => "commit-merge",
166		}
167	}
168
169	pub fn reclaims_versioned_data(&self) -> bool {
170		match self {
171			Self::RowTtl
172			| Self::BufferHistoricalGc
173			| Self::PersistentFlush
174			| Self::CdcTruncate
175			| Self::EpochLog
176			| Self::QueueRetention => true,
177			Self::QueueLeaseReap | Self::CommitMerge => false,
178		}
179	}
180
181	pub fn floor_terms(&self) -> &'static [FloorTerm] {
182		match self {
183			Self::RowTtl => &[FloorTerm::RowExpiry],
184			Self::BufferHistoricalGc => &[FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin],
185			Self::PersistentFlush => {
186				&[FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin, FloorTerm::ConsumerPosition]
187			}
188			Self::QueueLeaseReap | Self::CommitMerge => &[],
189			Self::QueueRetention => &[FloorTerm::RowExpiry],
190			Self::CdcTruncate => &[FloorTerm::ConsumerCheckpoint],
191			Self::EpochLog => &[FloorTerm::RetentionHorizon],
192		}
193	}
194
195	pub fn constrained_by(&self, term: FloorTerm) -> bool {
196		self.floor_terms().contains(&term)
197	}
198}
199
200impl fmt::Display for RetentionClass {
201	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202		write!(f, "{}", self.name())
203	}
204}
205
206#[cfg(test)]
207mod tests {
208	use super::{FloorTerm, RetentionClass};
209
210	#[test]
211	fn every_class_declares_a_floor_term_for_exactly_the_data_it_reclaims() {
212		// A class reclaiming versioned data with no floor term deletes at head version. The converse
213		// pins the exemption: a class touching no row, version or tombstone has no honest term to
214		// name, and deleting versioned data under that exemption is what this direction catches.
215		for class in RetentionClass::all() {
216			if class.reclaims_versioned_data() {
217				assert!(
218					!class.floor_terms().is_empty(),
219					"{class} declares no floor term, so nothing bounds what it deletes"
220				);
221			} else {
222				assert!(
223					class.floor_terms().is_empty(),
224					"{class} reclaims no versioned data, so a version floor cannot be what bounds it"
225				);
226			}
227		}
228	}
229
230	#[test]
231	fn class_names_are_unique_so_metrics_and_reports_cannot_collide() {
232		let mut names: Vec<&str> = RetentionClass::all().iter().map(|c| c.name()).collect();
233		let total = names.len();
234		names.sort_unstable();
235		names.dedup();
236
237		assert_eq!(names.len(), total, "two classes share a name; their metrics and report lines would merge");
238	}
239
240	#[test]
241	fn row_expiry_classes_are_not_hostage_to_readers_of_other_data() {
242		// Row expiry is protected by MVCC-transactional discovery, not by these readers. Sharing one
243		// watermark with them lets a wedged CDC consumer or a leaked query lease freeze it.
244		{
245			let class = RetentionClass::RowTtl;
246			assert!(
247				!class.constrained_by(FloorTerm::ConsumerCheckpoint),
248				"{class} must not be pinned by a CDC consumer; it reclaims rows no consumer reads"
249			);
250			assert!(
251				!class.constrained_by(FloorTerm::LeaseMin),
252				"{class} must not be pinned by an operator-state lease"
253			);
254			assert!(
255				!class.constrained_by(FloorTerm::QueryDoneUntil),
256				"{class} must not be pinned by an in-flight query; transactional discovery protects it"
257			);
258		}
259	}
260
261	#[test]
262	fn version_history_classes_respect_every_reader_of_a_snapshot() {
263		// Buffer history is what a live reader resolves against, so an in-flight query and a held
264		// lease must both be present. A lagging subscription rides LeaseMin through its batch lease
265		// rather than holding a term of its own.
266		let class = RetentionClass::BufferHistoricalGc;
267
268		for term in [FloorTerm::QueryDoneUntil, FloorTerm::LeaseMin] {
269			assert!(
270				class.constrained_by(term),
271				"{class} must keep the {term} term: it protects {}",
272				term.protects()
273			);
274		}
275	}
276
277	#[test]
278	fn a_lagging_subscription_must_not_pin_buffer_history_between_batches() {
279		// An ephemeral reader protects its in-flight batch with a lease and is otherwise overtaken
280		// loudly: a failed acquire triggers a resync, never a silent read of reclaimed history. A
281		// term of its own would let a lagging worker pin buffer history without bound.
282		assert!(
283			!RetentionClass::BufferHistoricalGc.constrained_by(FloorTerm::ConsumerCheckpoint),
284			"a CDC log consumer reads cdc.db, not buffer history, and must not pin it"
285		);
286		assert!(
287			RetentionClass::BufferHistoricalGc.constrained_by(FloorTerm::LeaseMin),
288			"an in-flight subscription batch protects its reads through its lease, so LeaseMin must stay"
289		);
290	}
291
292	#[test]
293	fn the_flush_floor_tracks_live_positions_while_cdc_truncation_tracks_durable_checkpoints() {
294		// The commit buffer is RAM and empty after a restart, so only a live reader can be harmed by
295		// flushing it. cdc.db is the opposite: a flow resumes from its durable checkpoint, so CDC below
296		// that must survive even with no live reader there. Collapsing the terms stalls buffer drain.
297		assert!(
298			RetentionClass::PersistentFlush.constrained_by(FloorTerm::ConsumerPosition),
299			"flushing the in-memory buffer may only be held back by a reader that is live now"
300		);
301		assert!(
302			!RetentionClass::PersistentFlush.constrained_by(FloorTerm::ConsumerCheckpoint),
303			"a throttled durable checkpoint lags the real read position and must not pin the buffer"
304		);
305
306		assert!(
307			RetentionClass::CdcTruncate.constrained_by(FloorTerm::ConsumerCheckpoint),
308			"cdc.db must retain everything a consumer would replay from after a crash"
309		);
310		assert!(
311			!RetentionClass::CdcTruncate.constrained_by(FloorTerm::ConsumerPosition),
312			"a live position is lost on restart, so it cannot govern durable CDC truncation"
313		);
314	}
315
316	#[test]
317	fn cdc_truncation_is_pinned_only_by_its_consumers() {
318		// CDC must respect the slowest consumer, but inheriting query or lease terms would let an
319		// unrelated stuck query stop cdc.db from ever shrinking.
320		let class = RetentionClass::CdcTruncate;
321
322		assert!(
323			class.constrained_by(FloorTerm::ConsumerCheckpoint),
324			"the slowest CDC log consumer legitimately pins CDC"
325		);
326		assert!(
327			!class.constrained_by(FloorTerm::QueryDoneUntil),
328			"an in-flight query does not read the CDC log"
329		);
330		assert!(
331			!class.constrained_by(FloorTerm::LeaseMin),
332			"an operator-state lease does not read the CDC log"
333		);
334	}
335
336	#[test]
337	fn the_epoch_log_is_bounded_by_the_longest_ttl_it_must_still_answer() {
338		// Pruning epoch samples below the longest declared ttl makes that ttl unresolvable: the cutoff
339		// silently becomes none and the data it governs never expires.
340		assert!(
341			RetentionClass::EpochLog.constrained_by(FloorTerm::RetentionHorizon),
342			"pruning epoch samples inside the retention horizon would make long ttls unresolvable"
343		);
344	}
345}