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