1#[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}