Skip to main content

reifydb_core/lifecycle/
gate.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::sync::{
5	Arc,
6	atomic::{AtomicU64, Ordering},
7};
8
9use reifydb_runtime::context::clock::Clock;
10use reifydb_value::value::duration::Duration;
11
12use crate::lifecycle::{class::RetentionClass, progress::Progress, task::LifecycleTask};
13
14#[derive(Clone)]
15pub struct RetentionStartupGate {
16	inner: Arc<Inner>,
17}
18
19struct Inner {
20	clock: Clock,
21	armed_at_nanos: u64,
22	grace: Duration,
23	skipped_slices: AtomicU64,
24}
25
26impl RetentionStartupGate {
27	pub fn arm(clock: Clock, grace: Duration) -> Self {
28		let armed_at_nanos = clock.now().to_nanos();
29		Self {
30			inner: Arc::new(Inner {
31				clock,
32				armed_at_nanos,
33				grace,
34				skipped_slices: AtomicU64::new(0),
35			}),
36		}
37	}
38
39	pub fn open(clock: Clock) -> Self {
40		Self::arm(clock, Duration::zero())
41	}
42
43	pub fn is_open(&self) -> bool {
44		if self.inner.grace.is_zero() {
45			return true;
46		}
47		let now = self.inner.clock.now();
48		match now.checked_sub(self.inner.grace) {
49			Some(released) => released.to_nanos() >= self.inner.armed_at_nanos,
50			None => false,
51		}
52	}
53
54	pub fn record_skip(&self) {
55		self.inner.skipped_slices.fetch_add(1, Ordering::Relaxed);
56	}
57
58	pub fn skipped_slices(&self) -> u64 {
59		self.inner.skipped_slices.load(Ordering::Relaxed)
60	}
61
62	pub fn grace(&self) -> Duration {
63		self.inner.grace
64	}
65}
66
67pub struct Gated<T: LifecycleTask> {
68	inner: T,
69	gate: RetentionStartupGate,
70}
71
72impl<T: LifecycleTask> Gated<T> {
73	pub fn new(inner: T, gate: RetentionStartupGate) -> Self {
74		Self {
75			inner,
76			gate,
77		}
78	}
79}
80
81impl<T: LifecycleTask> LifecycleTask for Gated<T> {
82	fn name(&self) -> &'static str {
83		self.inner.name()
84	}
85
86	fn interval(&self) -> Duration {
87		self.inner.interval()
88	}
89
90	fn classes(&self) -> &'static [RetentionClass] {
91		self.inner.classes()
92	}
93
94	fn run_slice(&mut self) -> Progress {
95		if !self.gate.is_open() {
96			self.gate.record_skip();
97			return Progress::Exhausted;
98		}
99		self.inner.run_slice()
100	}
101}
102
103#[cfg(test)]
104mod tests {
105	use std::sync::{
106		Arc,
107		atomic::{AtomicU64, Ordering},
108	};
109
110	use reifydb_runtime::context::clock::{Clock, MockClock};
111	use reifydb_value::value::duration::Duration;
112
113	use super::{Gated, RetentionStartupGate};
114	use crate::lifecycle::{class::RetentionClass, progress::Progress, task::LifecycleTask};
115
116	fn mock() -> (Clock, MockClock) {
117		let mock = MockClock::from_millis(0);
118		(Clock::Mock(mock.clone()), mock)
119	}
120
121	#[test]
122	fn a_gate_armed_with_a_grace_period_starts_closed() {
123		// The whole point of the gate: the first tick after a restart must not delete. A gate that starts
124		// open would let a process that was down longer than its TTLs mass-evict on tick one.
125		let (clock, _mock) = mock();
126		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(300).unwrap());
127
128		assert!(!gate.is_open(), "a freshly armed gate must hold reclamation back for its grace period");
129	}
130
131	#[test]
132	fn a_zero_grace_gate_is_open_immediately() {
133		// Tests and single-shot tools need reclamation without waiting out a grace period; zero grace is the
134		// documented way to ask for that, so it must not accidentally still gate.
135		let (clock, _mock) = mock();
136		let gate = RetentionStartupGate::open(clock);
137
138		assert!(gate.is_open(), "zero grace must mean no gating at all");
139	}
140
141	#[test]
142	fn the_gate_opens_once_the_grace_period_has_elapsed() {
143		let (clock, mock) = mock();
144		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(60).unwrap());
145		assert!(!gate.is_open(), "precondition: still inside the grace window");
146
147		mock.advance_secs(59);
148		assert!(!gate.is_open(), "one second short of the grace period must still gate");
149
150		mock.advance_secs(1);
151		assert!(gate.is_open(), "once the grace period elapses the gate must release reclamation");
152	}
153
154	#[test]
155	fn the_gate_stays_open_once_released() {
156		// The gate is a startup guard, not a rate limiter; re-closing it later would stall reclamation
157		// permanently on any clock that is not strictly monotonic across reads.
158		let (clock, mock) = mock();
159		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(60).unwrap());
160		mock.advance_secs(60);
161		assert!(gate.is_open(), "precondition: released");
162
163		mock.advance_secs(3600);
164
165		assert!(gate.is_open(), "a released gate must remain open for the life of the process");
166	}
167
168	struct CountingTask {
169		slices: Arc<AtomicU64>,
170	}
171
172	impl LifecycleTask for CountingTask {
173		fn name(&self) -> &'static str {
174			"counting"
175		}
176
177		fn interval(&self) -> Duration {
178			Duration::from_seconds(1).unwrap()
179		}
180
181		fn classes(&self) -> &'static [RetentionClass] {
182			&[RetentionClass::RowTtl]
183		}
184
185		fn run_slice(&mut self) -> Progress {
186			self.slices.fetch_add(1, Ordering::SeqCst);
187			Progress::Exhausted
188		}
189	}
190
191	#[test]
192	fn a_gated_task_does_no_work_while_the_gate_is_closed() {
193		// A durable epoch un-blinds every TTL consumer at boot, so the first slice after a long downtime
194		// would try to reclaim the whole backlog at once. The gate must stop the work itself, not
195		// merely record that it happened.
196		let (clock, _mock) = mock();
197		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(300).unwrap());
198		let slices = Arc::new(AtomicU64::new(0));
199		let mut task = Gated::new(
200			CountingTask {
201				slices: slices.clone(),
202			},
203			gate.clone(),
204		);
205
206		assert_eq!(task.run_slice(), Progress::Exhausted, "a gated slice must not ask the lane for a catch-up");
207
208		assert_eq!(slices.load(Ordering::SeqCst), 0, "the wrapped task must not run at all while gated");
209		assert_eq!(gate.skipped_slices(), 1, "the skip must be counted so a gated class is not read as idle");
210	}
211
212	#[test]
213	fn a_gated_task_runs_normally_once_the_gate_opens() {
214		// The other half: a gate that never releases is indistinguishable from reclamation being disabled,
215		// which is the failure this whole subsystem exists to make impossible.
216		let (clock, mock) = mock();
217		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(60).unwrap());
218		let slices = Arc::new(AtomicU64::new(0));
219		let mut task = Gated::new(
220			CountingTask {
221				slices: slices.clone(),
222			},
223			gate,
224		);
225		task.run_slice();
226		assert_eq!(slices.load(Ordering::SeqCst), 0, "precondition: gated");
227
228		mock.advance_secs(60);
229		task.run_slice();
230
231		assert_eq!(slices.load(Ordering::SeqCst), 1, "once released the wrapped task must run");
232	}
233
234	#[test]
235	fn gating_preserves_the_wrapped_class_identity() {
236		// The name and cadence are how the lane schedules a class and how the report and metrics key it. A
237		// wrapper that renamed or re-timed its inner task would make the gated class untraceable.
238		let (clock, _mock) = mock();
239		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(300).unwrap());
240		let task = Gated::new(
241			CountingTask {
242				slices: Arc::new(AtomicU64::new(0)),
243			},
244			gate,
245		);
246
247		assert_eq!(task.name(), "counting", "gating must not rename the class");
248		assert_eq!(task.interval(), Duration::from_seconds(1).unwrap(), "gating must not change the cadence");
249	}
250
251	#[test]
252	fn the_gate_counts_the_slices_it_turned_away() {
253		// A gated executor looks identical to a broken one from the outside - both report zero work. The skip
254		// counter is what distinguishes "deliberately held back" from "silently not running", which is the
255		// exact ambiguity this subsystem exists to remove.
256		let (clock, _mock) = mock();
257		let gate = RetentionStartupGate::arm(clock, Duration::from_seconds(300).unwrap());
258
259		for _ in 0..3 {
260			if !gate.is_open() {
261				gate.record_skip();
262			}
263		}
264
265		assert_eq!(gate.skipped_slices(), 3, "every skipped slice must be counted, not silently dropped");
266	}
267}