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(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 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 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 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 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 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 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 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 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 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 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 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 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}