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