Skip to main content

reifydb_store_multi/tier/persistent/sqlite/
storage.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (c) 2026 ReifyDB
3
4use 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		// TODO: change the TierStorage interface so persistent doesn't have to expose
576
577		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		// TODO: change the TierStorage interface to remove the
582
583		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		// TODO: change the TierStorage interface so persistent doesn't have to expose
617
618		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		// Two "sides" distinguished by a leading prefix byte, both stale by the cutoff.
736		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}