1use 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 let old_internal = FlowNodeInternalStateKey::encoded(node, vec![9u8]);
202
203 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 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 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 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 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 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 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 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 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 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}