1use 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 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 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 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 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 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 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 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 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 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 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 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 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}