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