1use std::{
5 collections::BTreeMap,
6 sync::{
7 Arc,
8 atomic::{AtomicU64, Ordering},
9 },
10};
11
12use crate::lifecycle::class::{Floor, FloorTerm, RetentionClass};
13#[derive(Debug, Default)]
14pub struct GcMetrics {
15 pub objects_scanned: u64,
16 pub versions_dropped: u64,
17}
18
19const STARVATION_WINDOW_NANOS: u64 = 5 * 60 * 1_000_000_000;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum StuckOnset {
23 Quiet,
24 FloorPinned {
25 floor: Floor,
26 binding: FloorTerm,
27 backlog_hint: u64,
28 },
29 Starved {
30 binding: FloorTerm,
31 backlog_hint: u64,
32 },
33 FloorUnresolvable,
34}
35
36#[derive(Debug, Default, Clone, PartialEq, Eq)]
37pub struct ClassSnapshot {
38 pub floor_version: u64,
39
40 pub binding: Option<FloorTerm>,
41
42 pub work_done: u64,
43
44 pub backlog_hint: u64,
45
46 pub slices: u64,
47
48 pub stuck_slices: u64,
49
50 pub budget_exhausted_slices: u64,
51
52 pub gated_slices: u64,
53}
54
55#[derive(Default)]
56struct ClassCounters {
57 floor_version: AtomicU64,
58 binding: AtomicU64,
59 stuck_streak: AtomicU64,
60 starved_since: AtomicU64,
61 work_done: AtomicU64,
62 backlog_hint: AtomicU64,
63 slices: AtomicU64,
64 stuck_slices: AtomicU64,
65 budget_exhausted_slices: AtomicU64,
66 gated_slices: AtomicU64,
67}
68
69impl ClassCounters {
70 fn record_starvation(
71 &self,
72 floor_key: u64,
73 binding: FloorTerm,
74 work_done: u64,
75 backlog_hint: u64,
76 ) -> StuckOnset {
77 if work_done > 0 || backlog_hint == 0 {
78 self.stuck_streak.store(0, Ordering::Relaxed);
79 return StuckOnset::Quiet;
80 }
81
82 self.stuck_slices.fetch_add(1, Ordering::Relaxed);
83 if self.stuck_streak.fetch_add(1, Ordering::Relaxed) == 0 {
84 self.starved_since.store(floor_key, Ordering::Relaxed);
85 return StuckOnset::Quiet;
86 }
87
88 let since = self.starved_since.load(Ordering::Relaxed);
89 if floor_key < since {
90 self.starved_since.store(floor_key, Ordering::Relaxed);
91 return StuckOnset::Quiet;
92 }
93 if floor_key - since < STARVATION_WINDOW_NANOS {
94 return StuckOnset::Quiet;
95 }
96
97 self.starved_since.store(floor_key, Ordering::Relaxed);
98 StuckOnset::Starved {
99 binding,
100 backlog_hint,
101 }
102 }
103
104 fn record_pinning(&self, floor: Option<(Floor, FloorTerm)>, work_done: u64, backlog_hint: u64) -> StuckOnset {
105 let stuck = match floor {
106 Some((floor, _)) => {
107 let key = floor.monotonic_key();
108 let previous = self.floor_version.swap(key, Ordering::Relaxed);
109 key <= previous && work_done == 0
110 }
111 None => true,
112 };
113
114 if !stuck {
115 self.stuck_streak.store(0, Ordering::Relaxed);
116 return StuckOnset::Quiet;
117 }
118
119 self.stuck_slices.fetch_add(1, Ordering::Relaxed);
120 if self.stuck_streak.fetch_add(1, Ordering::Relaxed) > 0 {
121 return StuckOnset::Quiet;
122 }
123 if backlog_hint == 0 {
124 return StuckOnset::Quiet;
125 }
126
127 match floor {
128 Some((floor, binding)) => StuckOnset::FloorPinned {
129 floor,
130 binding,
131 backlog_hint,
132 },
133 None => StuckOnset::FloorUnresolvable,
134 }
135 }
136
137 fn snapshot(&self) -> ClassSnapshot {
138 ClassSnapshot {
139 floor_version: self.floor_version.load(Ordering::Relaxed),
140 binding: decode_binding(self.binding.load(Ordering::Relaxed)),
141 work_done: self.work_done.load(Ordering::Relaxed),
142 backlog_hint: self.backlog_hint.load(Ordering::Relaxed),
143 slices: self.slices.load(Ordering::Relaxed),
144 stuck_slices: self.stuck_slices.load(Ordering::Relaxed),
145 budget_exhausted_slices: self.budget_exhausted_slices.load(Ordering::Relaxed),
146 gated_slices: self.gated_slices.load(Ordering::Relaxed),
147 }
148 }
149}
150
151#[derive(Clone, Default)]
152pub struct RetentionMetrics {
153 classes: Arc<BTreeMap<RetentionClass, ClassCounters>>,
154}
155
156impl RetentionMetrics {
157 pub fn new() -> Self {
158 let classes = RetentionClass::all().iter().map(|class| (*class, ClassCounters::default())).collect();
159 Self {
160 classes: Arc::new(classes),
161 }
162 }
163
164 fn counters(&self, class: RetentionClass) -> Option<&ClassCounters> {
165 self.classes.get(&class)
166 }
167
168 pub fn record_liveness(&self, class: RetentionClass) {
169 if let Some(counters) = self.counters(class) {
170 counters.slices.fetch_add(1, Ordering::Relaxed);
171 }
172 }
173
174 pub fn record_reclamation(
175 &self,
176 class: RetentionClass,
177 floor: Option<(Floor, FloorTerm)>,
178 work_done: u64,
179 backlog_hint: u64,
180 ) -> StuckOnset {
181 let Some(counters) = self.counters(class) else {
182 return StuckOnset::Quiet;
183 };
184 counters.work_done.fetch_add(work_done, Ordering::Relaxed);
185 counters.backlog_hint.store(backlog_hint, Ordering::Relaxed);
186 counters.binding.store(encode_binding(floor.map(|(_, binding)| binding)), Ordering::Relaxed);
187
188 match floor {
189 Some((floor, binding)) if binding.is_clock_driven() => {
190 let key = floor.monotonic_key();
191 counters.floor_version.store(key, Ordering::Relaxed);
192 counters.record_starvation(key, binding, work_done, backlog_hint)
193 }
194 _ => counters.record_pinning(floor, work_done, backlog_hint),
195 }
196 }
197
198 pub fn record_budget_exhausted(&self, class: RetentionClass) {
199 if let Some(counters) = self.counters(class) {
200 counters.budget_exhausted_slices.fetch_add(1, Ordering::Relaxed);
201 }
202 }
203
204 pub fn record_gated(&self, class: RetentionClass) {
205 if let Some(counters) = self.counters(class) {
206 counters.gated_slices.fetch_add(1, Ordering::Relaxed);
207 }
208 }
209
210 pub fn snapshot(&self, class: RetentionClass) -> ClassSnapshot {
211 self.counters(class).map(ClassCounters::snapshot).unwrap_or_default()
212 }
213
214 pub fn report(&self) -> Vec<(RetentionClass, ClassSnapshot)> {
215 self.classes.iter().map(|(class, counters)| (*class, counters.snapshot())).collect()
216 }
217}
218
219fn encode_binding(term: Option<FloorTerm>) -> u64 {
220 term.map(|term| term.index() as u64 + 1).unwrap_or(0)
221}
222
223fn decode_binding(encoded: u64) -> Option<FloorTerm> {
224 FloorTerm::from_index(encoded.checked_sub(1)? as usize)
225}
226
227#[cfg(test)]
228mod tests {
229 use reifydb_value::value::datetime::DateTime;
230
231 use super::{RetentionMetrics, STARVATION_WINDOW_NANOS, StuckOnset};
232 use crate::{
233 common::CommitVersion,
234 lifecycle::class::{Floor, FloorTerm, RetentionClass},
235 };
236
237 const HOUR_NANOS: u64 = 3_600 * 1_000_000_000;
238
239 const BASE: u64 = 10 * HOUR_NANOS;
240
241 fn expiry_floor(nanos: u64) -> Option<(Floor, FloorTerm)> {
242 Some((Floor::Instant(DateTime::from_nanos(nanos)), FloorTerm::RowExpiry))
246 }
247
248 #[test]
249 fn a_clock_driven_binding_is_never_reported_as_a_pinned_floor() {
250 let metrics = RetentionMetrics::new();
256 let class = RetentionClass::RowTtl;
257
258 for step in 0..20u64 {
259 let floor = match step % 2 {
260 0 => BASE,
261 _ => BASE - HOUR_NANOS,
262 };
263 let onset = metrics.record_reclamation(class, expiry_floor(floor), 0, 2);
264
265 assert!(
266 !matches!(onset, StuckOnset::FloorPinned { .. }),
267 "slice {step} reported a floor that nothing but the clock can move as pinned"
268 );
269 }
270 }
271
272 #[test]
273 fn a_catch_up_sweep_shorter_than_the_window_never_alarms() {
274 let metrics = RetentionMetrics::new();
279 let class = RetentionClass::RowTtl;
280 let step = STARVATION_WINDOW_NANOS / 100;
281
282 for slice in 0..50u64 {
283 let onset = metrics.record_reclamation(class, expiry_floor(BASE + slice * step), 0, 2);
284
285 assert_eq!(onset, StuckOnset::Quiet, "slice {slice} alarmed inside the starvation window");
286 }
287 }
288
289 #[test]
290 fn a_backlog_that_outlives_the_window_alarms_once_per_window() {
291 let metrics = RetentionMetrics::new();
295 let class = RetentionClass::RowTtl;
296
297 assert_eq!(metrics.record_reclamation(class, expiry_floor(BASE), 0, 7), StuckOnset::Quiet);
298 assert_eq!(
299 metrics.record_reclamation(class, expiry_floor(BASE + STARVATION_WINDOW_NANOS), 0, 7),
300 StuckOnset::Starved {
301 binding: FloorTerm::RowExpiry,
302 backlog_hint: 7,
303 },
304 "a backlog held across the whole window with nothing reclaimed must alarm"
305 );
306 assert_eq!(
307 metrics.record_reclamation(class, expiry_floor(BASE + STARVATION_WINDOW_NANOS + 1), 0, 7),
308 StuckOnset::Quiet,
309 "the very next slice must not alarm again, or the window has bought nothing"
310 );
311 }
312
313 #[test]
314 fn reclaiming_anything_restarts_the_starvation_window() {
315 let metrics = RetentionMetrics::new();
319 let class = RetentionClass::RowTtl;
320
321 metrics.record_reclamation(class, expiry_floor(BASE), 0, 4);
322 metrics.record_reclamation(class, expiry_floor(BASE + STARVATION_WINDOW_NANOS), 12, 4);
323
324 assert_eq!(
325 metrics.record_reclamation(class, expiry_floor(BASE + STARVATION_WINDOW_NANOS + 1), 0, 4),
326 StuckOnset::Quiet,
327 "the slice after progress opens a fresh window rather than inheriting the old one"
328 );
329 assert_eq!(
330 metrics.record_reclamation(class, expiry_floor(BASE + 2 * STARVATION_WINDOW_NANOS), 0, 4),
331 StuckOnset::Quiet,
332 "and the fresh window must run its full length from the slice that opened it"
333 );
334 assert_eq!(
335 metrics.record_reclamation(class, expiry_floor(BASE + 2 * STARVATION_WINDOW_NANOS + 2), 0, 4),
336 StuckOnset::Starved {
337 binding: FloorTerm::RowExpiry,
338 backlog_hint: 4,
339 },
340 "once the fresh window elapses the class is starving again and must say so"
341 );
342 }
343
344 #[test]
345 fn an_externally_pinned_floor_is_still_reported_as_pinned() {
346 let metrics = RetentionMetrics::new();
349 let class = RetentionClass::BufferHistoricalGc;
350 let floor = Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin));
351
352 assert_eq!(metrics.record_reclamation(class, floor, 0, 3), StuckOnset::Quiet);
353
354 assert_eq!(
355 metrics.record_reclamation(class, floor, 0, 3),
356 StuckOnset::FloorPinned {
357 floor: Floor::Version(CommitVersion(10)),
358 binding: FloorTerm::LeaseMin,
359 backlog_hint: 3,
360 },
361 "a version floor that did not move while work waited must still name what holds it"
362 );
363 }
364
365 #[test]
366 fn an_unresolvable_floor_alarms_only_when_something_is_waiting_on_it() {
367 let idle = RetentionMetrics::new();
371 assert_eq!(
372 idle.record_reclamation(RetentionClass::RowTtl, None, 0, 0),
373 StuckOnset::Quiet,
374 "an unresolvable floor with nothing eligible is not a fault to alarm on"
375 );
376
377 let waiting = RetentionMetrics::new();
378 assert_eq!(
379 waiting.record_reclamation(RetentionClass::RowTtl, None, 0, 3),
380 StuckOnset::FloorUnresolvable,
381 "the same missing cutoff with work waiting behind it must still be reported"
382 );
383 }
384
385 #[test]
386 fn every_class_is_present_from_construction() {
387 let metrics = RetentionMetrics::new();
390
391 assert_eq!(
392 metrics.report().len(),
393 RetentionClass::all().len(),
394 "the report must enumerate every class before any of them has run"
395 );
396 }
397
398 #[test]
399 fn a_slice_that_reclaims_nothing_on_a_frozen_floor_counts_as_stuck() {
400 let metrics = RetentionMetrics::new();
403 let class = RetentionClass::RowTtl;
404
405 metrics.record_reclamation(
406 class,
407 Some((Floor::Version(CommitVersion(100)), FloorTerm::QueryDoneUntil)),
408 0,
409 0,
410 );
411 metrics.record_reclamation(
412 class,
413 Some((Floor::Version(CommitVersion(100)), FloorTerm::QueryDoneUntil)),
414 0,
415 0,
416 );
417
418 assert_eq!(
419 metrics.snapshot(class).stuck_slices,
420 1,
421 "a repeated floor with no work done is a stuck class, not an idle one"
422 );
423 }
424
425 #[test]
426 fn an_unresolvable_floor_counts_as_stuck_rather_than_silently_passing() {
427 let metrics = RetentionMetrics::new();
431 let class = RetentionClass::RowTtl;
432
433 metrics.record_reclamation(class, None, 0, 0);
434
435 assert_eq!(metrics.snapshot(class).stuck_slices, 1, "an unresolvable cutoff must be reported as stuck");
436 }
437
438 #[test]
439 fn an_advancing_floor_with_work_is_never_stuck() {
440 let metrics = RetentionMetrics::new();
441 let class = RetentionClass::RowTtl;
442
443 metrics.record_reclamation(
444 class,
445 Some((Floor::Version(CommitVersion(100)), FloorTerm::QueryDoneUntil)),
446 10,
447 0,
448 );
449 metrics.record_reclamation(
450 class,
451 Some((Floor::Version(CommitVersion(200)), FloorTerm::QueryDoneUntil)),
452 10,
453 0,
454 );
455
456 let snapshot = metrics.snapshot(class);
457 assert_eq!(snapshot.stuck_slices, 0, "a class making progress must not be flagged");
458 assert_eq!(snapshot.work_done, 20, "work is cumulative across slices");
459 assert_eq!(snapshot.floor_version, 200, "the floor reported is the most recent one");
460 }
461
462 #[test]
463 fn the_binding_term_is_reported_so_a_stuck_class_names_what_holds_it() {
464 let metrics = RetentionMetrics::new();
467 let class = RetentionClass::BufferHistoricalGc;
468
469 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 0);
470
471 assert_eq!(
472 metrics.snapshot(class).binding,
473 Some(FloorTerm::LeaseMin),
474 "the term that produced the cutoff must survive into the report"
475 );
476 }
477
478 #[test]
479 fn an_unresolvable_floor_reports_no_binding_term() {
480 let metrics = RetentionMetrics::new();
483 let class = RetentionClass::RowTtl;
484 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 1, 0);
485
486 metrics.record_reclamation(class, None, 0, 0);
487
488 assert_eq!(metrics.snapshot(class).binding, None, "an unresolved floor must clear the binding term");
489 }
490
491 #[test]
492 fn progress_clears_the_stuck_streak_so_the_alarm_can_fire_again() {
493 let metrics = RetentionMetrics::new();
496 let class = RetentionClass::RowTtl;
497
498 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 0);
499 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 0);
500 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(20)), FloorTerm::LeaseMin)), 5, 0);
501 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(20)), FloorTerm::LeaseMin)), 0, 0);
502
503 assert_eq!(
504 metrics.snapshot(class).stuck_slices,
505 2,
506 "stuck_slices stays cumulative across separate wedges"
507 );
508 }
509
510 #[test]
511 fn a_frozen_floor_with_no_eligible_work_is_stuck_but_not_an_alarm() {
512 let metrics = RetentionMetrics::new();
516 let class = RetentionClass::RowTtl;
517
518 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 0);
519 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 0);
520
521 assert_eq!(metrics.snapshot(class).stuck_slices, 1, "an idle frozen floor is still counted as stuck");
522 assert_eq!(metrics.snapshot(class).backlog_hint, 0, "and reports nothing eligible");
523 }
524
525 #[test]
526 fn a_frozen_floor_with_work_waiting_reports_the_backlog() {
527 let metrics = RetentionMetrics::new();
530 let class = RetentionClass::RowTtl;
531
532 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 3);
533 metrics.record_reclamation(class, Some((Floor::Version(CommitVersion(10)), FloorTerm::LeaseMin)), 0, 3);
534
535 let snapshot = metrics.snapshot(class);
536 assert_eq!(snapshot.stuck_slices, 1);
537 assert_eq!(snapshot.backlog_hint, 3, "the eligible-work estimate must reach the report");
538 assert_eq!(snapshot.binding, Some(FloorTerm::LeaseMin), "and name what is holding the floor down");
539 }
540
541 #[test]
542 fn classes_account_independently() {
543 let metrics = RetentionMetrics::new();
546
547 metrics.record_reclamation(RetentionClass::CdcTruncate, None, 0, 0);
548 metrics.record_reclamation(
549 RetentionClass::RowTtl,
550 Some((Floor::Version(CommitVersion(5)), FloorTerm::QueryDoneUntil)),
551 3,
552 0,
553 );
554
555 assert_eq!(metrics.snapshot(RetentionClass::CdcTruncate).stuck_slices, 1);
556 assert_eq!(metrics.snapshot(RetentionClass::RowTtl).stuck_slices, 0);
557 assert_eq!(metrics.snapshot(RetentionClass::CdcTruncate).work_done, 0);
558 assert_eq!(metrics.snapshot(RetentionClass::RowTtl).work_done, 3);
559 }
560
561 #[test]
562 fn a_backlog_that_survives_a_full_budget_is_visible() {
563 let metrics = RetentionMetrics::new();
566 let class = RetentionClass::RowTtl;
567
568 metrics.record_reclamation(
569 class,
570 Some((Floor::Version(CommitVersion(10)), FloorTerm::QueryDoneUntil)),
571 1024,
572 50_000,
573 );
574 metrics.record_budget_exhausted(class);
575
576 let snapshot = metrics.snapshot(class);
577 assert_eq!(snapshot.budget_exhausted_slices, 1);
578 assert_eq!(
579 snapshot.backlog_hint, 50_000,
580 "the latest backlog estimate must be readable, not accumulated"
581 );
582 }
583}