1use std::{
5 collections::HashMap,
6 iter::repeat_n,
7 ops::Bound,
8 sync::{
9 Arc,
10 atomic::{AtomicUsize, Ordering},
11 },
12 time::Duration,
13};
14
15use reifydb_core::{
16 common::CommitVersion,
17 encoded::{key::EncodedKey, row::EncodedRow},
18 error::diagnostic::internal::internal,
19 interface::store::EntryKind,
20 row::TtlAnchor,
21};
22use reifydb_runtime::sync::{
23 map::Map,
24 mutex::{Mutex, MutexGuard},
25};
26use reifydb_sqlite::{
27 SqliteConfig, SqliteTempPathGuard,
28 connection::{connect, convert_flags, resolve_db_path},
29 pragma,
30};
31use reifydb_value::{Result, error, util::cowvec::CowVec};
32use rusqlite::{Connection, Error::QueryReturnedNoRows, Result as SqliteResult, ToSql, params, params_from_iter};
33use tracing::{instrument, warn};
34
35use crate::tier::{
36 HistoricalCursor, RangeBatch, RangeCursor, RawEntry, TierBackend, TierBatch, TierStorage, VersionedGetResult,
37 persistent::{
38 CheckpointOutcome,
39 sqlite::{
40 entry::current_table_name,
41 query::{
42 build_create_current_sql, build_delete_expired_sql, build_delete_keys_sql,
43 build_get_current_sql, build_get_many_current_sql, build_range_current_sql,
44 build_upsert_current_sql, prefix_upper_bound, version_from_bytes, version_to_bytes,
45 },
46 },
47 },
48};
49
50const GET_MANY_CHUNK: usize = 900;
51
52const GET_MANY_BUCKETS: [usize; 5] = [1, 8, 64, 512, GET_MANY_CHUNK];
53
54fn bucket_key_count(len: usize) -> usize {
55 for &bucket in GET_MANY_BUCKETS.iter() {
56 if len <= bucket {
57 return bucket;
58 }
59 }
60 GET_MANY_CHUNK
61}
62
63const WRITER_BUSY_TIMEOUT: Duration = Duration::from_millis(200);
64
65#[derive(Clone)]
66pub struct SqlitePersistentStorage {
67 inner: Arc<SqlitePersistentStorageInner>,
68}
69
70struct SqlitePersistentStorageInner {
71 conn: Mutex<Connection>,
72 readers: ReadPool,
73 checkpoint_threshold_frames: u32,
74 table_sql: Map<EntryKind, Arc<TableSql>>,
75}
76
77struct TableSql {
78 table_name: String,
79 get_sql: String,
80 upsert_sql: String,
81 create_sql: String,
82}
83
84impl TableSql {
85 fn build(table: EntryKind) -> Self {
86 let table_name = current_table_name(table);
87 let get_sql = build_get_current_sql(&table_name);
88 let upsert_sql = build_upsert_current_sql(&table_name);
89 let create_sql = build_create_current_sql(&table_name);
90 Self {
91 table_name,
92 get_sql,
93 upsert_sql,
94 create_sql,
95 }
96 }
97}
98
99struct ReadPool {
100 conns: Vec<Mutex<Connection>>,
101 next: AtomicUsize,
102}
103
104impl ReadPool {
105 fn acquire(&self) -> MutexGuard<'_, Connection> {
106 let n = self.conns.len();
107 let start = self.next.fetch_add(1, Ordering::Relaxed) % n;
108 for i in 0..n {
109 if let Some(guard) = self.conns[(start + i) % n].try_lock() {
110 return guard;
111 }
112 }
113 self.conns[start].lock()
114 }
115}
116
117impl SqlitePersistentStorage {
118 #[instrument(name = "store::multi::persistent::sqlite::new", level = "debug", skip(config), fields(
119 db_path = ?config.path,
120 page_size = config.page_size,
121 journal_mode = %config.journal_mode.as_str()
122 ))]
123 pub fn new(config: SqliteConfig) -> Self {
124 let db_path = resolve_db_path(config.path.clone(), "persistent.db");
125 let flags = convert_flags(&config.flags);
126
127 let conn = connect(&db_path, flags).expect("Failed to connect to persistent database");
128 pragma::apply(&conn, &config).expect("Failed to configure persistent SQLite pragmas");
129 conn.busy_timeout(WRITER_BUSY_TIMEOUT).expect("Failed to set persistent busy timeout");
130
131 let pool_size = config.read_pool_size.max(1) as usize;
132 let mut conns = Vec::with_capacity(pool_size);
133 for _ in 0..pool_size {
134 let reader = connect(&db_path, flags).expect("Failed to open persistent read connection");
135 pragma::apply_read_only(&reader, &config)
136 .expect("Failed to configure persistent read connection");
137 conns.push(Mutex::new(reader));
138 }
139
140 Self {
141 inner: Arc::new(SqlitePersistentStorageInner {
142 conn: Mutex::new(conn),
143 readers: ReadPool {
144 conns,
145 next: AtomicUsize::new(0),
146 },
147 checkpoint_threshold_frames: config.wal_autocheckpoint,
148 table_sql: Map::new(),
149 }),
150 }
151 }
152
153 pub fn maybe_checkpoint(&self) -> Result<CheckpointOutcome> {
154 let conn = self.inner.conn.lock();
155
156 let mut log_frames: i64 = 0;
157 conn.pragma(None, "wal_checkpoint", "PASSIVE", |row| {
158 log_frames = row.get(1)?;
159 Ok(())
160 })
161 .map_err(|e| error!(internal(format!("Failed to query persistent WAL size: {}", e))))?;
162
163 let log_frames = log_frames.max(0) as u32;
164 if log_frames <= self.inner.checkpoint_threshold_frames {
165 return Ok(CheckpointOutcome {
166 log_frames,
167 restarted: false,
168 });
169 }
170
171 let mut busy: i64 = 1;
172 if let Err(e) = conn.pragma(None, "wal_checkpoint", "RESTART", |row| {
173 busy = row.get(0)?;
174 Ok(())
175 }) {
176 warn!(error = %e, "persistent checkpoint: RESTART failed");
177 }
178
179 Ok(CheckpointOutcome {
180 log_frames,
181 restarted: busy == 0,
182 })
183 }
184
185 pub fn in_memory() -> (Self, SqliteTempPathGuard) {
186 let (config, guard) = SqliteConfig::in_memory();
187 (Self::new(config), guard)
188 }
189
190 fn table_sql(&self, table: EntryKind) -> Arc<TableSql> {
191 self.inner.table_sql.get_or_insert_with(table, || Arc::new(TableSql::build(table)))
192 }
193
194 pub fn count_current(&self, table: EntryKind) -> Result<u64> {
195 let table_sql = self.table_sql(table);
196 let conn = self.inner.readers.acquire();
197 let sql = format!("SELECT COUNT(*) FROM \"{}\"", table_sql.table_name);
198 match conn.query_row(&sql, [], |row| row.get::<_, i64>(0)) {
199 Ok(c) => Ok(c as u64),
200 Err(e) if e.to_string().contains("no such table") => Ok(0),
201 Err(e) => Err(error!(internal(format!("Failed to count persistent current: {}", e)))),
202 }
203 }
204
205 pub fn delete_expired(
206 &self,
207 table: EntryKind,
208 anchor: TtlAnchor,
209 cutoff_nanos: u64,
210 prefix: Option<&[u8]>,
211 ) -> Result<u64> {
212 let table_sql = self.table_sql(table);
213 let anchor_column = match anchor {
214 TtlAnchor::Created => "created_nanos",
215 TtlAnchor::Updated => "updated_nanos",
216 };
217 let sql = build_delete_expired_sql(&table_sql.table_name, anchor_column, prefix.is_some());
218 let conn = self.inner.conn.lock();
219 let result = match prefix {
220 Some(prefix) => {
221 let upper = prefix_upper_bound(prefix);
222 conn.execute(&sql, params![cutoff_nanos as i64, prefix, upper.as_slice()])
223 }
224 None => conn.execute(&sql, params![cutoff_nanos as i64]),
225 };
226 match result {
227 Ok(n) => Ok(n as u64),
228 Err(e) if e.to_string().contains("no such table") => Ok(0),
229 Err(e) => Err(error!(internal(format!(
230 "Failed to delete expired persistent rows from {}: {}",
231 table_sql.table_name, e
232 )))),
233 }
234 }
235
236 pub fn delete_keys(&self, table: EntryKind, keys: &[EncodedKey]) -> Result<u64> {
237 if keys.is_empty() {
238 return Ok(0);
239 }
240 let table_sql = self.table_sql(table);
241 let conn = self.inner.conn.lock();
242 let mut total = 0u64;
243 for chunk in keys.chunks(GET_MANY_CHUNK) {
244 let sql = build_delete_keys_sql(&table_sql.table_name, chunk.len());
245 match conn.execute(&sql, params_from_iter(chunk.iter().map(|k| k.as_slice()))) {
246 Ok(n) => total += n as u64,
247 Err(e) if e.to_string().contains("no such table") => return Ok(total),
248 Err(e) => {
249 return Err(error!(internal(format!(
250 "Failed to delete keys from {}: {}",
251 table_sql.table_name, e
252 ))));
253 }
254 }
255 }
256 Ok(total)
257 }
258
259 fn create_table_if_needed(conn: &Connection, create_sql: &str) -> SqliteResult<()> {
260 conn.execute_batch(create_sql)?;
261 Ok(())
262 }
263
264 fn range_chunk(&self, cursor: &mut RangeCursor, req: RangeChunkRequest<'_>) -> Result<RangeBatch> {
265 if cursor.exhausted {
266 return Ok(RangeBatch::empty());
267 }
268
269 let table_sql = self.table_sql(req.table);
270 let conn = self.inner.readers.acquire();
271
272 let sql = build_range_current_sql(
273 &table_sql.table_name,
274 bound_shape(req.start),
275 bound_shape(req.end),
276 cursor.last_key.is_some(),
277 req.descending,
278 );
279
280 let mut stmt = match conn.prepare_cached(&sql) {
281 Ok(s) => s,
282 Err(e) if e.to_string().contains("no such table") => {
283 cursor.exhausted = true;
284 return Ok(RangeBatch::empty());
285 }
286 Err(e) => return Err(error!(internal(format!("Failed to prepare persistent range: {}", e)))),
287 };
288
289 let version_bytes = version_to_bytes(req.version).to_vec();
290 let limit_i64 = req.batch_size as i64;
291 let mut params: Vec<Box<dyn ToSql>> = Vec::new();
292 match req.start {
293 Bound::Included(s) | Bound::Excluded(s) => params.push(Box::new(s.to_vec())),
294 Bound::Unbounded => {}
295 }
296 match req.end {
297 Bound::Included(e) | Bound::Excluded(e) => params.push(Box::new(e.to_vec())),
298 Bound::Unbounded => {}
299 }
300 if let Some(k) = cursor.last_key.as_deref() {
301 params.push(Box::new(k.to_vec()));
302 }
303 params.push(Box::new(version_bytes));
304 params.push(Box::new(limit_i64));
305
306 let entries = match stmt.query_map(params_from_iter(params), |row| {
307 let key: Vec<u8> = row.get(0)?;
308 let version_blob: Vec<u8> = row.get(1)?;
309 let value: Option<Vec<u8>> = row.get(2)?;
310 Ok(RawEntry {
311 key: EncodedKey::new(key),
312 version: version_from_bytes(&version_blob),
313 value: value.map(CowVec::new),
314 })
315 }) {
316 Ok(rows) => rows
317 .collect::<SqliteResult<Vec<_>>>()
318 .map_err(|e| error!(internal(format!("Failed to read persistent row: {}", e))))?,
319 Err(e) if e.to_string().contains("no such table") => {
320 cursor.exhausted = true;
321 return Ok(RangeBatch::empty());
322 }
323 Err(e) => return Err(error!(internal(format!("Failed to scan persistent range: {}", e)))),
324 };
325
326 if entries.len() < req.batch_size {
327 cursor.exhausted = true;
328 }
329 if let Some(last) = entries.last() {
330 cursor.last_key = Some(last.key.clone());
331 }
332
333 let has_more = !cursor.exhausted;
334 Ok(RangeBatch {
335 entries,
336 has_more,
337 })
338 }
339}
340
341fn bound_shape(b: Bound<&[u8]>) -> Bound<()> {
342 match b {
343 Bound::Included(_) => Bound::Included(()),
344 Bound::Excluded(_) => Bound::Excluded(()),
345 Bound::Unbounded => Bound::Unbounded,
346 }
347}
348
349struct RangeChunkRequest<'a> {
350 table: EntryKind,
351 start: Bound<&'a [u8]>,
352 end: Bound<&'a [u8]>,
353 version: CommitVersion,
354 batch_size: usize,
355 descending: bool,
356}
357
358impl TierStorage for SqlitePersistentStorage {
359 #[instrument(name = "store::multi::persistent::sqlite::get", level = "trace", skip(self), fields(table = ?table, key_len = key.len(), version = version.0))]
360 fn get(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
361 let table_sql = self.table_sql(table);
362 let conn = self.inner.readers.acquire();
363
364 let result = match conn.prepare_cached(&table_sql.get_sql) {
365 Ok(mut stmt) => stmt.query_row(params![key], |row| {
366 let version_bytes: Vec<u8> = row.get(0)?;
367 let value: Option<Vec<u8>> = row.get(1)?;
368 Ok((version_from_bytes(&version_bytes), value))
369 }),
370 Err(e) if e.to_string().contains("no such table") => Err(QueryReturnedNoRows),
371 Err(e) => return Err(error!(internal(format!("Failed to prepare persistent get: {}", e)))),
372 };
373
374 match result {
375 Ok((stored_version, value)) if stored_version <= version => Ok(match value {
376 Some(v) => VersionedGetResult::Value {
377 value: CowVec::new(v),
378 version: stored_version,
379 },
380 None => VersionedGetResult::Tombstone,
381 }),
382 Ok(_) => Ok(VersionedGetResult::NotFound),
383 Err(QueryReturnedNoRows) => Ok(VersionedGetResult::NotFound),
384 Err(e) if e.to_string().contains("no such table") => Ok(VersionedGetResult::NotFound),
385 Err(e) => Err(error!(internal(format!("Failed to read persistent: {}", e)))),
386 }
387 }
388
389 fn get_many(
390 &self,
391 table: EntryKind,
392 keys: &[&[u8]],
393 version: CommitVersion,
394 ) -> Result<Vec<VersionedGetResult>> {
395 let mut out = vec![VersionedGetResult::NotFound; keys.len()];
396 if keys.is_empty() {
397 return Ok(out);
398 }
399
400 let index: HashMap<&[u8], usize> = keys.iter().enumerate().map(|(i, &k)| (k, i)).collect();
401 let table_sql = self.table_sql(table);
402 let conn = self.inner.readers.acquire();
403
404 for chunk in keys.chunks(GET_MANY_CHUNK) {
405 let bucket = bucket_key_count(chunk.len());
406 let sql = build_get_many_current_sql(&table_sql.table_name, bucket);
407 let mut stmt = match conn.prepare_cached(&sql) {
408 Ok(stmt) => stmt,
409 Err(e) if e.to_string().contains("no such table") => return Ok(out),
410 Err(e) => {
411 return Err(error!(internal(format!(
412 "Failed to prepare persistent get_many: {}",
413 e
414 ))));
415 }
416 };
417
418 let pad_key = chunk[0];
419 let padded = chunk.iter().copied().chain(repeat_n(pad_key, bucket - chunk.len()));
420 let mut rows = stmt
421 .query(params_from_iter(padded))
422 .map_err(|e| error!(internal(format!("Failed to query persistent get_many: {}", e))))?;
423
424 while let Some(row) = rows.next().map_err(|e| {
425 error!(internal(format!("Failed to read persistent get_many row: {}", e)))
426 })? {
427 let key_ref = row.get_ref(0).map_err(|e| {
428 error!(internal(format!("Failed to read persistent get_many key: {}", e)))
429 })?;
430 let key = key_ref.as_blob().map_err(|e| {
431 error!(internal(format!("Failed to decode persistent get_many key: {}", e)))
432 })?;
433 let Some(&i) = index.get(key) else {
434 continue;
435 };
436 let version_ref = row.get_ref(1).map_err(|e| {
437 error!(internal(format!("Failed to read persistent get_many version: {}", e)))
438 })?;
439 let version_bytes = version_ref.as_blob().map_err(|e| {
440 error!(internal(format!("Failed to decode persistent get_many version: {}", e)))
441 })?;
442 let stored_version = version_from_bytes(version_bytes);
443 if stored_version > version {
444 continue;
445 }
446 let value: Option<Vec<u8>> = row.get(2).map_err(|e| {
447 error!(internal(format!("Failed to read persistent get_many value: {}", e)))
448 })?;
449 out[i] = match value {
450 Some(v) => VersionedGetResult::Value {
451 value: CowVec::new(v),
452 version: stored_version,
453 },
454 None => VersionedGetResult::Tombstone,
455 };
456 }
457 }
458
459 Ok(out)
460 }
461
462 #[instrument(name = "store::multi::persistent::sqlite::set", level = "debug", skip(self, batches), fields(table_count = batches.len(), version = version.0))]
463 fn set(&self, version: CommitVersion, batches: TierBatch) -> Result<()> {
464 if batches.is_empty() {
465 return Ok(());
466 }
467
468 let conn = self.inner.conn.lock();
469 let tx = conn
470 .unchecked_transaction()
471 .map_err(|e| error!(internal(format!("Failed to start persistent transaction: {}", e))))?;
472
473 let new_version_bytes = version_to_bytes(version);
474
475 for (table, entries) in batches {
476 let table_sql = self.table_sql(table);
477 Self::create_table_if_needed(&tx, &table_sql.create_sql)
478 .map_err(|e| error!(internal(format!("Failed to ensure persistent table: {}", e))))?;
479
480 let mut stmt = tx
481 .prepare_cached(&table_sql.upsert_sql)
482 .map_err(|e| error!(internal(format!("Failed to prepare persistent upsert: {}", e))))?;
483
484 for (key, value) in entries {
485 let key_slice = key.as_slice();
486 let value_slice = value.as_ref().map(|v| v.as_slice());
487 let (created_nanos, updated_nanos) = match &value {
488 Some(v) if v.len() >= 24 => {
489 let row = EncodedRow(v.clone());
490 (row.created_at_nanos() as i64, row.updated_at_nanos() as i64)
491 }
492 _ => (0i64, 0i64),
493 };
494 stmt.execute(params![
495 key_slice,
496 new_version_bytes.as_slice(),
497 value_slice,
498 created_nanos,
499 updated_nanos
500 ])
501 .map_err(|e| error!(internal(format!("Failed to upsert persistent row: {}", e))))?;
502 }
503 }
504
505 tx.commit().map_err(|e| error!(internal(format!("Failed to commit persistent transaction: {}", e))))
506 }
507
508 fn range_next(
509 &self,
510 table: EntryKind,
511 cursor: &mut RangeCursor,
512 start: Bound<&[u8]>,
513 end: Bound<&[u8]>,
514 version: CommitVersion,
515 batch_size: usize,
516 ) -> Result<RangeBatch> {
517 self.range_chunk(
518 cursor,
519 RangeChunkRequest {
520 table,
521 start,
522 end,
523 version,
524 batch_size,
525 descending: false,
526 },
527 )
528 }
529
530 fn range_rev_next(
531 &self,
532 table: EntryKind,
533 cursor: &mut RangeCursor,
534 start: Bound<&[u8]>,
535 end: Bound<&[u8]>,
536 version: CommitVersion,
537 batch_size: usize,
538 ) -> Result<RangeBatch> {
539 self.range_chunk(
540 cursor,
541 RangeChunkRequest {
542 table,
543 start,
544 end,
545 version,
546 batch_size,
547 descending: true,
548 },
549 )
550 }
551
552 fn ensure_table(&self, table: EntryKind) -> Result<()> {
553 let table_sql = self.table_sql(table);
554 let conn = self.inner.conn.lock();
555 Self::create_table_if_needed(&conn, &table_sql.create_sql)
556 .map_err(|e| error!(internal(format!("Failed to ensure persistent table: {}", e))))
557 }
558
559 fn clear_table(&self, table: EntryKind) -> Result<()> {
560 let table_sql = self.table_sql(table);
561 let conn = self.inner.conn.lock();
562 let result = conn.execute(&format!("DELETE FROM \"{}\"", table_sql.table_name), []);
563 if let Err(e) = result
564 && !e.to_string().contains("no such table")
565 {
566 return Err(error!(internal(format!(
567 "Failed to clear persistent {}: {}",
568 table_sql.table_name, e
569 ))));
570 }
571 Ok(())
572 }
573
574 fn drop(&self, _batches: HashMap<EntryKind, Vec<(EncodedKey, CommitVersion)>>) -> Result<()> {
575 panic!("SqlitePersistentStorage::drop: persistent tier has no historical chain to drop versions from");
578 }
579
580 fn get_all_versions(&self, table: EntryKind, key: &[u8]) -> Result<Vec<(CommitVersion, Option<CowVec<u8>>)>> {
581 let table_sql = self.table_sql(table);
584 let conn = self.inner.readers.acquire();
585
586 let result = match conn.prepare_cached(&table_sql.get_sql) {
587 Ok(mut stmt) => stmt.query_row(params![key], |row| {
588 let version_bytes: Vec<u8> = row.get(0)?;
589 let value: Option<Vec<u8>> = row.get(1)?;
590 Ok((version_from_bytes(&version_bytes), value.map(CowVec::new)))
591 }),
592 Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
593 Err(e) => {
594 return Err(error!(internal(format!(
595 "Failed to prepare persistent get_all_versions: {}",
596 e
597 ))));
598 }
599 };
600
601 match result {
602 Ok(row) => Ok(vec![row]),
603 Err(QueryReturnedNoRows) => Ok(Vec::new()),
604 Err(e) if e.to_string().contains("no such table") => Ok(Vec::new()),
605 Err(e) => Err(error!(internal(format!("Failed to read persistent versions: {}", e)))),
606 }
607 }
608
609 fn scan_historical_below(
610 &self,
611 _table: EntryKind,
612 _cutoff: CommitVersion,
613 _cursor: &mut HistoricalCursor,
614 _batch_size: usize,
615 ) -> Result<Vec<(EncodedKey, CommitVersion)>> {
616 panic!("SqlitePersistentStorage::scan_historical_below: persistent tier has no historical chain");
619 }
620}
621
622impl TierBackend for SqlitePersistentStorage {}
623
624#[cfg(test)]
625mod tests {
626 use std::collections::HashMap;
627
628 use reifydb_core::interface::catalog::{id::TableId, shape::ShapeId};
629
630 use super::*;
631
632 fn table() -> EntryKind {
633 EntryKind::Source(ShapeId::Table(TableId(1)))
634 }
635
636 fn key(n: u64) -> EncodedKey {
637 EncodedKey::new(n.to_be_bytes().to_vec())
638 }
639
640 fn row(created_nanos: u64, updated_nanos: u64, payload: &[u8]) -> CowVec<u8> {
641 let mut buf = vec![0u8; 24 + payload.len()];
642 buf[8..16].copy_from_slice(&created_nanos.to_le_bytes());
643 buf[16..24].copy_from_slice(&updated_nanos.to_le_bytes());
644 buf[24..].copy_from_slice(payload);
645 CowVec::new(buf)
646 }
647
648 fn visible(s: &SqlitePersistentStorage, k: &EncodedKey) -> bool {
649 s.get(table(), k.as_slice(), CommitVersion(u64::MAX)).unwrap().value().is_some()
650 }
651
652 #[test]
653 fn delete_expired_created_anchor_removes_only_rows_at_or_below_cutoff() {
654 let (s, _guard) = SqlitePersistentStorage::in_memory();
655 s.set(
656 CommitVersion(1),
657 HashMap::from([(
658 table(),
659 vec![
660 (key(1), Some(row(100, 100, b"a"))),
661 (key(2), Some(row(200, 200, b"b"))),
662 (key(3), Some(row(300, 300, b"c"))),
663 ],
664 )]),
665 )
666 .unwrap();
667 assert_eq!(s.count_current(table()).unwrap(), 3);
668
669 let deleted = s.delete_expired(table(), TtlAnchor::Created, 200, None).unwrap();
670
671 assert_eq!(deleted, 2, "rows created at <= cutoff(200) must be physically deleted");
672 assert_eq!(
673 s.count_current(table()).unwrap(),
674 1,
675 "deletion must reclaim sqlite rows, not tombstone them"
676 );
677 assert!(!visible(&s, &key(1)));
678 assert!(!visible(&s, &key(2)));
679 assert!(visible(&s, &key(3)), "row newer than the TTL cutoff must survive");
680 }
681
682 #[test]
683 fn delete_expired_updated_anchor_keeps_recently_updated_rows() {
684 let (s, _guard) = SqlitePersistentStorage::in_memory();
685 s.set(
686 CommitVersion(1),
687 HashMap::from([(
688 table(),
689 vec![
690 (key(1), Some(row(10, 500, b"created-old-updated-fresh"))),
691 (key(2), Some(row(10, 50, b"created-old-updated-stale"))),
692 ],
693 )]),
694 )
695 .unwrap();
696
697 let deleted = s.delete_expired(table(), TtlAnchor::Updated, 100, None).unwrap();
698
699 assert_eq!(deleted, 1, "Updated anchor must key eviction on updated_nanos, not created_nanos");
700 assert!(
701 visible(&s, &key(1)),
702 "a row updated after the cutoff must NOT be evicted even if created long ago"
703 );
704 assert!(!visible(&s, &key(2)));
705 }
706
707 #[test]
708 fn delete_expired_skips_rows_with_unset_anchor() {
709 let (s, _guard) = SqlitePersistentStorage::in_memory();
710 s.set(
711 CommitVersion(1),
712 HashMap::from([(table(), vec![(key(1), None), (key(2), Some(row(0, 0, b"no-anchor")))])]),
713 )
714 .unwrap();
715 let before = s.count_current(table()).unwrap();
716
717 let deleted = s.delete_expired(table(), TtlAnchor::Created, u64::MAX, None).unwrap();
718
719 assert_eq!(deleted, 0, "rows whose anchor is 0 (tombstones / undatable) must never be mass-deleted");
720 assert_eq!(s.count_current(table()).unwrap(), before);
721 }
722
723 #[test]
724 fn delete_expired_on_missing_table_is_noop() {
725 let (s, _guard) = SqlitePersistentStorage::in_memory();
726 let deleted = s
727 .delete_expired(EntryKind::Source(ShapeId::Table(TableId(999))), TtlAnchor::Created, 100, None)
728 .unwrap();
729 assert_eq!(deleted, 0);
730 }
731
732 #[test]
733 fn delete_expired_with_prefix_only_touches_matching_keys() {
734 let (s, _guard) = SqlitePersistentStorage::in_memory();
735 let left = EncodedKey::new(vec![0x01, 0xAA]);
737 let right = EncodedKey::new(vec![0x02, 0xBB]);
738 s.set(
739 CommitVersion(1),
740 HashMap::from([(
741 table(),
742 vec![(left.clone(), Some(row(10, 10, b"l"))), (right.clone(), Some(row(10, 10, b"r")))],
743 )]),
744 )
745 .unwrap();
746
747 let deleted = s.delete_expired(table(), TtlAnchor::Updated, 100, Some(&[0x01])).unwrap();
748
749 assert_eq!(deleted, 1, "only the 0x01-prefixed (left) row should be deleted");
750 assert!(!visible(&s, &left));
751 assert!(visible(&s, &right), "the 0x02-prefixed (right) row must survive a left-only prefix sweep");
752 }
753}