Skip to main content

reifydb_store_multi/gc/operator/
scanner.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (c) 2026 ReifyDB
3
4use 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		// An OLD internal-state row (e.g. a row-number mapping). It must stay immune even though
244		// its anchor is well past the TTL, because operator GC only scans the data-state range.
245		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		// Exactly the old data-state row is expired: the fresh data row is within TTL, and the
271		// old internal-state row is outside the data-state scan range entirely.
272		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		// The internal-state row survives the drop - immune to operator GC.
279		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		// Re-scanning finds nothing: the expired data row is gone, the fresh one stays.
286		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		// Left side (0x01) and right side (0x02) each with an old and a fresh row, plus the
299		// per-side schema rows (0x03 left, 0x04 right) which carry no TTL and must survive.
300		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		// Both sides configured: each side's old row expires; fresh rows and schema rows survive.
331		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		// Asymmetric: only the left side has a TTL -> only the old left row is eligible.
344		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		// A row created long ago but updated recently must be evicted under a Created anchor and
354		// kept under an Updated anchor - the scan must honor the per-side anchor, not force one.
355		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}