Skip to main content

reifydb_store_multi/flush/
actor.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (c) 2026 ReifyDB
3
4#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
5use std::collections::HashMap;
6use std::{
7	sync::{Arc, OnceLock},
8	time::Duration,
9};
10
11#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
12use reifydb_core::{common::CommitVersion, encoded::key::EncodedKey, interface::store::EntryKind};
13#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
14use reifydb_runtime::actor::{
15	context::Context,
16	mailbox::ActorRef,
17	system::{ActorConfig, ActorSystem},
18	traits::{Actor, Directive},
19};
20use reifydb_runtime::{actor::timers::TimerHandle, sync::waiter::WaiterHandle};
21use reifydb_value::value::datetime::DateTime;
22#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
23use tracing::{debug, error, warn};
24
25#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
26use crate::tier::{TierBatch, TierStorage};
27use crate::{
28	flush::ShapePersistence,
29	gc::EvictionWatermark,
30	tier::{
31		commit::buffer::MultiCommitBufferTier, persistent::MultiPersistentTier,
32		read::buffer::MultiReadBufferTier,
33	},
34};
35
36#[derive(Clone)]
37pub enum FlushMessage {
38	Tick(DateTime),
39	Shutdown,
40
41	FlushPending {
42		waiter: Arc<WaiterHandle>,
43	},
44}
45
46#[allow(dead_code)]
47pub struct FlushActorState {
48	_timer_handle: Option<TimerHandle>,
49}
50
51#[allow(dead_code)]
52pub struct FlushActor {
53	commit: MultiCommitBufferTier,
54	persistent: MultiPersistentTier,
55	flush_interval: Duration,
56	persistence: Arc<OnceLock<Arc<dyn ShapePersistence>>>,
57	eviction_watermark: Arc<OnceLock<Arc<dyn EvictionWatermark>>>,
58	read: Option<MultiReadBufferTier>,
59}
60
61#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
62impl FlushActor {
63	pub fn new(
64		commit: MultiCommitBufferTier,
65		persistent: MultiPersistentTier,
66		flush_interval: Duration,
67		persistence: Arc<OnceLock<Arc<dyn ShapePersistence>>>,
68		eviction_watermark: Arc<OnceLock<Arc<dyn EvictionWatermark>>>,
69		read: Option<MultiReadBufferTier>,
70	) -> Self {
71		Self {
72			commit,
73			persistent,
74			flush_interval,
75			persistence,
76			eviction_watermark,
77			read,
78		}
79	}
80
81	pub fn spawn(
82		system: &ActorSystem,
83		commit: MultiCommitBufferTier,
84		persistent: MultiPersistentTier,
85		flush_interval: Duration,
86		persistence: Arc<OnceLock<Arc<dyn ShapePersistence>>>,
87		eviction_watermark: Arc<OnceLock<Arc<dyn EvictionWatermark>>>,
88		read: Option<MultiReadBufferTier>,
89	) -> ActorRef<FlushMessage> {
90		let actor = Self::new(commit, persistent, flush_interval, persistence, eviction_watermark, read);
91		system.spawn_background("persistent-flush", actor).actor_ref().clone()
92	}
93
94	fn eviction_cutoff(&self) -> Option<CommitVersion> {
95		let cutoff = self.eviction_watermark.get()?.watermark();
96		if cutoff.0 == 0 {
97			return None;
98		}
99		Some(cutoff)
100	}
101
102	fn is_persistent_shape(&self, kind: EntryKind) -> bool {
103		match kind {
104			EntryKind::Source(shape) => {
105				self.persistence.get().map(|provider| provider.is_persistent(shape)).unwrap_or(true)
106			}
107			_ => true,
108		}
109	}
110
111	fn sweep(&self, cutoff: CommitVersion) {
112		let entry_kinds = match self.commit.list_all_entry_kinds() {
113			Ok(v) => v,
114			Err(e) => {
115				warn!(error = %e, "flush sweep: list_all_entry_kinds failed");
116				return;
117			}
118		};
119
120		let mut persisted = 0usize;
121		let mut dropped = 0usize;
122
123		for kind in entry_kinds {
124			let (to_persist, to_drop) = match &self.commit {
125				MultiCommitBufferTier::Memory(s) => s.collect_evictable_below(kind, cutoff),
126			};
127
128			if to_drop.is_empty() {
129				continue;
130			}
131
132			if self.is_persistent_shape(kind) && !to_persist.is_empty() {
133				let mut batch: HashMap<CommitVersion, TierBatch> = HashMap::new();
134				for (key, version, value) in to_persist {
135					batch.entry(version).or_default().entry(kind).or_default().push((key, value));
136				}
137				let mut persist_failed = false;
138				for (version, by_kind) in batch {
139					let count: usize = by_kind.values().map(|v| v.len()).sum();
140					if let Err(e) = self.persistent.set(version, by_kind) {
141						error!(version = version.0, error = %e, "flush sweep: persist failed");
142						persist_failed = true;
143						break;
144					}
145					persisted += count;
146				}
147				if persist_failed {
148					continue;
149				}
150			}
151
152			let drop_count = to_drop.len();
153			if let Some(read) = &self.read {
154				for (key, _) in &to_drop {
155					read.invalidate(key);
156				}
157			}
158			let mut batches: HashMap<EntryKind, Vec<(EncodedKey, CommitVersion)>> = HashMap::new();
159			batches.insert(kind, to_drop);
160			if let Err(e) = self.commit.drop(batches) {
161				warn!(?kind, error = %e, "flush sweep: commit buffer drop failed");
162				continue;
163			}
164			dropped += drop_count;
165		}
166
167		if persisted > 0 || dropped > 0 {
168			debug!(cutoff = cutoff.0, persisted, dropped, "flush sweep completed");
169			if persisted > 0
170				&& let Err(e) = self.persistent.maybe_checkpoint()
171			{
172				warn!(error = %e, "flush sweep: checkpoint failed");
173			}
174			self.commit.maintenance();
175		}
176	}
177}
178
179#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
180impl Actor for FlushActor {
181	type State = FlushActorState;
182	type Message = FlushMessage;
183
184	fn init(&self, ctx: &Context<FlushMessage>) -> FlushActorState {
185		debug!("Persistent flush actor started");
186		let timer_handle =
187			ctx.schedule_tick(self.flush_interval, |nanos| FlushMessage::Tick(DateTime::from_nanos(nanos)));
188		FlushActorState {
189			_timer_handle: Some(timer_handle),
190		}
191	}
192
193	fn handle(&self, _state: &mut FlushActorState, msg: FlushMessage, ctx: &Context<FlushMessage>) -> Directive {
194		if ctx.is_cancelled() {
195			if let Some(cutoff) = self.eviction_cutoff() {
196				self.sweep(cutoff);
197			}
198			return Directive::Stop;
199		}
200		match msg {
201			FlushMessage::Tick(_) => {
202				if let Some(cutoff) = self.eviction_cutoff() {
203					self.sweep(cutoff);
204				}
205			}
206			FlushMessage::Shutdown => {
207				debug!("Persistent flush actor shutting down");
208				if let Some(cutoff) = self.eviction_cutoff() {
209					self.sweep(cutoff);
210				}
211				return Directive::Stop;
212			}
213			FlushMessage::FlushPending {
214				waiter,
215			} => {
216				if let Some(cutoff) = self.eviction_cutoff() {
217					self.sweep(cutoff);
218				}
219				waiter.notify();
220			}
221		}
222		Directive::Continue
223	}
224
225	fn post_stop(&self) {
226		debug!("Persistent flush actor stopped");
227	}
228
229	fn config(&self) -> ActorConfig {
230		ActorConfig::new().mailbox_capacity(4096)
231	}
232}
233
234#[cfg(all(test, feature = "sqlite", not(target_arch = "wasm32")))]
235mod tests {
236	use reifydb_core::interface::catalog::{id::TableId, shape::ShapeId};
237	use reifydb_sqlite::SqliteTempPathGuard;
238	use reifydb_value::util::cowvec::CowVec;
239
240	use super::*;
241	use crate::tier::VersionedGetResult;
242
243	fn ek(s: &str) -> EncodedKey {
244		EncodedKey::new(s.as_bytes().to_vec())
245	}
246
247	fn val(s: &str) -> CowVec<u8> {
248		CowVec::new(s.as_bytes().to_vec())
249	}
250
251	fn write(buffer: &MultiCommitBufferTier, kind: EntryKind, key: &EncodedKey, version: u64, value: &str) {
252		buffer.set(CommitVersion(version), HashMap::from([(kind, vec![(key.clone(), Some(val(value)))])]))
253			.unwrap();
254	}
255
256	struct StaticWatermark(CommitVersion);
257
258	impl EvictionWatermark for StaticWatermark {
259		fn watermark(&self) -> CommitVersion {
260			self.0
261		}
262	}
263
264	struct AllPersistent;
265
266	impl ShapePersistence for AllPersistent {
267		fn is_persistent(&self, _shape: ShapeId) -> bool {
268			true
269		}
270	}
271
272	struct NonePersistent;
273
274	impl ShapePersistence for NonePersistent {
275		fn is_persistent(&self, _shape: ShapeId) -> bool {
276			false
277		}
278	}
279
280	fn build_actor(
281		persistence: Arc<dyn ShapePersistence>,
282		watermark: Option<CommitVersion>,
283	) -> (FlushActor, SqliteTempPathGuard) {
284		let buffer = MultiCommitBufferTier::memory();
285		let (persistent, guard) = MultiPersistentTier::sqlite_in_memory();
286		let persistence_lock: Arc<OnceLock<Arc<dyn ShapePersistence>>> = Arc::new(OnceLock::new());
287		let _ = persistence_lock.set(persistence);
288		let watermark_lock: Arc<OnceLock<Arc<dyn EvictionWatermark>>> = Arc::new(OnceLock::new());
289		if let Some(w) = watermark {
290			let _ = watermark_lock.set(Arc::new(StaticWatermark(w)));
291		}
292		(
293			FlushActor::new(
294				buffer,
295				persistent,
296				Duration::from_secs(5),
297				persistence_lock,
298				watermark_lock,
299				None,
300			),
301			guard,
302		)
303	}
304
305	fn build_actor_with_read(
306		persistence: Arc<dyn ShapePersistence>,
307		watermark: CommitVersion,
308		read: MultiReadBufferTier,
309	) -> (FlushActor, SqliteTempPathGuard) {
310		let buffer = MultiCommitBufferTier::memory();
311		let (persistent, guard) = MultiPersistentTier::sqlite_in_memory();
312		let persistence_lock: Arc<OnceLock<Arc<dyn ShapePersistence>>> = Arc::new(OnceLock::new());
313		let _ = persistence_lock.set(persistence);
314		let watermark_lock: Arc<OnceLock<Arc<dyn EvictionWatermark>>> = Arc::new(OnceLock::new());
315		let _ = watermark_lock.set(Arc::new(StaticWatermark(watermark)));
316		(
317			FlushActor::new(
318				buffer,
319				persistent,
320				Duration::from_secs(5),
321				persistence_lock,
322				watermark_lock,
323				Some(read),
324			),
325			guard,
326		)
327	}
328
329	#[test]
330	fn eviction_cutoff_is_none_without_watermark() {
331		let (actor, _guard) = build_actor(Arc::new(AllPersistent), None);
332		assert!(actor.eviction_cutoff().is_none(), "no watermark set => no eviction");
333	}
334
335	#[test]
336	fn eviction_cutoff_is_none_at_zero() {
337		let (actor, _guard) = build_actor(Arc::new(AllPersistent), Some(CommitVersion(0)));
338		assert!(actor.eviction_cutoff().is_none());
339	}
340
341	#[test]
342	fn sweep_persists_then_evicts_persistent_shape_below_watermark() {
343		let (actor, _guard) = build_actor(Arc::new(AllPersistent), Some(CommitVersion(2)));
344		let kind = EntryKind::Source(ShapeId::Table(TableId(1)));
345		let key = ek("k");
346		write(&actor.commit, kind, &key, 1, "v1");
347		write(&actor.commit, kind, &key, 2, "v2");
348		write(&actor.commit, kind, &key, 3, "v3");
349
350		actor.sweep(CommitVersion(2));
351
352		assert!(
353			matches!(
354				actor.commit.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
355				VersionedGetResult::NotFound
356			),
357			"v2 must be gone from the buffer after eviction"
358		);
359		assert!(
360			matches!(
361				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
362				VersionedGetResult::Value { .. }
363			),
364			"v2 must survive in the persistent tier"
365		);
366
367		assert_eq!(
368			actor.commit.get(kind, key.as_ref(), CommitVersion(3)).unwrap().value().as_deref(),
369			Some(b"v3".as_slice()),
370			"v3 (> cutoff) must stay in the buffer"
371		);
372	}
373
374	#[test]
375	fn sweep_evicts_non_persistent_shape_without_persisting() {
376		let (actor, _guard) = build_actor(Arc::new(NonePersistent), Some(CommitVersion(2)));
377		let kind = EntryKind::Source(ShapeId::Table(TableId(7)));
378		let key = ek("ephemeral");
379		write(&actor.commit, kind, &key, 1, "v1");
380		write(&actor.commit, kind, &key, 2, "v2");
381		write(&actor.commit, kind, &key, 3, "v3");
382
383		actor.sweep(CommitVersion(2));
384
385		assert!(
386			matches!(
387				actor.commit.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
388				VersionedGetResult::NotFound
389			),
390			"non-persistent shape must still be evicted below the watermark"
391		);
392		assert!(
393			matches!(
394				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
395				VersionedGetResult::NotFound
396			),
397			"non-persistent shape must NOT be written to the persistent tier"
398		);
399		assert_eq!(
400			actor.commit.get(kind, key.as_ref(), CommitVersion(3)).unwrap().value().as_deref(),
401			Some(b"v3".as_slice()),
402			"v3 (> cutoff) must stay resident even for a non-persistent shape"
403		);
404	}
405
406	#[test]
407	fn sweep_keeps_everything_when_all_above_watermark() {
408		let (actor, _guard) = build_actor(Arc::new(AllPersistent), Some(CommitVersion(1)));
409		let kind = EntryKind::Source(ShapeId::Table(TableId(3)));
410		let key = ek("k");
411		write(&actor.commit, kind, &key, 5, "v5");
412
413		actor.sweep(CommitVersion(1));
414
415		assert_eq!(
416			actor.commit.get(kind, key.as_ref(), CommitVersion(5)).unwrap().value().as_deref(),
417			Some(b"v5".as_slice()),
418			"a version above the watermark must never be evicted"
419		);
420		assert!(
421			matches!(
422				actor.persistent.get(kind, key.as_ref(), CommitVersion(5)).unwrap(),
423				VersionedGetResult::NotFound
424			),
425			"nothing below the watermark => nothing persisted"
426		);
427	}
428
429	#[test]
430	fn sweep_invalidates_evicted_keys_in_the_read_tier() {
431		let read = MultiReadBufferTier::new(16);
432		let (actor, _guard) = build_actor_with_read(Arc::new(AllPersistent), CommitVersion(2), read.clone());
433		let kind = EntryKind::Source(ShapeId::Table(TableId(11)));
434		let key = ek("k");
435		write(&actor.commit, kind, &key, 1, "v1");
436		write(&actor.commit, kind, &key, 2, "v2");
437
438		read.insert(key.clone(), CommitVersion(2), Some(val("stale")));
439		assert!(matches!(read.get(&key, CommitVersion(2)), VersionedGetResult::Value { .. }));
440
441		actor.sweep(CommitVersion(2));
442
443		assert!(
444			matches!(read.get(&key, CommitVersion(2)), VersionedGetResult::NotFound),
445			"the read tier must be invalidated for keys evicted by the sweep"
446		);
447	}
448
449	#[test]
450	fn sweep_persists_tombstone_so_deleted_keys_stay_deleted_after_eviction() {
451		let (actor, _guard) = build_actor(Arc::new(AllPersistent), Some(CommitVersion(2)));
452		let kind = EntryKind::Source(ShapeId::Table(TableId(12)));
453		let key = ek("k");
454		write(&actor.commit, kind, &key, 1, "v1");
455		actor.commit.set(CommitVersion(2), HashMap::from([(kind, vec![(key.clone(), None)])])).unwrap();
456
457		actor.sweep(CommitVersion(2));
458
459		assert!(
460			matches!(
461				actor.commit.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
462				VersionedGetResult::NotFound
463			),
464			"both versions are gone from the buffer"
465		);
466		assert!(
467			matches!(
468				actor.persistent.get(kind, key.as_ref(), CommitVersion(2)).unwrap(),
469				VersionedGetResult::Tombstone
470			),
471			"the persisted latest value must be the tombstone - the row must not resurrect"
472		);
473	}
474
475	#[test]
476	fn sweep_evicts_below_and_keeps_above_across_multiple_keys() {
477		let (actor, _guard) = build_actor(Arc::new(AllPersistent), Some(CommitVersion(2)));
478		let kind = EntryKind::Source(ShapeId::Table(TableId(13)));
479		let cold = ek("cold");
480		let hot = ek("hot");
481		write(&actor.commit, kind, &cold, 1, "cold1");
482		write(&actor.commit, kind, &hot, 4, "hot4");
483
484		actor.sweep(CommitVersion(2));
485
486		assert!(
487			matches!(
488				actor.commit.get(kind, cold.as_ref(), CommitVersion(2)).unwrap(),
489				VersionedGetResult::NotFound
490			),
491			"cold (v1 <= cutoff) must be evicted from the buffer"
492		);
493		assert!(
494			matches!(
495				actor.persistent.get(kind, cold.as_ref(), CommitVersion(2)).unwrap(),
496				VersionedGetResult::Value { .. }
497			),
498			"cold must survive in persistent"
499		);
500		assert_eq!(
501			actor.commit.get(kind, hot.as_ref(), CommitVersion(4)).unwrap().value().as_deref(),
502			Some(b"hot4".as_slice()),
503			"hot (v4 > cutoff) must stay resident in the buffer"
504		);
505		assert!(
506			matches!(
507				actor.persistent.get(kind, hot.as_ref(), CommitVersion(4)).unwrap(),
508				VersionedGetResult::NotFound
509			),
510			"hot must not be persisted - it is above the watermark"
511		);
512	}
513}