Skip to main content

reifydb_store_multi/gc/operator/
scanner.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{collections::HashMap, ops::Bound};
5
6use reifydb_codec::key::encoded::EncodedKey;
7use reifydb_core::{
8	common::CommitVersion,
9	interface::{catalog::flow::FlowNodeId, store::EntryKind},
10	key::{EncodableKey, flow_node_state::FlowNodeStateKey},
11};
12use reifydb_value::Result;
13
14use super::OperatorScanStats;
15use crate::{
16	MultiVersionScope,
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_expired(
28	storage: &MultiCommitBufferTier,
29	node_id: FlowNodeId,
30	cutoff_version: CommitVersion,
31	batch_size: usize,
32	cursor: &mut RangeCursor,
33) -> Result<(Vec<ExpiredOperatorState>, ScanResult)> {
34	let range = FlowNodeStateKey::node_range(node_id);
35	let table = EntryKind::Operator(node_id);
36
37	let start = bound_as_ref(&range.start);
38	let end = bound_as_ref(&range.end);
39
40	let mut expired = Vec::new();
41	let mut batch_cursor = cursor.clone();
42	let scope = MultiVersionScope::AsOf {
43		read: CommitVersion(u64::MAX),
44	};
45	let batch = storage.range_next(table, &mut batch_cursor, start, end, scope, batch_size)?;
46
47	for entry in &batch.entries {
48		if let Some(ref value) = entry.value
49			&& entry.version <= cutoff_version
50		{
51			expired.push(ExpiredOperatorState {
52				node_id,
53				key: entry.key.clone(),
54				scanned_bytes: value.len() as u64,
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(crate) const JOIN_LEFT_PREFIX: u8 = 0x01;
68pub(crate) const JOIN_RIGHT_PREFIX: u8 = 0x02;
69
70pub fn scan_operator_join(
71	storage: &MultiCommitBufferTier,
72	node_id: FlowNodeId,
73	left_cutoff: Option<CommitVersion>,
74	right_cutoff: Option<CommitVersion>,
75	batch_size: usize,
76	cursor: &mut RangeCursor,
77) -> Result<(Vec<ExpiredOperatorState>, ScanResult)> {
78	let range = FlowNodeStateKey::node_range(node_id);
79	let table = EntryKind::Operator(node_id);
80
81	let start = bound_as_ref(&range.start);
82	let end = bound_as_ref(&range.end);
83
84	let mut expired = Vec::new();
85	let mut batch_cursor = cursor.clone();
86	let batch = storage.range_next(
87		table,
88		&mut batch_cursor,
89		start,
90		end,
91		MultiVersionScope::AsOf {
92			read: CommitVersion(u64::MAX),
93		},
94		batch_size,
95	)?;
96
97	for entry in &batch.entries {
98		let Some(ref value) = entry.value else {
99			continue;
100		};
101
102		let side_prefix = FlowNodeStateKey::decode(&entry.key).and_then(|k| k.key.first().copied());
103		let cutoff = match side_prefix {
104			Some(JOIN_LEFT_PREFIX) => left_cutoff,
105			Some(JOIN_RIGHT_PREFIX) => right_cutoff,
106			_ => None,
107		};
108		let Some(cutoff) = cutoff else {
109			continue;
110		};
111
112		if entry.version <= cutoff {
113			expired.push(ExpiredOperatorState {
114				node_id,
115				key: entry.key.clone(),
116				scanned_bytes: value.len() as u64,
117			});
118		}
119	}
120
121	*cursor = batch_cursor;
122	if !batch.has_more || cursor.exhausted {
123		Ok((expired, ScanResult::Exhausted))
124	} else {
125		Ok((expired, ScanResult::Yielded))
126	}
127}
128
129fn bound_as_ref(bound: &Bound<impl AsRef<[u8]>>) -> Bound<&[u8]> {
130	match bound {
131		Bound::Included(v) => Bound::Included(v.as_ref()),
132		Bound::Excluded(v) => Bound::Excluded(v.as_ref()),
133		Bound::Unbounded => Bound::Unbounded,
134	}
135}
136
137pub fn drop_expired_operator_keys(
138	storage: &MultiCommitBufferTier,
139	expired: &[ExpiredOperatorState],
140	stats: &mut OperatorScanStats,
141) -> Result<()> {
142	if expired.is_empty() {
143		return Ok(());
144	}
145
146	let mut drop_batches: HashMap<EntryKind, Vec<(EncodedKey, CommitVersion)>> = HashMap::new();
147
148	for row in expired {
149		let table = EntryKind::Operator(row.node_id);
150		let node_bytes = stats.bytes_reclaimed.entry(row.node_id).or_insert(0);
151		let drop_batch = drop_batches.entry(table).or_default();
152
153		let versions = storage.get_all_versions(table, &row.key)?;
154		for (version, value) in &versions {
155			if let Some(v) = value {
156				*node_bytes += v.len() as u64;
157			}
158			drop_batch.push((row.key.clone(), *version));
159			stats.versions_dropped += 1;
160		}
161	}
162
163	if !drop_batches.is_empty() {
164		storage.drop(drop_batches)?;
165	}
166
167	Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172	use std::collections::HashMap;
173
174	use reifydb_codec::encoded::row::SHAPE_HEADER_SIZE;
175	use reifydb_core::{
176		common::CommitVersion,
177		interface::{catalog::flow::FlowNodeId, store::EntryKind},
178		key::{flow_node_internal_state::FlowNodeInternalStateKey, flow_node_state::FlowNodeStateKey},
179	};
180	use reifydb_value::util::cowvec::CowVec;
181
182	use super::*;
183	use crate::tier::{TierStorage, commit::buffer::MultiCommitBufferTier};
184
185	fn row(payload: &[u8]) -> CowVec<u8> {
186		let mut buf = vec![0u8; SHAPE_HEADER_SIZE + payload.len()];
187		buf[SHAPE_HEADER_SIZE..].copy_from_slice(payload);
188		CowVec::new(buf)
189	}
190
191	#[test]
192	fn scan_drops_expired_data_state_but_never_internal_state() {
193		let storage = MultiCommitBufferTier::memory();
194		let node = FlowNodeId(1);
195		let table = EntryKind::Operator(node);
196
197		let old_data = FlowNodeStateKey::encoded(node, vec![1u8]);
198		let fresh_data = FlowNodeStateKey::encoded(node, vec![2u8]);
199		// An internal-state row (e.g. a row-number mapping). It must stay immune even though it is
200		// older than the cutoff, because operator GC only scans the data-state range.
201		let old_internal = FlowNodeInternalStateKey::encoded(node, vec![9u8]);
202
203		// Old data + internal at v1; the fresh data row at v3.
204		storage.set(
205			CommitVersion(1),
206			HashMap::from([(
207				table,
208				vec![(old_data.clone(), Some(row(b"old"))), (old_internal.clone(), Some(row(b"map")))],
209			)]),
210		)
211		.unwrap();
212		storage.set(CommitVersion(3), HashMap::from([(table, vec![(fresh_data.clone(), Some(row(b"new")))])]))
213			.unwrap();
214
215		// Cutoff sits between the two writes: the v1 data row is expired, the v3 data row survives.
216		let cutoff = CommitVersion(2);
217		let mut cursor = RangeCursor::default();
218		let (expired, _) = scan_operator_expired(&storage, node, cutoff, 4096, &mut cursor).unwrap();
219
220		assert_eq!(expired.len(), 1, "only the data row written at or below the cutoff version should expire");
221		assert_eq!(expired[0].key, old_data);
222
223		let mut stats = OperatorScanStats::default();
224		drop_expired_operator_keys(&storage, &expired, &mut stats).unwrap();
225
226		// The internal-state row survives the drop - immune to operator GC.
227		let internal_versions = storage.get_all_versions(table, old_internal.as_ref()).unwrap();
228		assert!(
229			internal_versions.iter().any(|(_, v)| v.is_some()),
230			"internal state must never be reclaimed by the operator GC"
231		);
232
233		// Re-scanning finds nothing: the expired data row is gone, the fresh one stays.
234		let mut cursor = RangeCursor::default();
235		let (expired_after, _) = scan_operator_expired(&storage, node, cutoff, 4096, &mut cursor).unwrap();
236		assert!(expired_after.is_empty(), "the expired data-state row should have been dropped");
237	}
238
239	#[test]
240	fn join_scan_evicts_per_side_and_never_touches_schema_rows() {
241		let storage = MultiCommitBufferTier::memory();
242		let node = FlowNodeId(2);
243		let table = EntryKind::Operator(node);
244
245		// Left side (0x01) and right side (0x02) each with an old and a fresh row, plus the
246		// per-side schema rows (0x03 left, 0x04 right) which carry no TTL and must survive.
247		let left_old = FlowNodeStateKey::encoded(node, vec![JOIN_LEFT_PREFIX, 1]);
248		let left_fresh = FlowNodeStateKey::encoded(node, vec![JOIN_LEFT_PREFIX, 2]);
249		let right_old = FlowNodeStateKey::encoded(node, vec![JOIN_RIGHT_PREFIX, 1]);
250		let right_fresh = FlowNodeStateKey::encoded(node, vec![JOIN_RIGHT_PREFIX, 2]);
251		let left_schema = FlowNodeStateKey::encoded(node, vec![0x03u8]);
252		let right_schema = FlowNodeStateKey::encoded(node, vec![0x04u8]);
253
254		// Old rows + schema rows at v1; fresh rows at v3.
255		storage.set(
256			CommitVersion(1),
257			HashMap::from([(
258				table,
259				vec![
260					(left_old.clone(), Some(row(b"lo"))),
261					(right_old.clone(), Some(row(b"ro"))),
262					(left_schema.clone(), Some(row(b"ls"))),
263					(right_schema.clone(), Some(row(b"rs"))),
264				],
265			)]),
266		)
267		.unwrap();
268		storage.set(
269			CommitVersion(3),
270			HashMap::from([(
271				table,
272				vec![(left_fresh.clone(), Some(row(b"lf"))), (right_fresh.clone(), Some(row(b"rf")))],
273			)]),
274		)
275		.unwrap();
276
277		// Both sides cut off at v2: each side's v1 row expires; v3 rows and schema rows survive.
278		let cutoff = CommitVersion(2);
279		let mut cursor = RangeCursor::default();
280		let (expired, _) =
281			scan_operator_join(&storage, node, Some(cutoff), Some(cutoff), 4096, &mut cursor).unwrap();
282		let keys: Vec<&EncodedKey> = expired.iter().map(|e| &e.key).collect();
283		assert_eq!(expired.len(), 2, "exactly the two old side rows expire");
284		assert!(keys.contains(&&left_old) && keys.contains(&&right_old));
285		assert!(!keys.contains(&&left_fresh) && !keys.contains(&&right_fresh), "fresh rows survive");
286		assert!(
287			!keys.contains(&&left_schema) && !keys.contains(&&right_schema),
288			"schema rows are never scanned"
289		);
290
291		// Asymmetric: only the left side has a cutoff -> only the old left row is eligible.
292		let mut cursor = RangeCursor::default();
293		let (expired_left_only, _) =
294			scan_operator_join(&storage, node, Some(cutoff), None, 4096, &mut cursor).unwrap();
295		assert_eq!(expired_left_only.len(), 1);
296		assert_eq!(expired_left_only[0].key, left_old);
297	}
298
299	#[test]
300	fn join_scan_applies_independent_per_side_cutoffs() {
301		// The two join sides expire on independent cutoff versions: a same-aged row is evicted on
302		// the side whose cutoff reaches its version and kept on the side whose cutoff is below it.
303		let storage = MultiCommitBufferTier::memory();
304		let node = FlowNodeId(3);
305		let table = EntryKind::Operator(node);
306		let left = FlowNodeStateKey::encoded(node, vec![JOIN_LEFT_PREFIX, 1]);
307		let right = FlowNodeStateKey::encoded(node, vec![JOIN_RIGHT_PREFIX, 1]);
308		storage.set(
309			CommitVersion(5),
310			HashMap::from([(
311				table,
312				vec![(left.clone(), Some(row(b"l"))), (right.clone(), Some(row(b"r")))],
313			)]),
314		)
315		.unwrap();
316
317		// Left cutoff (10) reaches the rows' version -> left expires; right cutoff (3) is below -> right
318		// survives.
319		let mut cursor = RangeCursor::default();
320		let (expired, _) = scan_operator_join(
321			&storage,
322			node,
323			Some(CommitVersion(10)),
324			Some(CommitVersion(3)),
325			4096,
326			&mut cursor,
327		)
328		.unwrap();
329		assert_eq!(expired.len(), 1, "only the side whose cutoff reaches the row's version expires");
330		assert_eq!(expired[0].key, left);
331	}
332}