Skip to main content

reifydb_store_multi/flush/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
5use std::collections::{HashMap, HashSet};
6use std::sync::{Arc, OnceLock};
7
8#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
9use reifydb_codec::key::encoded::EncodedKey;
10#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
11use reifydb_core::event::metric::{MultiEviction, MultiPersist, MultiSweptEvent};
12#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
13use reifydb_core::interface::catalog::storage::StorageId;
14#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
15use reifydb_core::lifecycle::progress::Progress;
16#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
17use reifydb_core::{common::CommitVersion, interface::store::EntryKind};
18use reifydb_core::{event::EventBus, lifecycle::watermark::EvictionWatermark};
19use reifydb_runtime::{
20	context::clock::Clock,
21	sync::{mutex::Mutex, rwlock::RwLock},
22};
23#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
24use reifydb_value::byte_size::ByteSize;
25#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
26use reifydb_value::{reifydb_assertions, util::cowvec::CowVec};
27#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
28use tracing::{debug, error, warn};
29
30#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
31use crate::tier::TierBatch;
32#[cfg(all(test, feature = "sqlite", not(target_arch = "wasm32")))]
33use crate::tier::TierStorage;
34#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
35use crate::tier::commit::memory::storage::EvictedVersion;
36use crate::{
37	flush::ObjectPersistence,
38	tier::{commit::buffer::MultiCommitBufferTier, persistent::MultiPersistentTier, read::MultiReadBufferTier},
39};
40
41#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
42pub const FLUSH_KEY_BUDGET: usize = 2048;
43
44#[derive(Default)]
45pub struct FlushEngineState {
46	#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
47	resume_from: Option<EntryKind>,
48}
49
50#[allow(dead_code)]
51pub struct FlushEngine {
52	commit: MultiCommitBufferTier,
53	persistent: MultiPersistentTier,
54	persistence: Arc<OnceLock<Arc<dyn ObjectPersistence>>>,
55	eviction_watermark: Arc<RwLock<Option<Arc<dyn EvictionWatermark>>>>,
56	read: Option<MultiReadBufferTier>,
57	clock: Clock,
58	event_bus: EventBus,
59	sweep_lock: Mutex<FlushEngineState>,
60}
61
62#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
63type EvictablePersist = Vec<(EncodedKey, CommitVersion, Option<CowVec<u8>>)>;
64#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
65type EvictableDrop = Vec<EvictedVersion>;
66#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
67type EvictablePartition = (EvictablePersist, EvictableDrop);
68
69#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
70pub struct SweepOutcome {
71	pub progress: Progress,
72	pub reclaimed: u64,
73	pub backlog: u64,
74}
75
76#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
77impl FlushEngine {
78	#[allow(clippy::too_many_arguments)]
79	pub fn new(
80		commit: MultiCommitBufferTier,
81		persistent: MultiPersistentTier,
82		persistence: Arc<OnceLock<Arc<dyn ObjectPersistence>>>,
83		eviction_watermark: Arc<RwLock<Option<Arc<dyn EvictionWatermark>>>>,
84		read: Option<MultiReadBufferTier>,
85		clock: Clock,
86		event_bus: EventBus,
87	) -> Self {
88		Self {
89			commit,
90			persistent,
91			persistence,
92			eviction_watermark,
93			read,
94			clock,
95			event_bus,
96			sweep_lock: Mutex::new(FlushEngineState::default()),
97		}
98	}
99
100	pub fn sweep_slice(&self, budget: usize) -> SweepOutcome {
101		let mut state = self.sweep_lock.lock();
102		let (progress, reclaimed) = match self.eviction_cutoff() {
103			Some(cutoff) => self.sweep_once(&mut state, cutoff, budget),
104			None => (Progress::Exhausted, 0),
105		};
106		SweepOutcome {
107			progress,
108			reclaimed,
109			backlog: self.buffered_entries(),
110		}
111	}
112
113	fn buffered_entries(&self) -> u64 {
114		self.commit
115			.list_all_entry_kinds()
116			.map(|kinds| kinds.iter().map(|kind| self.commit.count_current(*kind).unwrap_or(0)).sum())
117			.unwrap_or(0)
118	}
119
120	pub fn flush_pending(&self) {
121		let mut guard = self.sweep_lock.lock();
122		if let Some(cutoff) = self.eviction_cutoff() {
123			while self.sweep_once(&mut guard, cutoff, FLUSH_KEY_BUDGET).0.is_yielded() {}
124		}
125	}
126
127	pub fn flush_all(&self) {
128		let mut guard = self.sweep_lock.lock();
129		while self.sweep_once(&mut guard, CommitVersion(u64::MAX), FLUSH_KEY_BUDGET).0.is_yielded() {}
130	}
131
132	fn eviction_cutoff(&self) -> Option<CommitVersion> {
133		let cutoff = self.eviction_watermark.read().as_ref()?.watermark();
134		if cutoff.0 == 0 {
135			return None;
136		}
137		Some(cutoff)
138	}
139
140	fn is_persistent_object(&self, kind: EntryKind) -> bool {
141		match kind {
142			EntryKind::Source(storage) => {
143				self.persistence.get().map(|provider| provider.is_persistent(storage)).unwrap_or(true)
144			}
145			EntryKind::PartitionedSource(object) => {
146				let Some(storage) = StorageId::from_object(object) else {
147					return true;
148				};
149				self.persistence.get().map(|provider| provider.is_persistent(storage)).unwrap_or(true)
150			}
151			_ => true,
152		}
153	}
154
155	#[cfg(test)]
156	fn sweep(&self, cutoff: CommitVersion) {
157		let mut guard = self.sweep_lock.lock();
158		while self.sweep_once(&mut guard, cutoff, FLUSH_KEY_BUDGET).0.is_yielded() {}
159	}
160
161	fn sweep_once(&self, state: &mut FlushEngineState, cutoff: CommitVersion, budget: usize) -> (Progress, u64) {
162		let Some(mut entry_kinds) = self.list_evictable_kinds() else {
163			return (Progress::Exhausted, 0);
164		};
165		if let Some(resume) = state.resume_from
166			&& let Some(position) = entry_kinds.iter().position(|kind| *kind == resume)
167		{
168			entry_kinds.rotate_left(position);
169		}
170		state.resume_from = None;
171
172		let mut remaining = budget;
173		let mut more = false;
174		let mut plan: Vec<(EntryKind, bool, EvictablePartition)> = Vec::new();
175		let mut batches: HashMap<CommitVersion, TierBatch> = HashMap::new();
176		for kind in entry_kinds {
177			if remaining == 0 {
178				more = true;
179				state.resume_from = Some(kind);
180				break;
181			}
182			let (to_persist, to_drop, kind_more) = self.collect_evictable(kind, cutoff, remaining);
183			if to_persist.is_empty() && to_drop.is_empty() {
184				continue;
185			}
186			remaining = remaining.saturating_sub(to_persist.len());
187			more |= kind_more;
188			let persistent_object = self.is_persistent_object(kind);
189			if persistent_object {
190				for (key, version, value) in &to_persist {
191					batches.entry(*version)
192						.or_default()
193						.entry(kind)
194						.or_default()
195						.push((key.clone(), value.clone()));
196				}
197			}
198			plan.push((kind, persistent_object, (to_persist, to_drop)));
199		}
200		if plan.is_empty() {
201			return (Progress::Exhausted, 0);
202		}
203
204		let accepted = if batches.values().any(|batch| !batch.is_empty()) {
205			match self.persistent.persist_sweep(batches.into_iter().collect()) {
206				Ok(accepted) => accepted,
207				Err(e) => {
208					error!(error = %e, "flush sweep: persist failed, aborting slice");
209					return (Progress::Exhausted, 0);
210				}
211			}
212		} else {
213			Vec::new()
214		};
215		let persisted = accepted.len();
216
217		let accepted_keys: HashSet<&[u8]> = accepted.iter().map(|k| k.as_slice()).collect();
218		let mut evictions: Vec<MultiEviction> = Vec::new();
219		let mut persists: Vec<MultiPersist> = Vec::new();
220
221		let mut dropped = 0usize;
222		for (kind, persistent_object, (to_persist, to_drop)) in plan {
223			self.refresh_read_tier(persistent_object, &to_persist, &to_drop, &accepted);
224			if persistent_object {
225				for (key, _, value) in &to_persist {
226					if accepted_keys.contains(key.as_slice()) {
227						persists.push(MultiPersist {
228							key: key.clone(),
229							value_bytes: ByteSize::from_bytes(
230								value.as_ref().map(|v| v.len() as u64).unwrap_or(0),
231							),
232						});
233					}
234				}
235			}
236			for evicted in &to_drop {
237				evictions.push(MultiEviction {
238					key: evicted.key.clone(),
239					value_bytes: evicted.value_bytes,
240					current: evicted.current,
241				});
242			}
243			if let Some(count) = self.drop_from_commit(kind, to_drop) {
244				dropped += count;
245			}
246		}
247
248		if !evictions.is_empty() || !persists.is_empty() {
249			self.event_bus.emit(MultiSweptEvent::new(evictions, persists, cutoff));
250		}
251
252		if persisted > 0 || dropped > 0 {
253			debug!(cutoff = cutoff.0, persisted, dropped, more, "flush sweep slice completed");
254		}
255
256		let progress = if more {
257			Progress::Yielded
258		} else {
259			Progress::Exhausted
260		};
261		(progress, dropped as u64)
262	}
263
264	#[inline]
265	fn list_evictable_kinds(&self) -> Option<Vec<EntryKind>> {
266		match self.commit.list_entry_kinds_by_oldest_pending() {
267			Ok(v) => Some(v),
268			Err(e) => {
269				warn!(error = %e, "flush sweep: list_entry_kinds_by_oldest_pending failed");
270				None
271			}
272		}
273	}
274
275	#[inline]
276	fn collect_evictable(
277		&self,
278		kind: EntryKind,
279		cutoff: CommitVersion,
280		budget: usize,
281	) -> (EvictablePersist, EvictableDrop, bool) {
282		match &self.commit {
283			MultiCommitBufferTier::Memory(s) => s.collect_evictable_below(kind, cutoff, budget),
284		}
285	}
286
287	#[inline]
288	fn refresh_read_tier(
289		&self,
290		persistent_object: bool,
291		to_persist: &[(EncodedKey, CommitVersion, Option<CowVec<u8>>)],
292		to_drop: &[EvictedVersion],
293		accepted: &[EncodedKey],
294	) {
295		let Some(read) = &self.read else {
296			return;
297		};
298		if persistent_object {
299			let accepted: HashSet<&[u8]> = accepted.iter().map(|k| k.as_slice()).collect();
300			for (key, version, value) in to_persist {
301				if accepted.contains(key.as_slice()) {
302					read.insert(key.clone(), *version, value.clone());
303				} else {
304					read.invalidate(key);
305				}
306			}
307		} else {
308			for evicted in to_drop {
309				read.invalidate(&evicted.key);
310			}
311		}
312	}
313
314	#[inline]
315	fn drop_from_commit(&self, kind: EntryKind, to_drop: EvictableDrop) -> Option<usize> {
316		let drop_count = to_drop.len();
317		reifydb_assertions! {
318			assert!(
319				drop_count > 0,
320				"sweep must only reach drop_from_commit with a non-empty drop set; an empty drop \
321				 issues a no-op commit-buffer drop and lets the dropped counter run for zero work \
322				 (kind={kind:?})"
323			);
324		}
325		let mut batches: HashMap<EntryKind, Vec<(EncodedKey, CommitVersion)>> = HashMap::new();
326		batches.insert(kind, to_drop.into_iter().map(|e| (e.key, e.version)).collect());
327		if let Err(e) = self.commit.compact(batches) {
328			warn!(?kind, error = %e, "flush sweep: commit buffer drop failed");
329			return None;
330		}
331		Some(drop_count)
332	}
333}
334
335#[cfg(all(test, feature = "sqlite", not(target_arch = "wasm32")))]
336mod tests {
337	use reifydb_core::{
338		event::EventListener,
339		interface::catalog::{id::TableId, storage::StorageId},
340	};
341	use reifydb_runtime::{actor::system::ActorSystem, shutdown::Shutdown};
342	use reifydb_sqlite::SqliteTempPathGuard;
343	use reifydb_value::util::cowvec::CowVec;
344
345	use super::*;
346	use crate::tier::{VersionedGetResult, read::ReadBufferConfig};
347
348	fn ek(s: &str) -> EncodedKey {
349		EncodedKey::new(s.as_bytes())
350	}
351
352	fn val(s: &str) -> CowVec<u8> {
353		CowVec::new(s.as_bytes().to_vec())
354	}
355
356	fn write(buffer: &MultiCommitBufferTier, kind: EntryKind, key: &EncodedKey, version: u64, value: &str) {
357		buffer.set(CommitVersion(version), HashMap::from([(kind, vec![(key.clone(), Some(val(value)))])]))
358			.unwrap();
359	}
360
361	struct StaticWatermark(CommitVersion);
362
363	impl EvictionWatermark for StaticWatermark {
364		fn watermark(&self) -> CommitVersion {
365			self.0
366		}
367	}
368
369	struct AllPersistent;
370
371	impl ObjectPersistence for AllPersistent {
372		fn is_persistent(&self, _storage: StorageId) -> bool {
373			true
374		}
375	}
376
377	struct NonePersistent;
378
379	impl ObjectPersistence for NonePersistent {
380		fn is_persistent(&self, _storage: StorageId) -> bool {
381			false
382		}
383	}
384
385	fn build_engine(
386		persistence: Arc<dyn ObjectPersistence>,
387		watermark: Option<CommitVersion>,
388	) -> (FlushEngine, SqliteTempPathGuard) {
389		let buffer = MultiCommitBufferTier::memory();
390		let (persistent, guard) = MultiPersistentTier::sqlite_in_memory();
391		let persistence_lock: Arc<OnceLock<Arc<dyn ObjectPersistence>>> = Arc::new(OnceLock::new());
392		let _ = persistence_lock.set(persistence);
393		let watermark_lock: Arc<RwLock<Option<Arc<dyn EvictionWatermark>>>> = Arc::new(RwLock::new(None));
394		if let Some(w) = watermark {
395			*watermark_lock.write() = Some(Arc::new(StaticWatermark(w)));
396		}
397		(
398			FlushEngine::new(
399				buffer,
400				persistent,
401				persistence_lock,
402				watermark_lock,
403				None,
404				Clock::Real,
405				testing_event_bus(),
406			),
407			guard,
408		)
409	}
410
411	fn testing_event_bus() -> EventBus {
412		EventBus::new(&ActorSystem::testing(Clock::testing()).spawner())
413	}
414
415	#[derive(Clone, Default)]
416	struct SweepCollector {
417		events: Arc<Mutex<Vec<MultiSweptEvent>>>,
418	}
419
420	impl EventListener<MultiSweptEvent> for SweepCollector {
421		fn on(&self, event: &MultiSweptEvent) {
422			self.events.lock().push(event.clone());
423		}
424	}
425
426	fn build_engine_watching_sweeps(
427		persistence: Arc<dyn ObjectPersistence>,
428		watermark: CommitVersion,
429	) -> (FlushEngine, SqliteTempPathGuard, SweepCollector) {
430		let buffer = MultiCommitBufferTier::memory();
431		let (persistent, guard) = MultiPersistentTier::sqlite_in_memory();
432		let persistence_lock: Arc<OnceLock<Arc<dyn ObjectPersistence>>> = Arc::new(OnceLock::new());
433		let _ = persistence_lock.set(persistence);
434		let watermark_lock: Arc<RwLock<Option<Arc<dyn EvictionWatermark>>>> = Arc::new(RwLock::new(None));
435		*watermark_lock.write() = Some(Arc::new(StaticWatermark(watermark)));
436
437		let event_bus = testing_event_bus();
438		let collector = SweepCollector::default();
439		event_bus.register::<MultiSweptEvent, _>(collector.clone());
440
441		(
442			FlushEngine::new(
443				buffer,
444				persistent,
445				persistence_lock,
446				watermark_lock,
447				None,
448				Clock::Real,
449				event_bus,
450			),
451			guard,
452			collector,
453		)
454	}
455
456	#[test]
457	fn a_sweep_reports_every_version_it_evicted_from_the_commit_buffer() {
458		let (engine, _guard, collector) =
459			build_engine_watching_sweeps(Arc::new(AllPersistent), CommitVersion(2));
460		let kind = EntryKind::Source(StorageId::table(TableId(1)));
461		let key = ek("k");
462
463		write(&engine.commit, kind, &key, 1, "v1");
464		write(&engine.commit, kind, &key, 2, "v2");
465
466		engine.sweep(CommitVersion(2));
467		engine.event_bus.wait_for_completion();
468
469		let events = collector.events.lock().clone();
470		assert_eq!(events.len(), 1, "one sweep slice must report exactly once");
471
472		let evictions = events[0].evictions();
473		assert_eq!(evictions.len(), 2, "both versions left the buffer and both must be accounted");
474
475		let current: Vec<&MultiEviction> = evictions.iter().filter(|e| e.current).collect();
476		assert_eq!(current.len(), 1, "exactly one of the two was the live version");
477		assert_eq!(
478			current[0].value_bytes,
479			ByteSize::from_bytes(2),
480			"the evicted bytes must be the value's own, not a placeholder"
481		);
482
483		let superseded: Vec<&MultiEviction> = evictions.iter().filter(|e| !e.current).collect();
484		assert_eq!(superseded.len(), 1, "v1 was superseded by v2 and is discarded, not persisted");
485
486		let persists = events[0].persists();
487		assert_eq!(persists.len(), 1, "only the latest version below the cutoff reaches the persistent tier");
488		assert_eq!(persists[0].value_bytes, ByteSize::from_bytes(2));
489	}
490
491	#[test]
492	fn a_sweep_that_persists_nothing_still_reports_what_it_discarded() {
493		let (engine, _guard, collector) =
494			build_engine_watching_sweeps(Arc::new(NonePersistent), CommitVersion(2));
495		let kind = EntryKind::Source(StorageId::table(TableId(1)));
496		let key = ek("k");
497
498		write(&engine.commit, kind, &key, 1, "v1");
499		write(&engine.commit, kind, &key, 2, "v2");
500
501		engine.sweep(CommitVersion(2));
502		engine.event_bus.wait_for_completion();
503
504		let events = collector.events.lock().clone();
505		assert_eq!(events.len(), 1);
506		assert_eq!(events[0].evictions().len(), 2, "the discarded versions are still reported");
507		assert!(events[0].persists().is_empty(), "a non-persistent object persists nothing");
508	}
509
510	fn build_engine_with_read(
511		persistence: Arc<dyn ObjectPersistence>,
512		watermark: CommitVersion,
513		read: MultiReadBufferTier,
514	) -> (FlushEngine, SqliteTempPathGuard) {
515		let buffer = MultiCommitBufferTier::memory();
516		let (persistent, guard) = MultiPersistentTier::sqlite_in_memory();
517		let persistence_lock: Arc<OnceLock<Arc<dyn ObjectPersistence>>> = Arc::new(OnceLock::new());
518		let _ = persistence_lock.set(persistence);
519		let watermark_lock: Arc<RwLock<Option<Arc<dyn EvictionWatermark>>>> = Arc::new(RwLock::new(None));
520		*watermark_lock.write() = Some(Arc::new(StaticWatermark(watermark)));
521		(
522			FlushEngine::new(
523				buffer,
524				persistent,
525				persistence_lock,
526				watermark_lock,
527				Some(read),
528				Clock::Real,
529				testing_event_bus(),
530			),
531			guard,
532		)
533	}
534
535	#[test]
536	fn eviction_cutoff_is_none_without_watermark() {
537		let (actor, _guard) = build_engine(Arc::new(AllPersistent), None);
538		assert!(actor.eviction_cutoff().is_none(), "no watermark set => no eviction");
539	}
540
541	#[test]
542	fn eviction_cutoff_is_none_at_zero() {
543		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(0)));
544		assert!(actor.eviction_cutoff().is_none());
545	}
546
547	#[test]
548	fn a_pinned_cutoff_reports_the_entries_it_could_not_release() {
549		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(1)));
550		let kind = EntryKind::Source(StorageId::Table(TableId(1)));
551		for i in 0..8u64 {
552			write(&actor.commit, kind, &ek(&format!("k{i}")), 10 + i, "v");
553		}
554
555		let outcome = actor.sweep_slice(FLUSH_KEY_BUDGET);
556
557		assert_eq!(outcome.reclaimed, 0, "nothing is below the pinned cutoff, so nothing can be reclaimed");
558		assert_eq!(
559			outcome.backlog, 8,
560			"the entries the cutoff could not release must still be reported, or a pinned floor \
561			 looks exactly like an idle one"
562		);
563		assert!(
564			outcome.progress.is_exhausted(),
565			"budget exhaustion must not be the backlog signal: a pinned cutoff collects nothing and \
566			 therefore never reports more work to do"
567		);
568	}
569
570	#[test]
571	fn a_cutoff_that_can_release_reports_what_it_reclaimed() {
572		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(20)));
573		let kind = EntryKind::Source(StorageId::Table(TableId(1)));
574		for i in 0..8u64 {
575			write(&actor.commit, kind, &ek(&format!("k{i}")), 10 + i, "v");
576		}
577
578		let outcome = actor.sweep_slice(FLUSH_KEY_BUDGET);
579
580		assert!(outcome.reclaimed > 0, "entries below the cutoff must count as work done");
581		assert_eq!(outcome.backlog, 0, "a drained buffer reports no backlog");
582	}
583
584	#[test]
585	fn sweep_persists_then_evicts_persistent_object_below_watermark() {
586		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(2)));
587		let kind = EntryKind::Source(StorageId::Table(TableId(1)));
588		let key = ek("k");
589		write(&actor.commit, kind, &key, 1, "v1");
590		write(&actor.commit, kind, &key, 2, "v2");
591		write(&actor.commit, kind, &key, 3, "v3");
592
593		actor.sweep(CommitVersion(2));
594
595		assert!(
596			matches!(
597				actor.commit.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
598				VersionedGetResult::NotFound
599			),
600			"v2 must be gone from the buffer after eviction"
601		);
602		assert!(
603			matches!(
604				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
605				VersionedGetResult::Value { .. }
606			),
607			"v2 must survive in the persistent tier"
608		);
609
610		assert_eq!(
611			actor.commit.get(kind, key.as_ref(), CommitVersion(3)).unwrap().value().as_deref(),
612			Some(b"v3".as_slice()),
613			"v3 (> cutoff) must stay in the buffer"
614		);
615	}
616
617	#[test]
618	fn sweep_evicts_non_persistent_object_without_persisting() {
619		let (actor, _guard) = build_engine(Arc::new(NonePersistent), Some(CommitVersion(2)));
620		let kind = EntryKind::Source(StorageId::Table(TableId(7)));
621		let key = ek("ephemeral");
622		write(&actor.commit, kind, &key, 1, "v1");
623		write(&actor.commit, kind, &key, 2, "v2");
624		write(&actor.commit, kind, &key, 3, "v3");
625
626		actor.sweep(CommitVersion(2));
627
628		assert!(
629			matches!(
630				actor.commit.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
631				VersionedGetResult::NotFound
632			),
633			"non-persistent object must still be evicted below the watermark"
634		);
635		assert!(
636			matches!(
637				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
638				VersionedGetResult::NotFound
639			),
640			"non-persistent object must NOT be written to the persistent tier"
641		);
642		assert_eq!(
643			actor.commit.get(kind, key.as_ref(), CommitVersion(3)).unwrap().value().as_deref(),
644			Some(b"v3".as_slice()),
645			"v3 (> cutoff) must stay resident even for a non-persistent object"
646		);
647	}
648
649	#[test]
650	fn sweep_keeps_everything_when_all_above_watermark() {
651		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(1)));
652		let kind = EntryKind::Source(StorageId::Table(TableId(3)));
653		let key = ek("k");
654		write(&actor.commit, kind, &key, 5, "v5");
655
656		actor.sweep(CommitVersion(1));
657
658		assert_eq!(
659			actor.commit.get(kind, key.as_ref(), CommitVersion(5)).unwrap().value().as_deref(),
660			Some(b"v5".as_slice()),
661			"a version above the watermark must never be evicted"
662		);
663		assert!(
664			matches!(
665				actor.persistent.get(kind, key.as_ref(), CommitVersion(5)).unwrap(),
666				VersionedGetResult::NotFound
667			),
668			"nothing below the watermark => nothing persisted"
669		);
670	}
671
672	#[test]
673	fn sweep_seeds_evicted_keys_into_the_read_tier() {
674		let read = MultiReadBufferTier::new(ReadBufferConfig {
675			resident_pages: 16,
676			..Default::default()
677		})
678		.unwrap();
679		let (actor, _guard) = build_engine_with_read(Arc::new(AllPersistent), CommitVersion(2), read.clone());
680		let kind = EntryKind::Source(StorageId::Table(TableId(11)));
681		let key = ek("k");
682		write(&actor.commit, kind, &key, 1, "v1");
683		write(&actor.commit, kind, &key, 2, "v2");
684
685		read.insert(key.clone(), CommitVersion(2), Some(val("stale")));
686
687		actor.sweep(CommitVersion(2));
688
689		match read.get(&key, CommitVersion(2)) {
690			VersionedGetResult::Value {
691				value,
692				..
693			} => assert_eq!(
694				value.as_ref(),
695				val("v2").as_ref(),
696				"the read tier must hold the persisted value, not the stale one"
697			),
698			other => panic!("the sweep must seed the evicted key into the read tier, got {other:?}"),
699		}
700	}
701
702	#[test]
703	fn sweep_seeds_tombstone_into_read_tier() {
704		let read = MultiReadBufferTier::new(ReadBufferConfig {
705			resident_pages: 16,
706			..Default::default()
707		})
708		.unwrap();
709		let (actor, _guard) = build_engine_with_read(Arc::new(AllPersistent), CommitVersion(2), read.clone());
710		let kind = EntryKind::Source(StorageId::Table(TableId(21)));
711		let key = ek("k");
712		write(&actor.commit, kind, &key, 1, "v1");
713		actor.commit.set(CommitVersion(2), HashMap::from([(kind, vec![(key.clone(), None)])])).unwrap();
714
715		actor.sweep(CommitVersion(2));
716
717		assert!(
718			matches!(read.get(&key, CommitVersion(2)), VersionedGetResult::Tombstone),
719			"an evicted tombstone must be seeded into the read tier as a definitive miss, not left absent \
720			 (which would fall through and risk resurrecting an older value)"
721		);
722	}
723
724	#[test]
725	fn sweep_invalidates_rejected_key_but_seeds_accepted() {
726		let read = MultiReadBufferTier::new(ReadBufferConfig {
727			resident_pages: 16,
728			..Default::default()
729		})
730		.unwrap();
731		let (actor, _guard) = build_engine_with_read(Arc::new(AllPersistent), CommitVersion(2), read.clone());
732		let kind = EntryKind::Source(StorageId::Table(TableId(22)));
733		let rejected = ek("rejected");
734		let accepted = ek("accepted");
735
736		actor.persistent
737			.set(CommitVersion(3), HashMap::from([(kind, vec![(rejected.clone(), Some(val("high")))])]))
738			.unwrap();
739
740		read.insert(rejected.clone(), CommitVersion(2), Some(val("stale")));
741
742		write(&actor.commit, kind, &rejected, 2, "low");
743		write(&actor.commit, kind, &accepted, 2, "b");
744
745		actor.sweep(CommitVersion(2));
746
747		assert!(
748			matches!(read.get(&rejected, CommitVersion(2)), VersionedGetResult::NotFound),
749			"a guard-rejected key must be invalidated in the read tier so reads fall through to the newer \
750			 persisted value, never serving the stale entry"
751		);
752		match read.get(&accepted, CommitVersion(2)) {
753			VersionedGetResult::Value {
754				value,
755				..
756			} => assert_eq!(value.as_ref(), val("b").as_ref(), "the accepted key must be seeded"),
757			other => panic!("the accepted key must be seeded into the read tier, got {other:?}"),
758		}
759	}
760
761	#[test]
762	fn sweep_seed_respects_read_tier_downgrade_guard() {
763		let read = MultiReadBufferTier::new(ReadBufferConfig {
764			resident_pages: 16,
765			..Default::default()
766		})
767		.unwrap();
768		let (actor, _guard) = build_engine_with_read(Arc::new(AllPersistent), CommitVersion(2), read.clone());
769		let kind = EntryKind::Source(StorageId::Table(TableId(23)));
770		let key = ek("k");
771
772		read.insert(key.clone(), CommitVersion(5), Some(val("newer")));
773
774		write(&actor.commit, kind, &key, 2, "older");
775		actor.sweep(CommitVersion(2));
776
777		match read.get(&key, CommitVersion(5)) {
778			VersionedGetResult::Value {
779				value,
780				..
781			} => assert_eq!(
782				value.as_ref(),
783				val("newer").as_ref(),
784				"the older seeded value must not overwrite a newer resident read-tier entry"
785			),
786			other => panic!("the newer read-tier entry must survive the sweep's seed, got {other:?}"),
787		}
788	}
789
790	#[test]
791	fn sweep_invalidates_ephemeral_object_in_read_tier() {
792		let read = MultiReadBufferTier::new(ReadBufferConfig {
793			resident_pages: 16,
794			..Default::default()
795		})
796		.unwrap();
797		let (actor, _guard) = build_engine_with_read(Arc::new(NonePersistent), CommitVersion(2), read.clone());
798		let kind = EntryKind::Source(StorageId::Table(TableId(24)));
799		let key = ek("k");
800
801		read.insert(key.clone(), CommitVersion(2), Some(val("stale")));
802		write(&actor.commit, kind, &key, 2, "v2");
803
804		actor.sweep(CommitVersion(2));
805
806		assert!(
807			matches!(read.get(&key, CommitVersion(2)), VersionedGetResult::NotFound),
808			"an ephemeral (persistent:false) object must be invalidated in the read tier, never seeded"
809		);
810		assert!(
811			matches!(
812				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
813				VersionedGetResult::NotFound
814			),
815			"an ephemeral object must not be persisted"
816		);
817	}
818
819	#[test]
820	fn sweep_seeds_accepted_keys_across_version_buckets() {
821		let read = MultiReadBufferTier::new(ReadBufferConfig {
822			resident_pages: 16,
823			..Default::default()
824		})
825		.unwrap();
826		let (actor, _guard) = build_engine_with_read(Arc::new(AllPersistent), CommitVersion(4), read.clone());
827		let kind = EntryKind::Source(StorageId::Table(TableId(25)));
828		let a = ek("a");
829		let b = ek("b");
830		write(&actor.commit, kind, &a, 1, "a1");
831		write(&actor.commit, kind, &a, 2, "a2");
832		write(&actor.commit, kind, &b, 3, "b3");
833		write(&actor.commit, kind, &b, 4, "b4");
834
835		actor.sweep(CommitVersion(4));
836
837		match read.get(&a, CommitVersion(4)) {
838			VersionedGetResult::Value {
839				value,
840				..
841			} => assert_eq!(value.as_ref(), val("a2").as_ref(), "a's latest-<=W (v2) must be seeded"),
842			other => panic!("key a must be seeded across version buckets, got {other:?}"),
843		}
844		match read.get(&b, CommitVersion(4)) {
845			VersionedGetResult::Value {
846				value,
847				..
848			} => assert_eq!(value.as_ref(), val("b4").as_ref(), "b's latest-<=W (v4) must be seeded"),
849			other => panic!("key b must be seeded across version buckets, got {other:?}"),
850		}
851	}
852
853	#[test]
854	fn sweep_persists_tombstone_so_deleted_keys_stay_deleted_after_eviction() {
855		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(2)));
856		let kind = EntryKind::Source(StorageId::Table(TableId(12)));
857		let key = ek("k");
858		write(&actor.commit, kind, &key, 1, "v1");
859		actor.commit.set(CommitVersion(2), HashMap::from([(kind, vec![(key.clone(), None)])])).unwrap();
860
861		actor.sweep(CommitVersion(2));
862
863		assert!(
864			matches!(
865				actor.commit.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
866				VersionedGetResult::NotFound
867			),
868			"both versions are gone from the buffer"
869		);
870		assert!(
871			matches!(
872				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
873				VersionedGetResult::Tombstone
874			),
875			"the persisted latest value must be the tombstone - the row must not resurrect"
876		);
877	}
878
879	#[test]
880	fn sweep_evicts_below_and_keeps_above_across_multiple_keys() {
881		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(2)));
882		let kind = EntryKind::Source(StorageId::Table(TableId(13)));
883		let cold = ek("cold");
884		let hot = ek("hot");
885		write(&actor.commit, kind, &cold, 1, "cold1");
886		write(&actor.commit, kind, &hot, 4, "hot4");
887
888		actor.sweep(CommitVersion(2));
889
890		assert!(
891			matches!(
892				actor.commit.get(kind, cold.as_ref(), CommitVersion(2)).unwrap(),
893				VersionedGetResult::NotFound
894			),
895			"cold (v1 <= cutoff) must be evicted from the buffer"
896		);
897		assert!(
898			matches!(
899				actor.persistent.get(kind, cold.as_ref(), CommitVersion(2)).unwrap(),
900				VersionedGetResult::Value { .. }
901			),
902			"cold must survive in persistent"
903		);
904		assert_eq!(
905			actor.commit.get(kind, hot.as_ref(), CommitVersion(4)).unwrap().value().as_deref(),
906			Some(b"hot4".as_slice()),
907			"hot (v4 > cutoff) must stay resident in the buffer"
908		);
909		assert!(
910			matches!(
911				actor.persistent.get(kind, hot.as_ref(), CommitVersion(4)).unwrap(),
912				VersionedGetResult::NotFound
913			),
914			"hot must not be persisted - it is above the watermark"
915		);
916	}
917
918	#[test]
919	fn flush_all_persists_every_key_regardless_of_watermark() {
920		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(1)));
921		let kind = EntryKind::Source(StorageId::Table(TableId(101)));
922		let cold = ek("cold");
923		let hot = ek("hot");
924		write(&actor.commit, kind, &cold, 2, "cold2");
925		write(&actor.commit, kind, &hot, 50, "hot50");
926
927		actor.sweep(CommitVersion(u64::MAX));
928
929		assert_eq!(
930			actor.persistent.get(kind, cold.as_ref(), CommitVersion(u64::MAX)).unwrap().value().as_deref(),
931			Some(b"cold2".as_slice()),
932			"a key committed above the watermark must be persisted by a full flush"
933		);
934		assert_eq!(
935			actor.persistent.get(kind, hot.as_ref(), CommitVersion(u64::MAX)).unwrap().value().as_deref(),
936			Some(b"hot50".as_slice()),
937			"the latest committed value of every key must survive a full flush"
938		);
939		assert!(
940			matches!(
941				actor.commit.get(kind, hot.as_ref(), CommitVersion(u64::MAX)).unwrap(),
942				VersionedGetResult::NotFound
943			),
944			"a full flush drains the buffer after persisting"
945		);
946	}
947
948	#[test]
949	fn sweep_aborts_and_keeps_buffer_when_persist_fails() {
950		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(2)));
951		let row_kind = EntryKind::Source(StorageId::Table(TableId(31)));
952		let dict_kind = EntryKind::Multi;
953		let row_key = ek("row-referencing-id-7");
954		let dict_key = ek("dictionary-entry-7");
955		write(&actor.commit, row_kind, &row_key, 1, "id=7");
956		write(&actor.commit, dict_kind, &dict_key, 1, "entry-7");
957
958		actor.persistent.shutdown();
959		actor.sweep(CommitVersion(2));
960
961		assert_eq!(
962			actor.commit.get(row_kind, row_key.as_ref(), CommitVersion(2)).unwrap().value().as_deref(),
963			Some(b"id=7".as_slice()),
964			"a failed persist must leave the row write in the commit buffer, not drop the only copy"
965		);
966		assert_eq!(
967			actor.commit.get(dict_kind, dict_key.as_ref(), CommitVersion(2)).unwrap().value().as_deref(),
968			Some(b"entry-7".as_slice()),
969			"a failed persist must leave the dictionary write in the commit buffer, not drop the only copy"
970		);
971	}
972
973	#[test]
974	fn persist_sweep_errors_when_storage_is_shut_down() {
975		let (persistent, _guard) = MultiPersistentTier::sqlite_in_memory();
976		persistent.shutdown();
977
978		let kind = EntryKind::Source(StorageId::Table(TableId(32)));
979		let batches = vec![(CommitVersion(1), HashMap::from([(kind, vec![(ek("k"), Some(val("v")))])]))];
980		assert!(
981			persistent.persist_sweep(batches).is_err(),
982			"a shut-down persistent tier must refuse the sweep loudly so the buffer is not dropped"
983		);
984	}
985
986	#[test]
987	fn sweep_persists_all_kinds_and_versions_together() {
988		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(3)));
989		let row_kind = EntryKind::Source(StorageId::Table(TableId(33)));
990		let dict_kind = EntryKind::Multi;
991		let row_key = ek("row-referencing-id-9");
992		let dict_key = ek("dictionary-entry-9");
993		write(&actor.commit, row_kind, &row_key, 3, "id=9");
994		write(&actor.commit, dict_kind, &dict_key, 2, "entry-9");
995
996		actor.sweep(CommitVersion(3));
997
998		assert_eq!(
999			actor.persistent.get(row_kind, row_key.as_ref(), CommitVersion(3)).unwrap().value().as_deref(),
1000			Some(b"id=9".as_slice()),
1001			"the row write must be durable after the sweep"
1002		);
1003		assert_eq!(
1004			actor.persistent
1005				.get(dict_kind, dict_key.as_ref(), CommitVersion(3))
1006				.unwrap()
1007				.value()
1008				.as_deref(),
1009			Some(b"entry-9".as_slice()),
1010			"the dictionary write committed at an earlier version must be durable in the same sweep"
1011		);
1012		assert!(
1013			matches!(
1014				actor.commit.get(row_kind, row_key.as_ref(), CommitVersion(3)).unwrap(),
1015				VersionedGetResult::NotFound
1016			),
1017			"a persisted row write must be drained from the buffer"
1018		);
1019		assert!(
1020			matches!(
1021				actor.commit.get(dict_kind, dict_key.as_ref(), CommitVersion(3)).unwrap(),
1022				VersionedGetResult::NotFound
1023			),
1024			"a persisted dictionary write must be drained from the buffer"
1025		);
1026	}
1027
1028	#[test]
1029	fn flush_all_persists_latest_tombstone_above_watermark() {
1030		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(1)));
1031		let kind = EntryKind::Source(StorageId::Table(TableId(102)));
1032		let key = ek("k");
1033		write(&actor.commit, kind, &key, 5, "v5");
1034		actor.commit.set(CommitVersion(9), HashMap::from([(kind, vec![(key.clone(), None)])])).unwrap();
1035
1036		actor.sweep(CommitVersion(u64::MAX));
1037
1038		assert!(
1039			matches!(
1040				actor.persistent.get(kind, key.as_ref(), CommitVersion(u64::MAX)).unwrap(),
1041				VersionedGetResult::Tombstone
1042			),
1043			"a delete committed above the watermark must persist as a tombstone, not resurrect"
1044		);
1045	}
1046
1047	#[test]
1048	fn sweep_persists_multi_kind_entries() {
1049		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(10)));
1050		let kind = EntryKind::Multi;
1051		let key = ek("dictionary-entry");
1052		write(&actor.commit, kind, &key, 5, "mint-id-7");
1053
1054		actor.sweep(CommitVersion(10));
1055
1056		assert!(
1057			matches!(
1058				actor.persistent.get(kind, key.as_ref(), CommitVersion(10)).unwrap(),
1059				VersionedGetResult::Value { .. }
1060			),
1061			"a Multi entry committed below the watermark must reach the persistent tier; \
1062			 dictionary entries and CDC checkpoints live in this keyspace and are lost on restart if it does not"
1063		);
1064	}
1065
1066	#[test]
1067	fn a_kind_behind_the_budget_prefix_is_still_swept_under_sustained_writes() {
1068		const KINDS: u64 = 40;
1069		const KEYS_PER_ROUND: u64 = 20;
1070		const BUDGET: usize = 40;
1071		const ROUNDS: u64 = 60;
1072		const FIRST_VERSION: u64 = 1;
1073
1074		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(1_000_000)));
1075		let kinds: Vec<EntryKind> =
1076			(0..KINDS).map(|i| EntryKind::Source(StorageId::Table(TableId(i + 1)))).collect();
1077
1078		let round_writes = |version: u64| {
1079			for kind in &kinds {
1080				for key in 0..KEYS_PER_ROUND {
1081					write(&actor.commit, *kind, &ek(&format!("v{version}-k{key}")), version, "x");
1082				}
1083			}
1084		};
1085
1086		round_writes(FIRST_VERSION);
1087		let mut exhausted = 0;
1088		for round in 0..ROUNDS {
1089			if actor.sweep_slice(BUDGET).progress.is_yielded() {
1090				exhausted += 1;
1091			}
1092			round_writes(FIRST_VERSION + 1 + round);
1093		}
1094
1095		assert_eq!(
1096			exhausted, ROUNDS,
1097			"the budget must run out every slice for this to exercise starvation at all"
1098		);
1099
1100		let oldest = actor.commit.oldest_pending_version().expect("writes are still pending");
1101		assert!(
1102			oldest.0 > FIRST_VERSION,
1103			"the oldest pending version is still {} after {ROUNDS} slices, so at least one of the \
1104			 {KINDS} kinds was never swept once; that kind pins the durable frontier at the first \
1105			 write, which clamps the tombstone reap cutoff to zero and leaves every tombstone in the \
1106			 persistent tier undeletable",
1107			oldest.0
1108		);
1109	}
1110
1111	#[test]
1112	fn a_kind_that_sorts_behind_a_deeper_backlog_is_still_reached_by_the_sweep() {
1113		const HOT_KINDS: u64 = 3;
1114		const KEYS_PER_ROUND: u64 = 40;
1115		const BUDGET: usize = 30;
1116		const ROUNDS: u64 = 80;
1117		const COLD_FIRST_VERSION: u64 = 50;
1118
1119		let (actor, _guard) = build_engine(Arc::new(AllPersistent), Some(CommitVersion(1_000_000)));
1120		let hot: Vec<EntryKind> =
1121			(0..HOT_KINDS).map(|i| EntryKind::Source(StorageId::Table(TableId(i + 1)))).collect();
1122		let cold = EntryKind::Source(StorageId::Table(TableId(HOT_KINDS + 1)));
1123
1124		for round in 1..=ROUNDS {
1125			for kind in &hot {
1126				for key in 0..KEYS_PER_ROUND {
1127					write(&actor.commit, *kind, &ek(&format!("v{round}-k{key}")), round, "x");
1128				}
1129			}
1130			if round == COLD_FIRST_VERSION {
1131				write(&actor.commit, cold, &ek("cold-key"), round, "x");
1132			}
1133			actor.sweep_slice(BUDGET);
1134		}
1135
1136		assert!(
1137			actor.commit.oldest_pending_for(hot[0]).is_some(),
1138			"the hot kinds must stay backlogged, otherwise the budget never ran out and this exercises nothing"
1139		);
1140		assert_eq!(
1141			actor.commit.oldest_pending_for(cold),
1142			None,
1143			"the single write to the cold kind is still pending after {} slices, so the sweep never reached past the hot kinds sorted ahead of it",
1144			ROUNDS - COLD_FIRST_VERSION
1145		);
1146	}
1147}