Skip to main content

reifydb_core/lifecycle/
metrics.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 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		// Every clock-driven floor in the ledger is `now - ttl` rendered as an instant, so a test
243		// exercising that path has to hand the same shape over: a version floor would take the pinned
244		// branch and prove nothing about the branch under test.
245		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		// A row-expiry floor is `now - ttl`: only the clock feeds it, so no reader, lease or consumer
251		// can hold it down and "the floor will not advance" cannot be a true diagnosis for it. The
252		// sequence below is what a budgeted sweep across a ttl ladder produces - the reported cutoff
253		// swinging between a short-lived and a long-lived object - which the pinned predicate reads as
254		// a floor moving backwards and alarms on.
255		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		// A sweep that cannot finish inside one budget leaves a backlog and reclaims nothing on the
275		// slices that only scan, and the lane re-runs it every few milliseconds. That is a healthy
276		// catch-up, not starvation, and alarming on it buries the real signal under dozens of lines a
277		// second.
278		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		// The condition worth waking someone for is a class that has held a backlog and reclaimed
292		// nothing for long enough that it cannot be a sweep in progress. It must still speak once per
293		// window rather than once per slice, because the lane re-slices at its catch-up cadence.
294		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		// A class that drains some of its backlog is making progress, and the clock it is measured
316		// against keeps running. Without restarting the window, the accumulated age of an old stall
317		// would alarm on a class that has since recovered.
318		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		// Splitting the alarm by binding must not retire the case it exists for: a lease, a query or a
347		// consumer holding the floor down is a real pin, diagnosed by naming the party responsible.
348		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		// "No floor" and "nothing eligible" are different states. A database that declares no ttl at
368		// all resolves no cutoff and has nothing to reclaim, which is not a fault; the same missing
369		// cutoff with rows waiting behind it means the class can reclaim nothing and must be heard.
370		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		// A class that only appears in the report once it has done work is invisible exactly when it matters:
388		// the report is how an operator checks whether a class is running at all.
389		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		// A class that keeps ticking and reports success while its floor never moves reclaims nothing;
401		// it has to be counted, or it reads as idle.
402		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		// None means the epoch could not resolve a cutoff - the exact state in which TTLs silently stopped
428		// firing. It must surface, because the executor's own behaviour (delete nothing) is indistinguishable
429		// from having nothing to do.
430		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		// "buffer-historical-gc is stuck" is not actionable; "stuck on lease-min" points at a leaked
465		// or long-held lease, while the same class stuck on query-done-until points at a wedged query.
466		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		// No cutoff means no term bound it. Reporting a stale binding would send an operator after a reader
481		// that is not the problem.
482		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		// The alarm fires on the transition into stuck. Without clearing on progress a class that recovers and
494		// wedges a second time would stay silent for the rest of the process lifetime.
495		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		// An idle database freezes its floor and reclaims nothing every tick. That is counted, because a
513		// human reading the report should see it, but it must not be an alarm or the alarm becomes noise on
514		// every quiet system.
515		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		// The alarm condition: rows are eligible, the floor will not move, nothing is being reclaimed. Only
528		// the backlog distinguishes this from the idle case above.
529		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		// Accounting is per class so one wedged class is visible as one wedged class, not smeared
544		// across the others.
545		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		// Draining under budget is healthy; draining under budget while the backlog stays high is losing
564		// ground. Only both counters together distinguish them.
565		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}