Skip to main content

reifydb_flow/operator/state/reaper/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{collections::HashMap, ops::Bound};
5
6use reifydb_codec::row::pod::EncodedPodRow;
7use reifydb_core::{
8	key::{
9		operator::{
10			keyspace::expiry::{ReapQueue, ReapQueueKey},
11			state::{GroupId, GroupStateKey, OperatorStateKey},
12		},
13		typed::direction::Desc,
14	},
15	state::{
16		timer::{StateStore, sweep_order},
17		typed::{TypedStateStore, typed_key},
18	},
19};
20use reifydb_value::{Result, reifydb_assertions};
21
22use crate::operator::state::reclaim::ReclaimOutcome;
23
24#[cfg(test)]
25mod tests;
26
27pub trait Reaper {
28	fn reap(&mut self, store: &mut dyn StateStore, key: &GroupStateKey) -> Result<()>;
29}
30
31pub trait IdentityReclaim: StateStore {
32	fn reclaim_identity(&mut self, group: GroupId, limit: usize) -> Result<ReclaimOutcome>;
33
34	fn reclaim_identity_keys(&mut self, group: GroupId, keys: &[GroupStateKey]) -> Result<ReclaimOutcome>;
35}
36
37pub struct StoreReaper;
38
39impl Reaper for StoreReaper {
40	fn reap(&mut self, store: &mut dyn StateStore, key: &GroupStateKey) -> Result<()> {
41		store.state_remove(key)
42	}
43}
44
45pub fn queue_key(group: GroupId) -> GroupStateKey {
46	typed_key::<ReapQueue>(
47		GroupId::ROOT,
48		&ReapQueueKey {
49			group: Desc(group),
50		},
51	)
52}
53
54pub fn enqueue(store: &mut dyn StateStore, group: GroupId) -> Result<()> {
55	store.state_set(&queue_key(group), EncodedPodRow::new(&[]))
56}
57
58pub struct Queued {
59	pub groups: Vec<GroupId>,
60	pub more: bool,
61}
62
63pub struct DrainOutcome {
64	pub freed: usize,
65	pub still_queued: Vec<GroupId>,
66	pub more: bool,
67}
68
69impl DrainOutcome {
70	pub fn queue_is_empty(&self) -> bool {
71		self.still_queued.is_empty() && !self.more
72	}
73}
74
75pub fn queued(store: &mut dyn StateStore, limit: usize) -> Result<Queued> {
76	let page = store.state_scan_in::<ReapQueue>(GroupId::ROOT, Bound::Unbounded, Some(limit.saturating_add(1)))?;
77	let more = page.len() > limit;
78	let groups = page.into_iter().take(limit).map(|(suffix, _)| suffix.group.0).collect();
79	Ok(Queued {
80		groups,
81		more,
82	})
83}
84
85pub struct GroupDrain {
86	pub freed: usize,
87	pub still_queued: bool,
88}
89
90#[derive(Default)]
91struct GroupScan {
92	identity: Vec<GroupStateKey>,
93	data: Vec<(GroupStateKey, EncodedPodRow)>,
94}
95
96fn bucket(rows: Vec<(GroupStateKey, EncodedPodRow)>) -> (HashMap<GroupId, GroupScan>, Option<GroupId>) {
97	let mut buckets: HashMap<GroupId, GroupScan> = HashMap::new();
98	let mut last = None;
99	for (key, row) in rows {
100		let Some((group, keyspace, _)) = OperatorStateKey::decode_inner(key.as_encoded().as_bytes()) else {
101			continue;
102		};
103		last = Some(group);
104		let bucket = buckets.entry(group).or_default();
105		match keyspace.is_data() {
106			true => bucket.data.push((key, row)),
107			false => bucket.identity.push(key),
108		}
109	}
110	(buckets, last)
111}
112
113fn scan_group(store: &mut dyn StateStore, group: GroupId, budget: usize) -> Result<Option<GroupScan>> {
114	let mut identity = Vec::new();
115	let mut data = Vec::new();
116	let swept = store.group_sweep(group, false, Some(budget.saturating_add(1)))?;
117	if swept.len() > budget {
118		return Ok(None);
119	}
120	for (key, row) in swept {
121		match OperatorStateKey::decode_inner(key.as_encoded().as_bytes()) {
122			Some((_, keyspace, _)) if keyspace.is_data() => data.push((key, row)),
123			Some(_) => identity.push(key),
124			None => {}
125		}
126	}
127	Ok(Some(GroupScan {
128		identity,
129		data,
130	}))
131}
132
133pub fn drain_group<R>(
134	store: &mut dyn IdentityReclaim,
135	group: GroupId,
136	reaper: &mut R,
137	budget: usize,
138) -> Result<GroupDrain>
139where
140	R: Reaper,
141{
142	let Some(scan) = scan_group(store, group, budget)? else {
143		return drain_group_scanning(store, group, reaper, budget);
144	};
145	Ok(GroupDrain {
146		freed: reap_scanned(store, group, scan, reaper)?,
147		still_queued: false,
148	})
149}
150
151fn reap_scanned<R>(store: &mut dyn IdentityReclaim, group: GroupId, scan: GroupScan, reaper: &mut R) -> Result<usize>
152where
153	R: Reaper,
154{
155	store.remove_root_siblings(&scan.data)?;
156	for (key, _) in &scan.data {
157		reaper.reap(store, key)?;
158	}
159	reifydb_assertions! {
160		let leftover = store.group_sweep(group, true, None)?.len();
161		assert!(
162			leftover == 0,
163			"group {} still holds {leftover} data rows in its own partition; forgetting its dictionary \
164			 entry now would orphan them behind a group id nothing can resolve again",
165			group
166		);
167	}
168	let freed = scan.data.len();
169	let outcome = store.reclaim_identity_keys(group, &scan.identity)?;
170	store.state_remove(&queue_key(group))?;
171	Ok(freed + outcome.removed.as_u64() as usize)
172}
173
174fn drain_group_scanning<R>(
175	store: &mut dyn IdentityReclaim,
176	group: GroupId,
177	reaper: &mut R,
178	budget: usize,
179) -> Result<GroupDrain>
180where
181	R: Reaper,
182{
183	let freed = reap_group(store, group, reaper, budget)?;
184	if freed >= budget {
185		return Ok(GroupDrain {
186			freed,
187			still_queued: true,
188		});
189	}
190	let outcome = store.reclaim_identity(group, budget - freed)?;
191	let freed = freed + outcome.removed.as_u64() as usize;
192	if outcome.more {
193		return Ok(GroupDrain {
194			freed,
195			still_queued: true,
196		});
197	}
198	store.state_remove(&queue_key(group))?;
199	Ok(GroupDrain {
200		freed,
201		still_queued: false,
202	})
203}
204
205pub fn drain<R>(store: &mut dyn IdentityReclaim, reaper: &mut R, budget: usize) -> Result<DrainOutcome>
206where
207	R: Reaper,
208{
209	let scan = queued(store, budget)?;
210	let ordered = sweep_order(&scan.groups);
211	let sweep = store.group_sweep_many(&ordered, budget)?;
212	let (mut buckets, last) = bucket(sweep.rows);
213	let cut = match sweep.complete {
214		true => None,
215		false => last,
216	};
217
218	let mut spent = 0usize;
219	let mut still_queued: Vec<GroupId> = Vec::new();
220	let mut pending = ordered.into_iter();
221	while let Some(group) = pending.next() {
222		if cut == Some(group) {
223			match spent {
224				0 => {
225					let outcome = drain_group_scanning(store, group, reaper, budget)?;
226					spent += outcome.freed;
227					if outcome.still_queued {
228						still_queued.push(group);
229					}
230				}
231				_ => still_queued.push(group),
232			}
233			still_queued.extend(pending);
234			break;
235		}
236		spent += reap_scanned(store, group, buckets.remove(&group).unwrap_or_default(), reaper)?;
237	}
238	Ok(DrainOutcome {
239		freed: spent,
240		still_queued,
241		more: scan.more,
242	})
243}
244
245pub fn reap_group<R>(store: &mut dyn StateStore, group: GroupId, reaper: &mut R, budget: usize) -> Result<usize>
246where
247	R: Reaper,
248{
249	let doomed = store.group_sweep(group, true, Some(budget))?;
250	store.remove_root_siblings(&doomed)?;
251	for (key, _) in &doomed {
252		reaper.reap(store, key)?;
253	}
254	Ok(doomed.len())
255}