1use std::{collections::HashMap, ops::Bound};
5
6use reifydb_core::{
7 common::CommitVersion,
8 encoded::{key::EncodedKey, row::EncodedRow},
9 interface::{catalog::flow::FlowNodeId, store::EntryKind},
10 key::{EncodableKey, flow_node_state::FlowNodeStateKey},
11 row::{Ttl, TtlAnchor},
12};
13use reifydb_value::Result;
14
15use super::OperatorScanStats;
16use crate::{
17 gc::row::scanner::ScanResult,
18 tier::{RangeCursor, TierStorage, commit::buffer::MultiCommitBufferTier},
19};
20
21pub struct ExpiredOperatorState {
22 pub node_id: FlowNodeId,
23 pub key: EncodedKey,
24 pub scanned_bytes: u64,
25}
26
27pub fn scan_operator_by_created_at(
28 storage: &MultiCommitBufferTier,
29 node_id: FlowNodeId,
30 ttl: &Ttl,
31 now_nanos: u64,
32 batch_size: usize,
33 cursor: &mut RangeCursor,
34) -> Result<(Vec<ExpiredOperatorState>, ScanResult)> {
35 let range = FlowNodeStateKey::node_range(node_id);
36 let table = EntryKind::Operator(node_id);
37
38 let start = bound_as_ref(&range.start);
39 let end = bound_as_ref(&range.end);
40
41 let mut expired = Vec::new();
42 let mut batch_cursor = cursor.clone();
43 let batch = storage.range_next(table, &mut batch_cursor, start, end, CommitVersion(u64::MAX), batch_size)?;
44
45 for entry in &batch.entries {
46 if let Some(ref value) = entry.value {
47 let row = EncodedRow(value.clone());
48 let anchor_nanos = row.created_at_nanos();
49 if now_nanos.saturating_sub(anchor_nanos) >= ttl.duration_nanos {
50 expired.push(ExpiredOperatorState {
51 node_id,
52 key: entry.key.clone(),
53 scanned_bytes: value.len() as u64,
54 });
55 }
56 }
57 }
58
59 *cursor = batch_cursor;
60 if !batch.has_more || cursor.exhausted {
61 Ok((expired, ScanResult::Exhausted))
62 } else {
63 Ok((expired, ScanResult::Yielded))
64 }
65}
66
67pub fn scan_operator_by_updated_at(
68 storage: &MultiCommitBufferTier,
69 node_id: FlowNodeId,
70 ttl: &Ttl,
71 now_nanos: u64,
72 batch_size: usize,
73 cursor: &mut RangeCursor,
74) -> Result<(Vec<ExpiredOperatorState>, ScanResult)> {
75 let range = FlowNodeStateKey::node_range(node_id);
76 let table = EntryKind::Operator(node_id);
77
78 let start = bound_as_ref(&range.start);
79 let end = bound_as_ref(&range.end);
80
81 let mut expired = Vec::new();
82 let mut batch_cursor = cursor.clone();
83 let batch = storage.range_next(table, &mut batch_cursor, start, end, CommitVersion(u64::MAX), batch_size)?;
84
85 for entry in &batch.entries {
86 if let Some(ref value) = entry.value {
87 let row = EncodedRow(value.clone());
88 let anchor_nanos = row.updated_at_nanos();
89 if now_nanos.saturating_sub(anchor_nanos) >= ttl.duration_nanos {
90 expired.push(ExpiredOperatorState {
91 node_id,
92 key: entry.key.clone(),
93 scanned_bytes: value.len() as u64,
94 });
95 }
96 }
97 }
98
99 *cursor = batch_cursor;
100 if !batch.has_more || cursor.exhausted {
101 Ok((expired, ScanResult::Exhausted))
102 } else {
103 Ok((expired, ScanResult::Yielded))
104 }
105}
106
107pub(crate) const JOIN_LEFT_PREFIX: u8 = 0x01;
108pub(crate) const JOIN_RIGHT_PREFIX: u8 = 0x02;
109
110pub fn scan_operator_join(
111 storage: &MultiCommitBufferTier,
112 node_id: FlowNodeId,
113 left: Option<&Ttl>,
114 right: Option<&Ttl>,
115 now_nanos: u64,
116 batch_size: usize,
117 cursor: &mut RangeCursor,
118) -> Result<(Vec<ExpiredOperatorState>, ScanResult)> {
119 let range = FlowNodeStateKey::node_range(node_id);
120 let table = EntryKind::Operator(node_id);
121
122 let start = bound_as_ref(&range.start);
123 let end = bound_as_ref(&range.end);
124
125 let mut expired = Vec::new();
126 let mut batch_cursor = cursor.clone();
127 let batch = storage.range_next(table, &mut batch_cursor, start, end, CommitVersion(u64::MAX), batch_size)?;
128
129 for entry in &batch.entries {
130 let Some(ref value) = entry.value else {
131 continue;
132 };
133
134 let side_prefix = FlowNodeStateKey::decode(&entry.key).and_then(|k| k.key.first().copied());
135 let ttl = match side_prefix {
136 Some(JOIN_LEFT_PREFIX) => left,
137 Some(JOIN_RIGHT_PREFIX) => right,
138 _ => None,
139 };
140 let Some(ttl) = ttl else {
141 continue;
142 };
143
144 let row = EncodedRow(value.clone());
145 let anchor_nanos = match ttl.anchor {
146 TtlAnchor::Created => row.created_at_nanos(),
147 TtlAnchor::Updated => row.updated_at_nanos(),
148 };
149 if now_nanos.saturating_sub(anchor_nanos) >= ttl.duration_nanos {
150 expired.push(ExpiredOperatorState {
151 node_id,
152 key: entry.key.clone(),
153 scanned_bytes: value.len() as u64,
154 });
155 }
156 }
157
158 *cursor = batch_cursor;
159 if !batch.has_more || cursor.exhausted {
160 Ok((expired, ScanResult::Exhausted))
161 } else {
162 Ok((expired, ScanResult::Yielded))
163 }
164}
165
166fn bound_as_ref(bound: &Bound<impl AsRef<[u8]>>) -> Bound<&[u8]> {
167 match bound {
168 Bound::Included(v) => Bound::Included(v.as_ref()),
169 Bound::Excluded(v) => Bound::Excluded(v.as_ref()),
170 Bound::Unbounded => Bound::Unbounded,
171 }
172}
173
174pub fn drop_expired_operator_keys(
175 storage: &MultiCommitBufferTier,
176 expired: &[ExpiredOperatorState],
177 stats: &mut OperatorScanStats,
178) -> Result<()> {
179 if expired.is_empty() {
180 return Ok(());
181 }
182
183 let mut drop_batches: HashMap<EntryKind, Vec<(EncodedKey, CommitVersion)>> = HashMap::new();
184
185 for row in expired {
186 let table = EntryKind::Operator(row.node_id);
187 let node_bytes = stats.bytes_reclaimed.entry(row.node_id).or_insert(0);
188 let drop_batch = drop_batches.entry(table).or_default();
189
190 let versions = storage.get_all_versions(table, &row.key)?;
191 for (version, value) in &versions {
192 if let Some(v) = value {
193 *node_bytes += v.len() as u64;
194 }
195 drop_batch.push((row.key.clone(), *version));
196 stats.versions_dropped += 1;
197 }
198 }
199
200 if !drop_batches.is_empty() {
201 storage.drop(drop_batches)?;
202 }
203
204 Ok(())
205}
206
207#[cfg(test)]
208mod tests {
209 use std::collections::HashMap;
210
211 use reifydb_core::{
212 common::CommitVersion,
213 encoded::row::SHAPE_HEADER_SIZE,
214 interface::{catalog::flow::FlowNodeId, store::EntryKind},
215 key::{flow_node_internal_state::FlowNodeInternalStateKey, flow_node_state::FlowNodeStateKey},
216 row::{Ttl, TtlAnchor, TtlCleanupMode},
217 };
218 use reifydb_value::util::cowvec::CowVec;
219
220 use super::*;
221 use crate::tier::{TierStorage, commit::buffer::MultiCommitBufferTier};
222
223 fn row_with_created(payload: &[u8], created_at: u64) -> CowVec<u8> {
224 row_with(payload, created_at, created_at)
225 }
226
227 fn row_with(payload: &[u8], created_at: u64, updated_at: u64) -> CowVec<u8> {
228 let mut buf = vec![0u8; SHAPE_HEADER_SIZE + payload.len()];
229 buf[8..16].copy_from_slice(&created_at.to_le_bytes());
230 buf[16..24].copy_from_slice(&updated_at.to_le_bytes());
231 buf[SHAPE_HEADER_SIZE..].copy_from_slice(payload);
232 CowVec::new(buf)
233 }
234
235 #[test]
236 fn scan_drops_expired_data_state_but_never_internal_state() {
237 let storage = MultiCommitBufferTier::memory();
238 let node = FlowNodeId(1);
239 let table = EntryKind::Operator(node);
240
241 let old_data = FlowNodeStateKey::encoded(node, vec![1u8]);
242 let fresh_data = FlowNodeStateKey::encoded(node, vec![2u8]);
243 let old_internal = FlowNodeInternalStateKey::encoded(node, vec![9u8]);
246
247 storage.set(
248 CommitVersion(1),
249 HashMap::from([(
250 table,
251 vec![
252 (old_data.clone(), Some(row_with_created(b"old", 1))),
253 (fresh_data.clone(), Some(row_with_created(b"new", 10_000))),
254 (old_internal.clone(), Some(row_with_created(b"map", 1))),
255 ],
256 )]),
257 )
258 .unwrap();
259
260 let ttl = Ttl {
261 duration_nanos: 100,
262 anchor: TtlAnchor::Created,
263 cleanup_mode: TtlCleanupMode::Drop,
264 };
265 let now = 1_000;
266
267 let mut cursor = RangeCursor::default();
268 let (expired, _) = scan_operator_by_created_at(&storage, node, &ttl, now, 4096, &mut cursor).unwrap();
269
270 assert_eq!(expired.len(), 1, "only the old data-state row should be expired");
273 assert_eq!(expired[0].key, old_data);
274
275 let mut stats = OperatorScanStats::default();
276 drop_expired_operator_keys(&storage, &expired, &mut stats).unwrap();
277
278 let internal_versions = storage.get_all_versions(table, old_internal.as_ref()).unwrap();
280 assert!(
281 internal_versions.iter().any(|(_, v)| v.is_some()),
282 "internal state must never be reclaimed by the operator GC"
283 );
284
285 let mut cursor = RangeCursor::default();
287 let (expired_after, _) =
288 scan_operator_by_created_at(&storage, node, &ttl, now, 4096, &mut cursor).unwrap();
289 assert!(expired_after.is_empty(), "the expired data-state row should have been dropped");
290 }
291
292 #[test]
293 fn join_scan_evicts_per_side_and_never_touches_schema_rows() {
294 let storage = MultiCommitBufferTier::memory();
295 let node = FlowNodeId(2);
296 let table = EntryKind::Operator(node);
297
298 let left_old = FlowNodeStateKey::encoded(node, vec![JOIN_LEFT_PREFIX, 1]);
301 let left_fresh = FlowNodeStateKey::encoded(node, vec![JOIN_LEFT_PREFIX, 2]);
302 let right_old = FlowNodeStateKey::encoded(node, vec![JOIN_RIGHT_PREFIX, 1]);
303 let right_fresh = FlowNodeStateKey::encoded(node, vec![JOIN_RIGHT_PREFIX, 2]);
304 let left_schema = FlowNodeStateKey::encoded(node, vec![0x03u8]);
305 let right_schema = FlowNodeStateKey::encoded(node, vec![0x04u8]);
306
307 storage.set(
308 CommitVersion(1),
309 HashMap::from([(
310 table,
311 vec![
312 (left_old.clone(), Some(row_with_created(b"lo", 1))),
313 (left_fresh.clone(), Some(row_with_created(b"lf", 10_000))),
314 (right_old.clone(), Some(row_with_created(b"ro", 1))),
315 (right_fresh.clone(), Some(row_with_created(b"rf", 10_000))),
316 (left_schema.clone(), Some(row_with_created(b"ls", 1))),
317 (right_schema.clone(), Some(row_with_created(b"rs", 1))),
318 ],
319 )]),
320 )
321 .unwrap();
322
323 let ttl = Ttl {
324 duration_nanos: 100,
325 anchor: TtlAnchor::Updated,
326 cleanup_mode: TtlCleanupMode::Drop,
327 };
328 let now = 1_000;
329
330 let mut cursor = RangeCursor::default();
332 let (expired, _) =
333 scan_operator_join(&storage, node, Some(&ttl), Some(&ttl), now, 4096, &mut cursor).unwrap();
334 let keys: Vec<&EncodedKey> = expired.iter().map(|e| &e.key).collect();
335 assert_eq!(expired.len(), 2, "exactly the two old side rows expire");
336 assert!(keys.contains(&&left_old) && keys.contains(&&right_old));
337 assert!(!keys.contains(&&left_fresh) && !keys.contains(&&right_fresh), "fresh rows survive");
338 assert!(
339 !keys.contains(&&left_schema) && !keys.contains(&&right_schema),
340 "schema rows are never scanned"
341 );
342
343 let mut cursor = RangeCursor::default();
345 let (expired_left_only, _) =
346 scan_operator_join(&storage, node, Some(&ttl), None, now, 4096, &mut cursor).unwrap();
347 assert_eq!(expired_left_only.len(), 1);
348 assert_eq!(expired_left_only[0].key, left_old);
349 }
350
351 #[test]
352 fn join_scan_respects_the_configured_anchor() {
353 let storage = MultiCommitBufferTier::memory();
356 let node = FlowNodeId(3);
357 let table = EntryKind::Operator(node);
358 let left = FlowNodeStateKey::encoded(node, vec![JOIN_LEFT_PREFIX, 1]);
359 storage.set(
360 CommitVersion(1),
361 HashMap::from([(table, vec![(left.clone(), Some(row_with(b"l", 1, 10_000)))])]),
362 )
363 .unwrap();
364 let now = 1_000;
365 let created = Ttl {
366 duration_nanos: 100,
367 anchor: TtlAnchor::Created,
368 cleanup_mode: TtlCleanupMode::Drop,
369 };
370 let updated = Ttl {
371 anchor: TtlAnchor::Updated,
372 ..created.clone()
373 };
374
375 let mut cursor = RangeCursor::default();
376 let (created_expired, _) =
377 scan_operator_join(&storage, node, Some(&created), None, now, 4096, &mut cursor).unwrap();
378 assert_eq!(created_expired.len(), 1, "Created anchor: an old created_at must expire");
379
380 let mut cursor = RangeCursor::default();
381 let (updated_expired, _) =
382 scan_operator_join(&storage, node, Some(&updated), None, now, 4096, &mut cursor).unwrap();
383 assert!(updated_expired.is_empty(), "Updated anchor: a fresh updated_at must survive");
384 }
385}