reifydb_core/lifecycle/
gate.rs1use 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 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 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 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 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 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 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 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}