Skip to main content

reifydb_runtime/
version_epoch.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::BTreeMap,
6	sync::{
7		Arc,
8		atomic::{AtomicU64, Ordering},
9	},
10};
11
12use reifydb_value::value::{datetime::DateTime, duration::Duration};
13
14use crate::sync::{mutex::Mutex, rwlock::RwLock};
15
16const DEFAULT_FINE_SAMPLES: usize = 3_600;
17const DEFAULT_COARSE_BUCKET: EpochSpan = EpochSpan::new(60);
18const DEFAULT_MAX_SAMPLES: usize = 100_000;
19
20pub const BUCKET_WIDTH: EpochSpan = EpochSpan::new(1);
21
22pub const MIN_TTL: Duration = Duration::from_seconds_const(1);
23
24#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct EpochSeconds(u64);
26
27impl EpochSeconds {
28	pub const fn new(seconds: u64) -> Self {
29		Self(seconds)
30	}
31
32	pub const fn seconds(self) -> u64 {
33		self.0
34	}
35
36	pub const fn bucket(self) -> BucketIndex {
37		BucketIndex(self.0 / BUCKET_WIDTH.0)
38	}
39
40	pub const fn since(self, earlier: Self) -> EpochSpan {
41		EpochSpan(self.0.saturating_sub(earlier.0))
42	}
43
44	pub const fn plus(self, span: EpochSpan) -> Self {
45		Self(self.0.saturating_add(span.0))
46	}
47
48	pub const fn minus(self, span: EpochSpan) -> Self {
49		Self(self.0.saturating_sub(span.0))
50	}
51
52	pub fn from_datetime(at: DateTime) -> Self {
53		Self(at.to_epoch_secs().max(0) as u64)
54	}
55
56	pub fn to_datetime(self) -> DateTime {
57		DateTime::from_nanos(i64::try_from(self.0.saturating_mul(1_000_000_000)).unwrap_or(i64::MAX))
58	}
59
60	pub const fn from_nanos(nanos: u64) -> Self {
61		Self(nanos / 1_000_000_000)
62	}
63}
64
65#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub struct EpochSpan(u64);
67
68impl EpochSpan {
69	pub const fn new(seconds: u64) -> Self {
70		Self(seconds)
71	}
72
73	pub const fn seconds(self) -> u64 {
74		self.0
75	}
76
77	pub const fn is_zero(self) -> bool {
78		self.0 == 0
79	}
80
81	pub const fn to_duration(self) -> Duration {
82		Duration::from_seconds_const(self.0 as i64)
83	}
84}
85
86#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
87pub struct BucketIndex(u64);
88
89impl BucketIndex {
90	pub const fn new(index: u64) -> Self {
91		Self(index)
92	}
93
94	pub const fn get(self) -> u64 {
95		self.0
96	}
97
98	pub const fn end(self) -> EpochSeconds {
99		EpochSeconds(self.0.saturating_add(1).saturating_mul(BUCKET_WIDTH.0))
100	}
101}
102
103#[derive(Clone, Copy, Debug)]
104pub struct EpochRetention {
105	pub fine_samples: usize,
106	pub coarse_bucket: EpochSpan,
107	pub max_samples: usize,
108}
109
110impl Default for EpochRetention {
111	fn default() -> Self {
112		Self {
113			fine_samples: DEFAULT_FINE_SAMPLES,
114			coarse_bucket: DEFAULT_COARSE_BUCKET,
115			max_samples: DEFAULT_MAX_SAMPLES,
116		}
117	}
118}
119
120impl EpochRetention {
121	pub fn guaranteed_coverage(&self) -> EpochSpan {
122		EpochSpan(
123			(self.max_samples.saturating_sub(self.fine_samples) as u64)
124				.saturating_mul(self.coarse_bucket.0),
125		)
126	}
127}
128
129#[derive(Clone, Debug)]
130pub struct VersionEpoch {
131	inner: Arc<Inner>,
132}
133
134#[derive(Debug)]
135struct Inner {
136	sealed: RwLock<BTreeMap<EpochSeconds, u64>>,
137	retention: RwLock<EpochRetention>,
138	open: Mutex<OpenBucket>,
139	floor_none_returns: AtomicU64,
140}
141
142#[derive(Debug, Default, Clone, Copy)]
143struct OpenBucket {
144	bucket: BucketIndex,
145	max: u64,
146}
147
148impl OpenBucket {
149	fn floor_at(&self, target: EpochSeconds) -> Option<u64> {
150		(self.max != 0 && target >= self.bucket.end()).then_some(self.max)
151	}
152
153	fn admit(&mut self, bucket: BucketIndex, version: u64) -> Option<Self> {
154		if bucket < self.bucket {
155			return None;
156		}
157		if bucket == self.bucket {
158			self.max = self.max.max(version);
159			return None;
160		}
161		let sealed = *self;
162		self.bucket = bucket;
163		self.max = version;
164		(sealed.max != 0).then_some(sealed)
165	}
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct EpochStats {
170	pub samples: usize,
171	pub coverage: EpochSpan,
172	pub floor_none_returns: u64,
173}
174
175impl Default for VersionEpoch {
176	fn default() -> Self {
177		Self::new()
178	}
179}
180
181impl VersionEpoch {
182	pub fn new() -> Self {
183		Self::with_retention(EpochRetention::default())
184	}
185
186	pub fn with_retention(retention: EpochRetention) -> Self {
187		Self {
188			inner: Arc::new(Inner {
189				sealed: RwLock::new(BTreeMap::new()),
190				retention: RwLock::new(retention),
191				open: Mutex::new(OpenBucket::default()),
192				floor_none_returns: AtomicU64::new(0),
193			}),
194		}
195	}
196
197	pub fn retention(&self) -> EpochRetention {
198		*self.inner.retention.read()
199	}
200
201	pub fn set_retention(&self, retention: EpochRetention) {
202		*self.inner.retention.write() = retention;
203		let mut sealed = self.inner.sealed.write();
204		if sealed.len() > retention.max_samples {
205			compact(&mut sealed, &retention);
206		}
207	}
208
209	pub fn record(&self, now: EpochSeconds, version: u64) {
210		if version == 0 {
211			return;
212		}
213		let Some(sealed) = self.inner.open.lock().admit(now.bucket(), version) else {
214			return;
215		};
216		self.seal(sealed.bucket.end(), sealed.max);
217	}
218
219	fn seal(&self, at: EpochSeconds, version: u64) {
220		let retention = *self.inner.retention.read();
221		let mut sealed = self.inner.sealed.write();
222		merge_max(&mut sealed, at, version);
223		if sealed.len() > retention.max_samples {
224			compact(&mut sealed, &retention);
225		}
226	}
227
228	pub fn backfill(&self, at: EpochSeconds, version: u64) {
229		if version == 0 {
230			return;
231		}
232		let retention = *self.inner.retention.read();
233		let mut sealed = self.inner.sealed.write();
234		merge_max(&mut sealed, at, version);
235		if sealed.len() > retention.max_samples {
236			compact(&mut sealed, &retention);
237		}
238	}
239
240	pub fn floor_version_at(&self, target: EpochSeconds) -> Option<u64> {
241		if let Some(version) = self.inner.open.lock().floor_at(target) {
242			return Some(version);
243		}
244		let resolved = self.inner.sealed.read().range(..=target).next_back().map(|(_, version)| *version);
245		if resolved.is_none() {
246			self.inner.floor_none_returns.fetch_add(1, Ordering::Relaxed);
247		}
248		resolved
249	}
250
251	pub fn sample_count(&self) -> usize {
252		self.inner.sealed.read().len()
253	}
254
255	pub fn stats(&self) -> EpochStats {
256		let open = *self.inner.open.lock();
257		let open_end = (open.max != 0).then(|| open.bucket.end());
258
259		let sealed = self.inner.sealed.read();
260		let oldest = sealed.keys().next().copied().or(open_end);
261		let newest = open_end.or_else(|| sealed.keys().next_back().copied());
262		let coverage = match (oldest, newest) {
263			(Some(oldest), Some(newest)) => newest.since(oldest),
264			_ => EpochSpan::default(),
265		};
266
267		EpochStats {
268			samples: sealed.len(),
269			coverage,
270			floor_none_returns: self.inner.floor_none_returns.load(Ordering::Relaxed),
271		}
272	}
273}
274
275fn merge_max(samples: &mut BTreeMap<EpochSeconds, u64>, key: EpochSeconds, version: u64) {
276	let held = samples.entry(key).or_insert(version);
277	if *held < version {
278		*held = version;
279	}
280}
281
282fn compact(samples: &mut BTreeMap<EpochSeconds, u64>, retention: &EpochRetention) {
283	if !retention.coarse_bucket.is_zero() {
284		let coarse_len = samples.len().saturating_sub(retention.fine_samples);
285		let mut drops = Vec::new();
286		let mut kept_bucket: Option<u64> = None;
287		for &key in samples.keys().take(coarse_len) {
288			let bucket = key.seconds() / retention.coarse_bucket.seconds();
289			match kept_bucket {
290				Some(previous) if previous == bucket => drops.push(key),
291				_ => kept_bucket = Some(bucket),
292			}
293		}
294		for key in drops {
295			samples.remove(&key);
296		}
297	}
298
299	while samples.len() > retention.max_samples {
300		let oldest = *samples.keys().next().expect("samples is non-empty during compaction");
301		samples.remove(&oldest);
302	}
303}
304
305#[cfg(test)]
306mod tests {
307	use super::{BUCKET_WIDTH, EpochRetention, EpochSeconds, EpochSpan, MIN_TTL, VersionEpoch};
308
309	fn sec(seconds: u64) -> EpochSeconds {
310		EpochSeconds::new(seconds)
311	}
312
313	fn small() -> VersionEpoch {
314		VersionEpoch::with_retention(EpochRetention {
315			fine_samples: 5,
316			coarse_bucket: EpochSpan::new(10),
317			max_samples: 50,
318		})
319	}
320
321	fn record_sealed(epoch: &VersionEpoch, at: EpochSeconds, version: u64) {
322		// Commits seal the previous bucket, so recording only once would assert against a
323		// permanently open bucket that no floor lookup at `at` can see.
324		epoch.record(at, version);
325		epoch.record(at.plus(BUCKET_WIDTH), version);
326	}
327
328	#[test]
329	fn cold_epoch_returns_none_so_gc_deletes_nothing() {
330		let epoch = VersionEpoch::new();
331		assert_eq!(
332			epoch.floor_version_at(sec(1_000)),
333			None,
334			"an empty epoch must yield no cutoff; otherwise a cold start would evict the whole store"
335		);
336	}
337
338	#[test]
339	fn floor_returns_latest_sample_at_or_before_target() {
340		let epoch = VersionEpoch::new();
341		epoch.record(sec(10), 10);
342		epoch.record(sec(20), 20);
343		epoch.record(sec(30), 30);
344
345		assert_eq!(epoch.floor_version_at(sec(5)), None, "target older than every sample -> no cutoff");
346		assert_eq!(epoch.floor_version_at(sec(15)), Some(10), "floor is the newest sample at or before");
347		assert_eq!(epoch.floor_version_at(sec(25)), Some(20), "floor advances with the target");
348		assert_eq!(epoch.floor_version_at(sec(9_999)), Some(30), "target after all samples -> newest");
349	}
350
351	#[test]
352	fn the_open_bucket_is_invisible_until_the_target_clears_it() {
353		// A bucket still accepting commits cannot be attributed to an instant inside itself: a
354		// commit landing later in the same bucket would then read as having happened before the
355		// target, and rows younger than the TTL would be evicted on that basis.
356		let epoch = VersionEpoch::new();
357		epoch.record(sec(10), 42);
358
359		assert_eq!(
360			epoch.floor_version_at(sec(10)),
361			None,
362			"an instant inside the open bucket must not resolve to that bucket's version"
363		);
364		assert_eq!(
365			epoch.floor_version_at(sec(10).plus(BUCKET_WIDTH)),
366			Some(42),
367			"once the target clears the bucket end every commit it holds is known to be older"
368		);
369	}
370
371	#[test]
372	fn a_stale_timestamp_never_rewrites_a_newer_bucket() {
373		// Wall clocks step backwards (NTP). Accepting the older reading would move the floor
374		// backwards and strand rows that were already eligible to expire.
375		let epoch = VersionEpoch::new();
376		record_sealed(&epoch, sec(30), 30);
377		epoch.record(sec(10), 5);
378
379		assert_eq!(
380			epoch.floor_version_at(sec(40)),
381			Some(30),
382			"a backwards clock reading must be dropped, not applied"
383		);
384	}
385
386	#[test]
387	fn record_keeps_highest_version_within_a_bucket() {
388		// Several commits inside one bucket (a write and the flow processing it triggers) must
389		// collapse to the HIGHEST version, or a row written by the later commit would read as
390		// too young to ever expire.
391		let epoch = VersionEpoch::new();
392		epoch.record(sec(10), 5);
393		epoch.record(sec(10), 9);
394		epoch.record(sec(10), 7);
395		epoch.record(sec(20), 20);
396
397		assert_eq!(
398			epoch.floor_version_at(sec(15)),
399			Some(9),
400			"the highest version committed in the bucket wins, and a lower one cannot undo it"
401		);
402	}
403
404	#[test]
405	fn a_bucket_holds_one_sample_however_many_commits_land_in_it() {
406		// The point of bucketing: map growth is bounded by elapsed time, not by commit rate, so
407		// a write-heavy database cannot inflate the epoch.
408		let epoch = VersionEpoch::new();
409		for version in 1..=1_000u64 {
410			epoch.record(sec(10), version);
411		}
412		epoch.record(sec(20), 1_001);
413
414		assert_eq!(epoch.sample_count(), 1, "a thousand commits in one bucket must seal a single sample");
415		assert_eq!(epoch.floor_version_at(sec(15)), Some(1_000), "and it must carry the highest version");
416	}
417
418	#[test]
419	fn old_samples_survive_far_beyond_the_uniform_eviction_limit() {
420		// Uniform drop-oldest bounds coverage at max_samples * sample_interval. A ttl longer than that
421		// resolves to no cutoff, and no cutoff means the class silently reclaims nothing.
422		let epoch = small();
423		for i in 1..=400u64 {
424			epoch.record(sec(i), i);
425		}
426
427		assert!(epoch.sample_count() <= 50, "the map must stay inside its budget");
428		assert!(epoch.floor_version_at(sec(400)).is_some(), "precondition: the newest end resolves");
429		assert!(
430			epoch.floor_version_at(sec(20)).is_some(),
431			"a target 380 samples back must still resolve; uniform eviction would have dropped it"
432		);
433	}
434
435	#[test]
436	fn thinning_never_reports_a_version_newer_than_the_true_floor() {
437		// A cutoff that is too NEW deletes rows a reader can still resolve. Thinning must therefore only ever
438		// answer with an older version, never a newer one, so coarsening over-retains instead of over-deleting.
439		let exact = VersionEpoch::with_retention(EpochRetention {
440			fine_samples: 5,
441			coarse_bucket: EpochSpan::new(10),
442			max_samples: 10_000,
443		});
444		let thinned = small();
445		for i in 1..=400u64 {
446			exact.record(sec(i), i);
447			thinned.record(sec(i), i);
448		}
449
450		for target in (1..=400u64).map(sec) {
451			let truth = exact.floor_version_at(target);
452			if let Some(version) = thinned.floor_version_at(target) {
453				assert!(
454					Some(version) <= truth,
455					"thinned floor {version:?} exceeded the true floor {truth:?} at {target:?}"
456				);
457			}
458		}
459	}
460
461	#[test]
462	fn the_newest_samples_keep_full_resolution() {
463		// Short TTLs need a precise cutoff; coarsening the recent end would over-retain rows whose whole
464		// purpose is to expire quickly. Precision at the recent end is one bucket, not one coarse bucket.
465		let epoch = small();
466		for i in 1..=400u64 {
467			epoch.record(sec(i), i);
468		}
469
470		assert_eq!(epoch.floor_version_at(sec(400)), Some(399), "the newest sealed sample is exact");
471		assert_eq!(epoch.floor_version_at(sec(399)), Some(398), "one second back is still exact");
472	}
473
474	#[test]
475	fn a_narrowed_retention_compacts_the_existing_map_immediately() {
476		// Boot replaces the constructed default with the configured rule after hydration has already filled the
477		// map. Applying it lazily would leave the map over budget until the next sample, which on an idle
478		// database is unbounded.
479		let epoch = VersionEpoch::with_retention(EpochRetention {
480			fine_samples: 5,
481			coarse_bucket: EpochSpan::new(10),
482			max_samples: 10_000,
483		});
484		for i in 1..=400u64 {
485			epoch.record(sec(i), i);
486		}
487		assert_eq!(epoch.sample_count(), 399, "precondition: nothing compacted under the wide rule");
488
489		epoch.set_retention(EpochRetention {
490			fine_samples: 5,
491			coarse_bucket: EpochSpan::new(10),
492			max_samples: 50,
493		});
494
495		assert!(epoch.sample_count() <= 50, "the narrowed budget must apply to samples already held");
496		assert!(epoch.floor_version_at(sec(400)).is_some(), "compaction must not empty the map");
497	}
498
499	#[test]
500	fn an_unresolvable_floor_is_counted() {
501		// A ttl the epoch cannot answer reclaims nothing and reports success. This counter is the only direct
502		// evidence that a TTL is silently not firing.
503		let epoch = VersionEpoch::new();
504		epoch.record(sec(100), 1);
505
506		assert_eq!(epoch.stats().floor_none_returns, 0, "a resolvable floor must not count");
507		epoch.floor_version_at(sec(50));
508		epoch.floor_version_at(sec(50));
509
510		assert_eq!(epoch.stats().floor_none_returns, 2, "every unanswerable lookup must be counted");
511	}
512
513	#[test]
514	fn coverage_reports_the_span_the_map_can_answer() {
515		let epoch = VersionEpoch::new();
516		assert_eq!(epoch.stats().coverage, EpochSpan::new(0), "an empty epoch covers nothing");
517
518		epoch.record(sec(10), 1);
519		epoch.record(sec(70), 2);
520
521		assert_eq!(epoch.stats().coverage, EpochSpan::new(60), "coverage spans oldest sealed to open bucket");
522		assert_eq!(epoch.stats().samples, 1, "only the rolled-over bucket is sealed; the newest is still open");
523	}
524
525	#[test]
526	fn guaranteed_coverage_exceeds_the_default_retention_horizon_floor() {
527		// The horizon floor promises 7 days of enforceable ttl. Coverage below it means the promise is a
528		// silent no-op for every ttl in between.
529		let week = EpochSpan::new(7 * 24 * 60 * 60);
530
531		assert!(
532			EpochRetention::default().guaranteed_coverage() >= week,
533			"default epoch coverage must reach the default MaxRetentionHorizonFloor"
534		);
535	}
536
537	#[test]
538	fn the_minimum_ttl_covers_at_least_one_whole_bucket() {
539		// Expiry resolves through whole buckets, so a real lifetime lands in [ttl, ttl + BUCKET_WIDTH]:
540		// never early, only late. A minimum TTL covering one bucket caps that error at 100% of the
541		// declared TTL, which is why the two constants may only move together.
542		assert!(
543			MIN_TTL.to_std().as_secs() >= BUCKET_WIDTH.seconds(),
544			"a TTL at the minimum must span at least one whole bucket, or a row outlives its ttl by a \
545			 multiple of itself"
546		);
547	}
548}