Skip to main content

reifydb_store_multi/tier/persistent/sqlite/
storage.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::HashMap,
6	iter::repeat_n,
7	ops::Bound,
8	sync::{
9		Arc,
10		atomic::{AtomicU64, AtomicUsize, Ordering},
11	},
12};
13
14use reifydb_codec::{
15	key::encoded::EncodedKey,
16	row::bytes::{SHAPE_HEADER_SIZE, read_updated_at},
17};
18#[cfg(test)]
19use reifydb_core::metrics::scan::ScanCounters;
20use reifydb_core::{
21	common::CommitVersion, error::diagnostic::internal::internal, interface::store::EntryKind,
22	metrics::scan::record_page,
23};
24use reifydb_runtime::{
25	shutdown::Shutdown,
26	sync::{
27		map::Map,
28		mutex::{Mutex, MutexGuard},
29	},
30};
31use reifydb_sqlite::{
32	SqliteConfig, SqliteTempPathGuard,
33	connection::{connect, convert_flags, resolve_db_path},
34	memory::sweep_connection_cache,
35	pragma,
36};
37use reifydb_value::{
38	Result,
39	byte_size::ByteSize,
40	count::Count,
41	error, reifydb_assertions,
42	util::cowvec::CowVec,
43	value::{datetime::DateTime, duration::Duration},
44};
45use rusqlite::{
46	Connection, Error::QueryReturnedNoRows, Result as SqliteResult, Row, ToSql, Transaction, TransactionBehavior,
47	params, params_from_iter,
48};
49use tracing::{instrument, warn};
50
51use crate::{
52	MultiVersionScope,
53	tier::{
54		DisplacedValues, RangeBatch, RangeCursor, RawEntry, TierBackend, TierBatch, TierStorage,
55		VersionedGetResult,
56		persistent::sqlite::{
57			entry::{current_table_name, current_table_name_to_entry},
58			query::{
59				build_chunked_upsert_sql, build_create_current_sql, build_delete_below_version_sql,
60				build_delete_keys_sql, build_expired_keys_sql, build_get_current_sql,
61				build_get_many_current_sql, build_range_consistent_sql, build_range_current_sql,
62				build_reap_tombstones_sql, build_upsert_current_sql, prefix_upper_bound,
63				version_from_bytes, version_to_bytes,
64			},
65		},
66	},
67};
68
69const GET_MANY_CHUNK: usize = 900;
70
71const UPSERT_CHUNK: usize = 100;
72
73const GET_MANY_BUCKETS: [usize; 5] = [1, 8, 64, 512, GET_MANY_CHUNK];
74
75fn bucket_key_count(len: usize) -> usize {
76	for &bucket in GET_MANY_BUCKETS.iter() {
77		if len <= bucket {
78			return bucket;
79		}
80	}
81	GET_MANY_CHUNK
82}
83
84const BUSY_TIMEOUT: Duration = Duration::from_milliseconds_const(200);
85
86#[derive(Clone)]
87pub struct SqlitePersistentStorage {
88	inner: Arc<SqlitePersistentStorageInner>,
89}
90
91struct SqlitePersistentStorageInner {
92	conn: Mutex<Option<Connection>>,
93	readers: ReadPool,
94	table_sql: Map<EntryKind, Arc<TableSql>>,
95	cache_hits: AtomicU64,
96	cache_misses: AtomicU64,
97	reaped_high_water: Map<EntryKind, Arc<AtomicU64>>,
98	resurrections: AtomicU64,
99}
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub struct SqlitePageCacheMetrics {
103	pub used: ByteSize,
104	pub hits: Count,
105	pub misses: Count,
106	pub connections_sampled: Count,
107	pub connections_total: Count,
108}
109
110struct TableSql {
111	table_name: String,
112	get_sql: String,
113	upsert_sql: String,
114	chunked_upsert_sql: String,
115	create_sql: String,
116}
117
118impl TableSql {
119	fn build(table: EntryKind) -> Self {
120		let table_name = current_table_name(table);
121		let get_sql = build_get_current_sql(&table_name);
122		let upsert_sql = build_upsert_current_sql(&table_name);
123		let chunked_upsert_sql = build_chunked_upsert_sql(&table_name, UPSERT_CHUNK);
124		let create_sql = build_create_current_sql(&table_name);
125		Self {
126			table_name,
127			get_sql,
128			upsert_sql,
129			chunked_upsert_sql,
130			create_sql,
131		}
132	}
133}
134
135struct ReadPool {
136	conns: Vec<Mutex<Option<Connection>>>,
137	next: AtomicUsize,
138}
139
140impl ReadPool {
141	fn acquire(&self) -> MutexGuard<'_, Option<Connection>> {
142		let n = self.conns.len();
143		let start = self.next.fetch_add(1, Ordering::Relaxed) % n;
144		for i in 0..n {
145			if let Some(guard) = self.conns[(start + i) % n].try_lock() {
146				return guard;
147			}
148		}
149		self.conns[start].lock()
150	}
151
152	fn shutdown(&self) {
153		for slot in &self.conns {
154			drop(slot.lock().take());
155		}
156	}
157}
158
159impl SqlitePersistentStorage {
160	#[instrument(name = "store::multi::persistent::sqlite::new", level = "debug", skip(config), fields(
161		db_path = ?config.path,
162		page_size = config.page_size.as_ref().map(|size| size.as_bytes()),
163		read_pool_size = config.read_pool_size,
164		journal_mode = config.journal_mode.as_ref().map(|mode| mode.as_str())
165	))]
166	pub fn new(config: SqliteConfig) -> Self {
167		let db_path = resolve_db_path(config.path.clone(), "persistent.db");
168		let flags = convert_flags(&config.flags);
169
170		let conn = connect(&db_path, flags).expect("Failed to connect to persistent database");
171		pragma::apply(&conn, &config).expect("Failed to configure persistent SQLite pragmas");
172		conn.busy_timeout(BUSY_TIMEOUT.to_std()).expect("Failed to set persistent busy timeout");
173
174		let pool_size = config.read_pool_size.max(1) as usize;
175		let mut conns = Vec::with_capacity(pool_size);
176		for _ in 0..pool_size {
177			let reader = connect(&db_path, flags).expect("Failed to open persistent read connection");
178			pragma::apply_read_only(&reader, &config)
179				.expect("Failed to configure persistent read connection");
180			reader.busy_timeout(BUSY_TIMEOUT.to_std()).expect("Failed to set persistent read busy timeout");
181			conns.push(Mutex::new(Some(reader)));
182		}
183
184		Self {
185			inner: Arc::new(SqlitePersistentStorageInner {
186				conn: Mutex::new(Some(conn)),
187				readers: ReadPool {
188					conns,
189					next: AtomicUsize::new(0),
190				},
191				table_sql: Map::new(),
192				cache_hits: AtomicU64::new(0),
193				cache_misses: AtomicU64::new(0),
194				reaped_high_water: Map::new(),
195				resurrections: AtomicU64::new(0),
196			}),
197		}
198	}
199
200	pub fn page_cache_metrics(&self) -> SqlitePageCacheMetrics {
201		let mut used = 0u64;
202		let mut sampled = 0u64;
203		let mut sweep = |conn: &Connection| {
204			let swept = sweep_connection_cache(conn);
205			self.inner.cache_hits.fetch_add(swept.hits.as_u64(), Ordering::Relaxed);
206			self.inner.cache_misses.fetch_add(swept.misses.as_u64(), Ordering::Relaxed);
207			used += swept.used.as_bytes();
208			sampled += 1;
209		};
210		if let Some(guard) = self.inner.conn.try_lock()
211			&& let Some(conn) = guard.as_ref()
212		{
213			sweep(conn);
214		}
215		for slot in &self.inner.readers.conns {
216			if let Some(guard) = slot.try_lock()
217				&& let Some(conn) = guard.as_ref()
218			{
219				sweep(conn);
220			}
221		}
222		SqlitePageCacheMetrics {
223			used: ByteSize::from_bytes(used),
224			hits: Count::new(self.inner.cache_hits.load(Ordering::Relaxed)),
225			misses: Count::new(self.inner.cache_misses.load(Ordering::Relaxed)),
226			connections_sampled: Count::new(sampled),
227			connections_total: Count::new(1 + self.inner.readers.conns.len() as u64),
228		}
229	}
230
231	#[instrument(name = "store::multi::sqlite::conn_acquire", level = "debug", skip(self))]
232	fn lock_conn(&self) -> MutexGuard<'_, Option<Connection>> {
233		self.inner.conn.lock()
234	}
235
236	pub fn set_checkpoint_threshold(&self, frames: u32) {
237		let guard = self.lock_conn();
238		if let Some(conn) = guard.as_ref()
239			&& let Err(e) = conn.pragma_update(None, "wal_autocheckpoint", frames)
240		{
241			warn!(error = %e, "failed to update wal_autocheckpoint pragma");
242		}
243	}
244
245	pub fn in_memory() -> (Self, SqliteTempPathGuard) {
246		let (config, guard) = SqliteConfig::in_memory();
247		(Self::new(config), guard)
248	}
249
250	fn table_sql(&self, table: EntryKind) -> Arc<TableSql> {
251		self.inner.table_sql.get_or_insert_with(table, || Arc::new(TableSql::build(table)))
252	}
253
254	pub fn count_current(&self, table: EntryKind) -> Result<u64> {
255		let table_sql = self.table_sql(table);
256		let guard = self.inner.readers.acquire();
257		let Some(conn) = guard.as_ref() else {
258			return Ok(0);
259		};
260		let sql = format!("SELECT COUNT(*) FROM \"{}\"", table_sql.table_name);
261		match conn.query_row(&sql, [], |row| row.get::<_, i64>(0)) {
262			Ok(c) => Ok(c as u64),
263			Err(e) if e.to_string().contains("no such table") => Ok(0),
264			Err(e) => Err(error!(internal(format!("Failed to count persistent current: {}", e)))),
265		}
266	}
267
268	pub fn delete_below_version(
269		&self,
270		table: EntryKind,
271		cutoff_version: CommitVersion,
272		prefix: Option<&[u8]>,
273		cursor: Option<&[u8]>,
274		limit: usize,
275	) -> Result<(Vec<EncodedKey>, Option<EncodedKey>)> {
276		if limit == 0 {
277			return Ok((Vec::new(), None));
278		}
279		let limit = limit.min(i64::MAX as usize);
280		let table_sql = self.table_sql(table);
281		let sql = build_delete_below_version_sql(
282			&table_sql.table_name,
283			prefix.is_some(),
284			cursor.is_some(),
285			limit,
286		);
287		let cutoff = version_to_bytes(cutoff_version);
288		let upper = prefix.map(prefix_upper_bound);
289		let guard = self.lock_conn();
290		let Some(conn) = guard.as_ref() else {
291			return Ok((Vec::new(), None));
292		};
293		let mut stmt = match conn.prepare_cached(&sql) {
294			Ok(stmt) => stmt,
295			Err(e) if e.to_string().contains("no such table") => return Ok((Vec::new(), None)),
296			Err(e) => {
297				return Err(error!(internal(format!(
298					"Failed to prepare delete expired for {}: {}",
299					table_sql.table_name, e
300				))));
301			}
302		};
303		let mut binds: Vec<&[u8]> = Vec::with_capacity(4);
304		binds.push(cutoff.as_slice());
305		if let Some(prefix) = prefix {
306			binds.push(prefix);
307			binds.push(upper.as_deref().expect("upper bound is present when a prefix is present"));
308		}
309		if let Some(cursor) = cursor {
310			binds.push(cursor);
311		}
312		let map_key = |row: &Row| row.get::<_, Vec<u8>>(0);
313		let rows = match stmt.query_map(params_from_iter(binds), map_key) {
314			Ok(rows) => rows,
315			Err(e) if e.to_string().contains("no such table") => return Ok((Vec::new(), None)),
316			Err(e) => {
317				return Err(error!(internal(format!(
318					"Failed to delete expired persistent rows from {}: {}",
319					table_sql.table_name, e
320				))));
321			}
322		};
323		let mut deleted = Vec::new();
324		for row in rows {
325			match row {
326				Ok(key) => deleted.push(EncodedKey::new(key)),
327				Err(e) => {
328					return Err(error!(internal(format!(
329						"Failed to read deleted key from {}: {}",
330						table_sql.table_name, e
331					))));
332				}
333			}
334		}
335		let next_cursor = if deleted.len() == limit {
336			deleted.iter().max().cloned()
337		} else {
338			None
339		};
340		Ok((deleted, next_cursor))
341	}
342
343	pub fn delete_keys(&self, table: EntryKind, keys: &[EncodedKey]) -> Result<u64> {
344		if keys.is_empty() {
345			return Ok(0);
346		}
347		let table_sql = self.table_sql(table);
348		let guard = self.lock_conn();
349		let Some(conn) = guard.as_ref() else {
350			return Ok(0);
351		};
352		let mut total = 0u64;
353		for chunk in keys.chunks(GET_MANY_CHUNK) {
354			let sql = build_delete_keys_sql(&table_sql.table_name, chunk.len());
355			match conn.execute(&sql, params_from_iter(chunk.iter().map(|k| k.as_slice()))) {
356				Ok(n) => total += n as u64,
357				Err(e) if e.to_string().contains("no such table") => return Ok(total),
358				Err(e) => {
359					return Err(error!(internal(format!(
360						"Failed to delete keys from {}: {}",
361						table_sql.table_name, e
362					))));
363				}
364			}
365		}
366		Ok(total)
367	}
368
369	pub fn list_current_entries(&self) -> Result<Vec<EntryKind>> {
370		let guard = self.lock_conn();
371		let Some(conn) = guard.as_ref() else {
372			return Ok(Vec::new());
373		};
374		let mut stmt = conn
375			.prepare_cached(
376				"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
377			)
378			.map_err(|e| error!(internal(format!("Failed to prepare table listing: {}", e))))?;
379		let names = stmt
380			.query_map([], |row| row.get::<_, String>(0))
381			.map_err(|e| error!(internal(format!("Failed to list current tables: {}", e))))?;
382		let mut out = Vec::new();
383		for name in names {
384			let name = name.map_err(|e| error!(internal(format!("Failed to read table name: {}", e))))?;
385			if let Some(kind) = current_table_name_to_entry(&name) {
386				out.push(kind);
387			}
388		}
389		Ok(out)
390	}
391
392	pub fn reap_tombstones(
393		&self,
394		kind: EntryKind,
395		cutoff_version: CommitVersion,
396		limit: usize,
397	) -> Result<(u64, bool)> {
398		if limit == 0 {
399			return Ok((0, false));
400		}
401		let limit = limit.min(i64::MAX as usize);
402		let table_name = &current_table_name(kind);
403		let sql = build_reap_tombstones_sql(table_name, limit);
404		let cutoff = version_to_bytes(cutoff_version);
405		let guard = self.lock_conn();
406		let Some(conn) = guard.as_ref() else {
407			return Ok((0, false));
408		};
409		let reaped = match conn.execute(&sql, params![cutoff.as_slice()]) {
410			Ok(n) => n as u64,
411			Err(e) if e.to_string().contains("no such table") => return Ok((0, false)),
412			Err(e) => {
413				return Err(error!(internal(format!(
414					"Failed to reap tombstones from {}: {}",
415					table_name, e
416				))));
417			}
418		};
419		self.inner
420			.reaped_high_water
421			.get_or_insert_with(kind, || Arc::new(AtomicU64::new(0)))
422			.fetch_max(cutoff_version.0, Ordering::Relaxed);
423
424		Ok((reaped, reaped == limit as u64))
425	}
426
427	pub fn expired_keys(
428		&self,
429		table: EntryKind,
430		cutoff: DateTime,
431		cursor: Option<(DateTime, &[u8])>,
432		limit: usize,
433	) -> Result<Vec<(EncodedKey, DateTime)>> {
434		if limit == 0 {
435			return Ok(Vec::new());
436		}
437		let table_sql = self.table_sql(table);
438		let sql = build_expired_keys_sql(&table_sql.table_name, cursor.is_some(), limit.min(i64::MAX as usize));
439		let guard = self.inner.readers.acquire();
440		let Some(conn) = guard.as_ref() else {
441			return Ok(Vec::new());
442		};
443		let mut stmt = match conn.prepare_cached(&sql) {
444			Ok(stmt) => stmt,
445			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
446			Err(e) => {
447				return Err(error!(internal(format!(
448					"Failed to prepare expired keys for {}: {}",
449					table_sql.table_name, e
450				))));
451			}
452		};
453		let mut params: Vec<Box<dyn ToSql>> = vec![Box::new(cutoff.to_nanos() as i64)];
454		if let Some((at, key)) = cursor {
455			params.push(Box::new(at.to_nanos() as i64));
456			params.push(Box::new(key.to_vec()));
457		}
458		let rows = match stmt.query_map(params_from_iter(params), |row| {
459			let key: Vec<u8> = row.get(0)?;
460			let nanos: i64 = row.get(1)?;
461			Ok((EncodedKey::new(key), DateTime::from_nanos(nanos as u64)))
462		}) {
463			Ok(rows) => rows,
464			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
465			Err(e) => {
466				return Err(error!(internal(format!(
467					"Failed to scan expired keys from {}: {}",
468					table_sql.table_name, e
469				))));
470			}
471		};
472		let mut out = Vec::new();
473		for row in rows {
474			out.push(row.map_err(|e| {
475				error!(internal(format!(
476					"Failed to read expired key from {}: {}",
477					table_sql.table_name, e
478				)))
479			})?);
480		}
481		Ok(out)
482	}
483
484	pub fn resurrections(&self) -> u64 {
485		self.inner.resurrections.load(Ordering::Relaxed)
486	}
487
488	#[cfg(reifydb_assertions)]
489	fn assert_no_resurrection(
490		&self,
491		tx: &Transaction,
492		kind: EntryKind,
493		table_sql: &TableSql,
494		key: &EncodedKey,
495		version: CommitVersion,
496	) {
497		let Some(high_water) = self.inner.reaped_high_water.get(&kind) else {
498			return;
499		};
500		let high_water = high_water.load(Ordering::Relaxed);
501		if version.0 > high_water {
502			return;
503		}
504		let exists = tx
505			.prepare_cached(&table_sql.get_sql)
506			.and_then(|mut check| check.exists(params![key.as_slice()]))
507			.unwrap_or(true);
508		if !exists {
509			self.inner.resurrections.fetch_add(1, Ordering::Relaxed);
510		}
511		assert!(
512			exists,
513			"resurrection: flush inserted an absent key into {} at version {} at or below that entry's own \
514			 reaped-tombstone high-water {}; every version <= a reap cutoff was already durable for that \
515			 entry when the reap ran (TombstoneReap floors on the per-kind flush watermark), so this write \
516			 can only rematerialize a reaped removal - the floor contract or flush monotonicity is broken \
517			 (key={:?})",
518			table_sql.table_name,
519			version.0,
520			high_water,
521			key.as_slice()
522		);
523	}
524
525	fn upsert_entries_collecting_accepted(
526		&self,
527		tx: &Transaction,
528		table: EntryKind,
529		table_sql: &TableSql,
530		version: CommitVersion,
531		entries: &[(EncodedKey, Option<CowVec<u8>>)],
532		accepted: &mut Vec<EncodedKey>,
533	) -> Result<()> {
534		let new_version_bytes = version_to_bytes(version);
535		let mut chunk_stmt = tx
536			.prepare_cached(&table_sql.chunked_upsert_sql)
537			.map_err(|e| error!(internal(format!("Failed to prepare chunked persistent upsert: {}", e))))?;
538		let mut single_stmt = tx
539			.prepare_cached(&table_sql.upsert_sql)
540			.map_err(|e| error!(internal(format!("Failed to prepare persistent upsert: {}", e))))?;
541
542		let mut chunks = entries.chunks_exact(UPSERT_CHUNK);
543		for chunk in chunks.by_ref() {
544			let mut boxed: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() * 4);
545			for (key, value) in chunk {
546				reifydb_assertions! {
547					self.assert_no_resurrection(tx, table, table_sql, key, version);
548				}
549				boxed.push(Box::new(key.as_slice().to_vec()));
550				boxed.push(Box::new(new_version_bytes.to_vec()));
551				boxed.push(Box::new(value.as_ref().map(|v| v.as_slice().to_vec())));
552				boxed.push(Box::new(
553					expiry_stamp(table, value.as_ref()).map(|at| at.to_nanos() as i64),
554				));
555			}
556			let flat: Vec<&dyn ToSql> = boxed.iter().map(|p| p.as_ref()).collect();
557			let returned = chunk_stmt
558				.query_map(params_from_iter(flat), |row| row.get::<_, Vec<u8>>(0))
559				.map_err(|e| error!(internal(format!("Failed to upsert persistent rows: {}", e))))?;
560			for key_bytes in returned {
561				let key_bytes = key_bytes.map_err(|e| {
562					error!(internal(format!("Failed to read accepted persistent key: {}", e)))
563				})?;
564				accepted.push(EncodedKey::new(key_bytes));
565			}
566		}
567
568		for (key, value) in chunks.remainder() {
569			reifydb_assertions! {
570				self.assert_no_resurrection(tx, table, table_sql, key, version);
571			}
572			let value_slice = value.as_ref().map(|v| v.as_slice());
573			let affected = single_stmt
574				.execute(params![
575					key.as_slice(),
576					new_version_bytes.as_slice(),
577					value_slice,
578					expiry_stamp(table, value.as_ref()).map(|at| at.to_nanos() as i64)
579				])
580				.map_err(|e| error!(internal(format!("Failed to upsert persistent row: {}", e))))?;
581			if affected > 0 {
582				accepted.push(key.clone());
583			}
584		}
585
586		Ok(())
587	}
588
589	#[instrument(name = "store::multi::persistent::sqlite::set", level = "debug", skip(self, batches), fields(table_count = batches.len(), version = version.0))]
590	pub fn set_collecting_accepted(&self, version: CommitVersion, batches: TierBatch) -> Result<Vec<EncodedKey>> {
591		let mut accepted = Vec::new();
592		if batches.is_empty() {
593			return Ok(accepted);
594		}
595
596		let guard = self.lock_conn();
597		let Some(conn) = guard.as_ref() else {
598			return Ok(accepted);
599		};
600		let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
601			.map_err(|e| error!(internal(format!("Failed to start persistent transaction: {}", e))))?;
602
603		for (table, entries) in batches {
604			let table_sql = self.table_sql(table);
605			Self::create_table_if_needed(&tx, &table_sql.create_sql)
606				.map_err(|e| error!(internal(format!("Failed to ensure persistent table: {}", e))))?;
607
608			self.upsert_entries_collecting_accepted(
609				&tx,
610				table,
611				&table_sql,
612				version,
613				&entries,
614				&mut accepted,
615			)?;
616		}
617
618		tx.commit().map_err(|e| error!(internal(format!("Failed to commit persistent transaction: {}", e))))?;
619		Ok(accepted)
620	}
621
622	#[instrument(name = "store::multi::persistent::sqlite::persist_sweep", level = "debug", skip(self, batches), fields(batch_count = batches.len()))]
623	pub fn persist_sweep(&self, batches: Vec<(CommitVersion, TierBatch)>) -> Result<Vec<EncodedKey>> {
624		let mut accepted = Vec::new();
625		if batches.iter().all(|(_, batch)| batch.is_empty()) {
626			return Ok(accepted);
627		}
628
629		let guard = self.lock_conn();
630		let Some(conn) = guard.as_ref() else {
631			return Err(error!(internal(
632				"Persistent storage is shut down; refusing to acknowledge a flush sweep whose \
633				 writes would then be dropped from the commit buffer unpersisted"
634					.to_string()
635			)));
636		};
637		let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
638			.map_err(|e| error!(internal(format!("Failed to start persistent transaction: {}", e))))?;
639
640		for (version, batch) in batches {
641			for (table, entries) in batch {
642				let table_sql = self.table_sql(table);
643				Self::create_table_if_needed(&tx, &table_sql.create_sql).map_err(|e| {
644					error!(internal(format!("Failed to ensure persistent table: {}", e)))
645				})?;
646
647				self.upsert_entries_collecting_accepted(
648					&tx,
649					table,
650					&table_sql,
651					version,
652					&entries,
653					&mut accepted,
654				)?;
655			}
656		}
657
658		tx.commit().map_err(|e| error!(internal(format!("Failed to commit persistent transaction: {}", e))))?;
659		Ok(accepted)
660	}
661
662	fn create_table_if_needed(conn: &Connection, create_sql: &str) -> SqliteResult<()> {
663		conn.execute_batch(create_sql)?;
664		Ok(())
665	}
666
667	fn range_chunk(&self, cursor: &mut RangeCursor, req: RangeChunkRequest<'_>) -> Result<RangeBatch> {
668		if cursor.exhausted {
669			return Ok(RangeBatch::empty());
670		}
671
672		let table_sql = self.table_sql(req.table);
673		let guard = self.inner.readers.acquire();
674		let Some(conn) = guard.as_ref() else {
675			cursor.exhausted = true;
676			return Ok(RangeBatch::empty());
677		};
678
679		let sql = build_range_current_sql(
680			&table_sql.table_name,
681			bound_shape(req.start),
682			bound_shape(req.end),
683			cursor.last_key.is_some(),
684			req.descending,
685		);
686
687		let mut stmt = match conn.prepare_cached(&sql) {
688			Ok(s) => s,
689			Err(e) if e.to_string().contains("no such table") => {
690				cursor.exhausted = true;
691				return Ok(RangeBatch::empty());
692			}
693			Err(e) => return Err(error!(internal(format!("Failed to prepare persistent range: {}", e)))),
694		};
695
696		let version_bytes = version_to_bytes(req.scope.read()).to_vec();
697		let limit_i64 = req.batch_size as i64;
698		let mut params: Vec<Box<dyn ToSql>> = Vec::new();
699		match req.start {
700			Bound::Included(s) | Bound::Excluded(s) => params.push(Box::new(s.to_vec())),
701			Bound::Unbounded => {}
702		}
703		match req.end {
704			Bound::Included(e) | Bound::Excluded(e) => params.push(Box::new(e.to_vec())),
705			Bound::Unbounded => {}
706		}
707		if let Some(k) = cursor.last_key.as_deref() {
708			params.push(Box::new(k.to_vec()));
709		}
710		params.push(Box::new(version_bytes));
711		params.push(Box::new(limit_i64));
712
713		let raw: Vec<RawEntry> = match stmt.query_map(params_from_iter(params), |row| {
714			let key: Vec<u8> = row.get(0)?;
715			let version_blob: Vec<u8> = row.get(1)?;
716			let value: Option<Vec<u8>> = row.get(2)?;
717			Ok(RawEntry {
718				key: EncodedKey::new(key),
719				version: version_from_bytes(&version_blob),
720				value: value.map(CowVec::new),
721			})
722		}) {
723			Ok(rows) => rows
724				.collect::<SqliteResult<Vec<_>>>()
725				.map_err(|e| error!(internal(format!("Failed to read persistent row: {}", e))))?,
726			Err(e) if e.to_string().contains("no such table") => {
727				cursor.exhausted = true;
728				return Ok(RangeBatch::empty());
729			}
730			Err(e) => return Err(error!(internal(format!("Failed to scan persistent range: {}", e)))),
731		};
732		let page_was_full = raw.len() >= req.batch_size;
733		let last_scanned = raw.last().map(|e| e.key.clone());
734		record_page(raw.len() as u64, raw.iter().filter(|e| e.value.is_none()).count() as u64);
735		let entries: Vec<RawEntry> = raw.into_iter().filter(|e| req.scope.contains(e.version)).collect();
736
737		if !page_was_full {
738			cursor.exhausted = true;
739		}
740		if let Some(last) = last_scanned {
741			cursor.last_key = Some(last);
742		}
743
744		let has_more = !cursor.exhausted;
745		Ok(RangeBatch {
746			entries,
747			has_more,
748		})
749	}
750
751	#[instrument(name = "store::multi::persistent::sqlite::load_consistent", level = "debug", skip_all, fields(table = ?table))]
752	pub fn load_range_consistent(
753		&self,
754		table: EntryKind,
755		start: Bound<&[u8]>,
756		end: Bound<&[u8]>,
757		read: CommitVersion,
758		limit: Option<usize>,
759	) -> Result<Vec<RawEntry>> {
760		let table_sql = self.table_sql(table);
761		let guard = self.inner.readers.acquire();
762		let Some(conn) = guard.as_ref() else {
763			return Ok(Vec::new());
764		};
765
766		let sql = build_range_consistent_sql(&table_sql.table_name, bound_shape(start), bound_shape(end));
767
768		let mut stmt = match conn.prepare_cached(&sql) {
769			Ok(s) => s,
770			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
771			Err(e) => {
772				return Err(error!(internal(format!(
773					"Failed to prepare persistent consistent range: {}",
774					e
775				))));
776			}
777		};
778
779		let version_bytes = version_to_bytes(read).to_vec();
780		let mut params: Vec<Box<dyn ToSql>> = Vec::new();
781		match start {
782			Bound::Included(s) | Bound::Excluded(s) => params.push(Box::new(s.to_vec())),
783			Bound::Unbounded => {}
784		}
785		match end {
786			Bound::Included(e) | Bound::Excluded(e) => params.push(Box::new(e.to_vec())),
787			Bound::Unbounded => {}
788		}
789		params.push(Box::new(version_bytes));
790
791		let raw: Vec<RawEntry> = match stmt.query_map(params_from_iter(params), |row| {
792			let key: Vec<u8> = row.get(0)?;
793			let version_blob: Vec<u8> = row.get(1)?;
794			let value: Option<Vec<u8>> = row.get(2)?;
795			Ok(RawEntry {
796				key: EncodedKey::new(key),
797				version: version_from_bytes(&version_blob),
798				value: value.map(CowVec::new),
799			})
800		}) {
801			Ok(rows) => {
802				let mut collected = Vec::new();
803				for row in rows {
804					let entry = row.map_err(|e| {
805						error!(internal(format!(
806							"Failed to read persistent consistent row: {}",
807							e
808						)))
809					})?;
810					collected.push(entry);
811					if limit.is_some_and(|l| collected.len() >= l) {
812						break;
813					}
814				}
815				collected
816			}
817			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
818			Err(e) => {
819				return Err(error!(internal(format!(
820					"Failed to scan persistent consistent range: {}",
821					e
822				))));
823			}
824		};
825
826		Ok(raw)
827	}
828}
829
830fn expiry_stamp(table: EntryKind, value: Option<&CowVec<u8>>) -> Option<DateTime> {
831	match (table, value) {
832		(EntryKind::Source(_) | EntryKind::PartitionedSource(_), Some(row))
833			if row.len() >= SHAPE_HEADER_SIZE =>
834		{
835			Some(read_updated_at(row))
836		}
837		_ => None,
838	}
839}
840
841fn bound_shape(b: Bound<&[u8]>) -> Bound<()> {
842	match b {
843		Bound::Included(_) => Bound::Included(()),
844		Bound::Excluded(_) => Bound::Excluded(()),
845		Bound::Unbounded => Bound::Unbounded,
846	}
847}
848
849struct RangeChunkRequest<'a> {
850	table: EntryKind,
851	start: Bound<&'a [u8]>,
852	end: Bound<&'a [u8]>,
853	scope: MultiVersionScope,
854	batch_size: usize,
855	descending: bool,
856}
857
858impl SqlitePersistentStorage {
859	#[instrument(name = "store::multi::persistent::sqlite::get::source", level = "trace", skip(self), fields(key_len = key.len(), version = version.0))]
860	fn get_source(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
861		self.get_impl(table, key, version)
862	}
863
864	#[instrument(name = "store::multi::persistent::sqlite::get::multi", level = "trace", skip(self), fields(key_len = key.len(), version = version.0))]
865	fn get_multi(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
866		self.get_impl(table, key, version)
867	}
868
869	fn get_impl(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
870		let table_sql = self.table_sql(table);
871		let guard = self.inner.readers.acquire();
872		let Some(conn) = guard.as_ref() else {
873			return Ok(VersionedGetResult::NotFound);
874		};
875
876		let result = match conn.prepare_cached(&table_sql.get_sql) {
877			Ok(mut stmt) => stmt.query_row(params![key], |row| {
878				let version_bytes: Vec<u8> = row.get(0)?;
879				let value: Option<Vec<u8>> = row.get(1)?;
880				Ok((version_from_bytes(&version_bytes), value))
881			}),
882			Err(e) if e.to_string().contains("no such table") => Err(QueryReturnedNoRows),
883			Err(e) => return Err(error!(internal(format!("Failed to prepare persistent get: {}", e)))),
884		};
885
886		match result {
887			Ok((stored_version, value)) if stored_version <= version => Ok(match value {
888				Some(v) => VersionedGetResult::Value {
889					value: CowVec::new(v),
890					version: stored_version,
891				},
892				None => VersionedGetResult::Tombstone,
893			}),
894			Ok(_) => Ok(VersionedGetResult::NotFound),
895			Err(QueryReturnedNoRows) => Ok(VersionedGetResult::NotFound),
896			Err(e) if e.to_string().contains("no such table") => Ok(VersionedGetResult::NotFound),
897			Err(e) => Err(error!(internal(format!("Failed to read persistent: {}", e)))),
898		}
899	}
900
901	#[instrument(name = "store::multi::persistent::sqlite::get_many::source", level = "trace", skip(self, keys), fields(key_count = keys.len(), version = version.0))]
902	fn get_many_source(
903		&self,
904		table: EntryKind,
905		keys: &[&[u8]],
906		version: CommitVersion,
907	) -> Result<Vec<VersionedGetResult>> {
908		self.get_many_impl(table, keys, version)
909	}
910
911	#[instrument(name = "store::multi::persistent::sqlite::get_many::multi", level = "trace", skip(self, keys), fields(key_count = keys.len(), version = version.0))]
912	fn get_many_multi(
913		&self,
914		table: EntryKind,
915		keys: &[&[u8]],
916		version: CommitVersion,
917	) -> Result<Vec<VersionedGetResult>> {
918		self.get_many_impl(table, keys, version)
919	}
920
921	fn get_many_impl(
922		&self,
923		table: EntryKind,
924		keys: &[&[u8]],
925		version: CommitVersion,
926	) -> Result<Vec<VersionedGetResult>> {
927		let mut out = vec![VersionedGetResult::NotFound; keys.len()];
928		if keys.is_empty() {
929			return Ok(out);
930		}
931
932		let index: HashMap<&[u8], usize> = keys.iter().enumerate().map(|(i, &k)| (k, i)).collect();
933		let table_sql = self.table_sql(table);
934		let guard = self.inner.readers.acquire();
935		let Some(conn) = guard.as_ref() else {
936			return Ok(out);
937		};
938
939		for chunk in keys.chunks(GET_MANY_CHUNK) {
940			let bucket = bucket_key_count(chunk.len());
941			let sql = build_get_many_current_sql(&table_sql.table_name, bucket);
942			let mut stmt = match conn.prepare_cached(&sql) {
943				Ok(stmt) => stmt,
944				Err(e) if e.to_string().contains("no such table") => return Ok(out),
945				Err(e) => {
946					return Err(error!(internal(format!(
947						"Failed to prepare persistent get_many: {}",
948						e
949					))));
950				}
951			};
952
953			let pad_key = chunk[0];
954			let padded = chunk.iter().copied().chain(repeat_n(pad_key, bucket - chunk.len()));
955			let mut rows = stmt
956				.query(params_from_iter(padded))
957				.map_err(|e| error!(internal(format!("Failed to query persistent get_many: {}", e))))?;
958
959			while let Some(row) = rows.next().map_err(|e| {
960				error!(internal(format!("Failed to read persistent get_many row: {}", e)))
961			})? {
962				let key_ref = row.get_ref(0).map_err(|e| {
963					error!(internal(format!("Failed to read persistent get_many key: {}", e)))
964				})?;
965				let key = key_ref.as_blob().map_err(|e| {
966					error!(internal(format!("Failed to decode persistent get_many key: {}", e)))
967				})?;
968				let Some(&i) = index.get(key) else {
969					continue;
970				};
971				let version_ref = row.get_ref(1).map_err(|e| {
972					error!(internal(format!("Failed to read persistent get_many version: {}", e)))
973				})?;
974				let version_bytes = version_ref.as_blob().map_err(|e| {
975					error!(internal(format!("Failed to decode persistent get_many version: {}", e)))
976				})?;
977				let stored_version = version_from_bytes(version_bytes);
978				if stored_version > version {
979					continue;
980				}
981				let value: Option<Vec<u8>> = row.get(2).map_err(|e| {
982					error!(internal(format!("Failed to read persistent get_many value: {}", e)))
983				})?;
984				out[i] = match value {
985					Some(v) => VersionedGetResult::Value {
986						value: CowVec::new(v),
987						version: stored_version,
988					},
989					None => VersionedGetResult::Tombstone,
990				};
991			}
992		}
993
994		Ok(out)
995	}
996}
997
998impl TierStorage for SqlitePersistentStorage {
999	fn get(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
1000		match table {
1001			EntryKind::Source(_) => self.get_source(table, key, version),
1002			_ => self.get_multi(table, key, version),
1003		}
1004	}
1005
1006	fn get_many(
1007		&self,
1008		table: EntryKind,
1009		keys: &[&[u8]],
1010		version: CommitVersion,
1011	) -> Result<Vec<VersionedGetResult>> {
1012		match table {
1013			EntryKind::Source(_) => self.get_many_source(table, keys, version),
1014			_ => self.get_many_multi(table, keys, version),
1015		}
1016	}
1017
1018	fn set(&self, version: CommitVersion, batches: TierBatch) -> Result<DisplacedValues> {
1019		self.set_collecting_accepted(version, batches)?;
1020		Ok(DisplacedValues::new())
1021	}
1022
1023	#[instrument(name = "store::multi::persistent::sqlite::range", level = "trace", skip(self, cursor, start, end), fields(table = ?table, batch_size = batch_size))]
1024	fn range_next(
1025		&self,
1026		table: EntryKind,
1027		cursor: &mut RangeCursor,
1028		start: Bound<&[u8]>,
1029		end: Bound<&[u8]>,
1030		scope: MultiVersionScope,
1031		batch_size: usize,
1032	) -> Result<RangeBatch> {
1033		self.range_chunk(
1034			cursor,
1035			RangeChunkRequest {
1036				table,
1037				start,
1038				end,
1039				scope,
1040				batch_size,
1041				descending: false,
1042			},
1043		)
1044	}
1045
1046	#[instrument(name = "store::multi::persistent::sqlite::range_rev", level = "trace", skip(self, cursor, start, end), fields(table = ?table, batch_size = batch_size))]
1047	fn range_rev_next(
1048		&self,
1049		table: EntryKind,
1050		cursor: &mut RangeCursor,
1051		start: Bound<&[u8]>,
1052		end: Bound<&[u8]>,
1053		scope: MultiVersionScope,
1054		batch_size: usize,
1055	) -> Result<RangeBatch> {
1056		self.range_chunk(
1057			cursor,
1058			RangeChunkRequest {
1059				table,
1060				start,
1061				end,
1062				scope,
1063				batch_size,
1064				descending: true,
1065			},
1066		)
1067	}
1068
1069	fn ensure_table(&self, table: EntryKind) -> Result<()> {
1070		let table_sql = self.table_sql(table);
1071		let guard = self.inner.conn.lock();
1072		let Some(conn) = guard.as_ref() else {
1073			return Ok(());
1074		};
1075		Self::create_table_if_needed(conn, &table_sql.create_sql)
1076			.map_err(|e| error!(internal(format!("Failed to ensure persistent table: {}", e))))
1077	}
1078
1079	fn clear_table(&self, table: EntryKind) -> Result<()> {
1080		let table_sql = self.table_sql(table);
1081		let guard = self.inner.conn.lock();
1082		let Some(conn) = guard.as_ref() else {
1083			return Ok(());
1084		};
1085		let result = conn.execute(&format!("DELETE FROM \"{}\"", table_sql.table_name), []);
1086		if let Err(e) = result
1087			&& !e.to_string().contains("no such table")
1088		{
1089			return Err(error!(internal(format!(
1090				"Failed to clear persistent {}: {}",
1091				table_sql.table_name, e
1092			))));
1093		}
1094		Ok(())
1095	}
1096}
1097
1098impl TierBackend for SqlitePersistentStorage {}
1099
1100impl Shutdown for SqlitePersistentStorage {
1101	fn shutdown(&self) {
1102		if let Some(conn) = self.inner.conn.lock().take() {
1103			if let Err(e) = pragma::shutdown(&conn) {
1104				warn!(error = %e, "persistent close: pragma shutdown failed");
1105			}
1106			drop(conn);
1107		}
1108		self.inner.readers.shutdown();
1109	}
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114	use std::collections::HashMap;
1115
1116	use reifydb_core::interface::catalog::{id::TableId, storage::StorageId};
1117
1118	use super::*;
1119
1120	fn table() -> EntryKind {
1121		EntryKind::Source(StorageId::Table(TableId(1)))
1122	}
1123
1124	fn key(n: u64) -> EncodedKey {
1125		EncodedKey::new(n.to_be_bytes())
1126	}
1127
1128	fn row(payload: &[u8]) -> CowVec<u8> {
1129		CowVec::new(payload.to_vec())
1130	}
1131
1132	fn stamped(nanos: u64) -> CowVec<u8> {
1133		// A body shorter than a full shape header must never be read as carrying an expiry stamp.
1134		let mut bytes = vec![0u8; SHAPE_HEADER_SIZE];
1135		bytes[16..24].copy_from_slice(&nanos.to_le_bytes());
1136		CowVec::new(bytes)
1137	}
1138
1139	fn at(nanos: u64) -> DateTime {
1140		DateTime::from_nanos(nanos)
1141	}
1142
1143	fn expired_at(s: &SqlitePersistentStorage, kind: EntryKind, cutoff: u64) -> Vec<u64> {
1144		s.expired_keys(kind, at(cutoff), None, 100)
1145			.unwrap()
1146			.into_iter()
1147			.map(|(key, _)| u64::from_be_bytes(key.as_slice().try_into().unwrap()))
1148			.collect()
1149	}
1150
1151	fn reap(s: &SqlitePersistentStorage, keys: &[u64]) -> u64 {
1152		let keys: Vec<EncodedKey> = keys.iter().map(|n| key(*n)).collect();
1153		s.delete_keys(table(), &keys).unwrap()
1154	}
1155
1156	fn stored_keys(s: &SqlitePersistentStorage) -> Vec<u64> {
1157		// Reads the raw table so a physical delete is distinguishable from a tombstone.
1158		let table_name = s.table_sql(table()).table_name.clone();
1159		let guard = s.inner.conn.lock();
1160		let conn = guard.as_ref().expect("write connection is present");
1161		let mut stmt = conn.prepare(&format!("SELECT key FROM \"{}\" ORDER BY key", table_name)).unwrap();
1162		let keys: Vec<u64> = stmt
1163			.query_map([], |row| row.get::<_, Vec<u8>>(0))
1164			.unwrap()
1165			.map(|key| u64::from_be_bytes(key.unwrap().as_slice().try_into().unwrap()))
1166			.collect();
1167		keys
1168	}
1169
1170	fn visible(s: &SqlitePersistentStorage, k: &EncodedKey) -> bool {
1171		s.get(table(), k.as_slice(), CommitVersion(u64::MAX)).unwrap().value().is_some()
1172	}
1173
1174	#[test]
1175	fn a_range_does_not_fetch_the_tombstones_it_would_only_discard() {
1176		// The timer probe asks for one row and used to receive every tombstone in the prefix,
1177		// because LIMIT applies after the WHERE: measured at 1,837 rows fetched per probe, 100%
1178		// of them dead. Filtering in SQL is only sound because `collected_to_batch` drops a None
1179		// value for every scope, and the commit buffer only ever holds versions ABOVE what was
1180		// flushed to persistent - so a persistent tombstone has no lower tier left to shadow.
1181		let (s, _guard) = SqlitePersistentStorage::in_memory();
1182		let mut writes = Vec::new();
1183		for i in 1..=50u64 {
1184			writes.push((key(i), Some(row(b"doomed"))));
1185		}
1186		s.set(CommitVersion(1), HashMap::from([(table(), writes)])).unwrap();
1187		let mut deletes = Vec::new();
1188		for i in 1..=50u64 {
1189			deletes.push((key(i), None));
1190		}
1191		s.set(CommitVersion(2), HashMap::from([(table(), deletes)])).unwrap();
1192		s.set(CommitVersion(3), HashMap::from([(table(), vec![(key(200), Some(row(b"alive")))])])).unwrap();
1193		assert_eq!(s.count_current(table()).unwrap(), 51, "precondition: 50 tombstones are physically present");
1194
1195		let before = ScanCounters::sample();
1196		let mut cursor = RangeCursor::default();
1197		let batch = s
1198			.range_next(
1199				table(),
1200				&mut cursor,
1201				Bound::Unbounded,
1202				Bound::Unbounded,
1203				MultiVersionScope::AsOf {
1204					read: CommitVersion(10),
1205				},
1206				1024,
1207			)
1208			.unwrap();
1209		let scanned = before.since();
1210
1211		assert_eq!(
1212			batch.entries.iter().map(|e| e.key.clone()).collect::<Vec<_>>(),
1213			vec![key(200)],
1214			"a tombstoned key must not surface, and the one live row must"
1215		);
1216		assert_eq!(scanned.fetched, 1, "the 50 tombstones must never cross into Rust");
1217		assert_eq!(scanned.tombstones, 0);
1218	}
1219
1220	#[test]
1221	fn a_page_the_scope_filter_empties_does_not_end_the_scan() {
1222		// The SQL only bounds version <= read, but Between also demands version > after, so the
1223		// surviving-row count is not evidence about whether sqlite has more rows. Deciding
1224		// exhaustion from it stops the scan on the first page that filters out, silently dropping
1225		// every later match; resuming from the last surviving key instead of the last scanned key
1226		// would re-read the filtered rows forever. Keys 1-2 fill a whole page and all fail the
1227		// filter, so a correct cursor must still reach keys 3-4.
1228		let (s, _guard) = SqlitePersistentStorage::in_memory();
1229		s.set(
1230			CommitVersion(1),
1231			HashMap::from([(table(), vec![(key(1), Some(row(b"a"))), (key(2), Some(row(b"b")))])]),
1232		)
1233		.unwrap();
1234		s.set(
1235			CommitVersion(5),
1236			HashMap::from([(table(), vec![(key(3), Some(row(b"c"))), (key(4), Some(row(b"d")))])]),
1237		)
1238		.unwrap();
1239
1240		let scope = MultiVersionScope::Between {
1241			after: CommitVersion(1),
1242			read: CommitVersion(10),
1243		};
1244		let mut cursor = RangeCursor::default();
1245		let mut seen: Vec<EncodedKey> = Vec::new();
1246		loop {
1247			let batch = s
1248				.range_next(table(), &mut cursor, Bound::Unbounded, Bound::Unbounded, scope, 2)
1249				.unwrap();
1250			seen.extend(batch.entries.iter().map(|e| e.key.clone()));
1251			if !batch.has_more {
1252				break;
1253			}
1254		}
1255
1256		assert_eq!(
1257			seen,
1258			vec![key(3), key(4)],
1259			"rows newer than `after` must survive a page that filtered out entirely"
1260		);
1261	}
1262
1263	#[test]
1264	fn page_cache_metrics_accumulates_hits_and_misses_across_sweeps() {
1265		// A sweep drains the per-connection counters take-and-reset into store totals, so the reported
1266		// counts must be monotone; raw per-connection reads would report a sawtooth, not a hit rate.
1267		let (s, _guard) = SqlitePersistentStorage::in_memory();
1268		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
1269		assert!(visible(&s, &key(1)));
1270
1271		let first = s.page_cache_metrics();
1272		assert_eq!(
1273			first.connections_sampled, first.connections_total,
1274			"an idle pool must have every connection sampled"
1275		);
1276		assert!(
1277			first.hits.as_u64() + first.misses.as_u64() > 0,
1278			"writing and reading a row must touch the page cache, got {first:?}"
1279		);
1280		assert!(first.used.as_bytes() > 0, "connections holding pages must report used bytes");
1281
1282		assert!(visible(&s, &key(1)));
1283		let second = s.page_cache_metrics();
1284		assert!(
1285			second.hits.as_u64() >= first.hits.as_u64(),
1286			"hit totals must accumulate, got {} then {}",
1287			first.hits.as_u64(),
1288			second.hits.as_u64()
1289		);
1290		assert!(
1291			second.misses.as_u64() >= first.misses.as_u64(),
1292			"miss totals must accumulate, got {} then {}",
1293			first.misses.as_u64(),
1294			second.misses.as_u64()
1295		);
1296	}
1297
1298	#[test]
1299	fn delete_below_version_removes_rows_at_or_below_cutoff() {
1300		let (s, _guard) = SqlitePersistentStorage::in_memory();
1301		// Each key written at a distinct commit version (separate set calls).
1302		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
1303		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), Some(row(b"b")))])])).unwrap();
1304		s.set(CommitVersion(3), HashMap::from([(table(), vec![(key(3), Some(row(b"c")))])])).unwrap();
1305		assert_eq!(s.count_current(table()).unwrap(), 3);
1306
1307		let (deleted, _) = s.delete_below_version(table(), CommitVersion(2), None, None, usize::MAX).unwrap();
1308
1309		assert_eq!(deleted.len(), 2, "rows whose version is <= cutoff(2) must be physically deleted");
1310		assert_eq!(
1311			s.count_current(table()).unwrap(),
1312			1,
1313			"deletion must reclaim sqlite rows, not tombstone them"
1314		);
1315		assert!(!visible(&s, &key(1)));
1316		assert!(!visible(&s, &key(2)));
1317		assert!(visible(&s, &key(3)), "a row written after the cutoff version must survive");
1318	}
1319
1320	#[test]
1321	fn create_table_indexes_the_version_column() {
1322		// The version-anchored TTL delete needs an index on `version` or GC full-scans the live set on
1323		// every tick; the retired timestamp indices must stay gone.
1324		let (s, _guard) = SqlitePersistentStorage::in_memory();
1325		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
1326
1327		let table_name = s.table_sql(table()).table_name.clone();
1328		let guard = s.inner.conn.lock();
1329		let conn = guard.as_ref().expect("write connection is present");
1330
1331		let indices: Vec<String> = conn
1332			.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?1")
1333			.unwrap()
1334			.query_map([table_name.as_str()], |r| r.get::<_, String>(0))
1335			.unwrap()
1336			.map(|r| r.unwrap())
1337			.collect();
1338
1339		assert!(
1340			indices.contains(&format!("{table_name}__version")),
1341			"the version column must be indexed so the TTL delete seeks instead of scanning, got {indices:?}"
1342		);
1343		assert!(
1344			!indices.iter().any(|n| n.ends_with("__created_nanos") || n.ends_with("__updated_nanos")),
1345			"the dropped timestamp indices must not be recreated, got {indices:?}"
1346		);
1347	}
1348
1349	#[test]
1350	fn delete_below_version_keeps_rows_written_after_the_cutoff() {
1351		let (s, _guard) = SqlitePersistentStorage::in_memory();
1352		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), Some(row(b"stale")))])])).unwrap();
1353		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), Some(row(b"fresh")))])])).unwrap();
1354
1355		let (deleted, _) = s.delete_below_version(table(), CommitVersion(3), None, None, usize::MAX).unwrap();
1356
1357		assert_eq!(deleted.len(), 1, "only the row whose last write is at or below the cutoff is evicted");
1358		assert!(visible(&s, &key(1)), "a row written after the cutoff version must NOT be evicted");
1359		assert!(!visible(&s, &key(2)));
1360	}
1361
1362	#[test]
1363	fn delete_below_version_boundary_is_inclusive() {
1364		let (s, _guard) = SqlitePersistentStorage::in_memory();
1365		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), Some(row(b"v5")))])])).unwrap();
1366
1367		let (deleted, _) = s.delete_below_version(table(), CommitVersion(5), None, None, usize::MAX).unwrap();
1368		assert_eq!(
1369			deleted.len(),
1370			1,
1371			"a row whose version equals the cutoff is evicted (the bound is inclusive)"
1372		);
1373		assert!(!visible(&s, &key(1)));
1374	}
1375
1376	#[test]
1377	fn delete_below_version_on_missing_table_is_noop() {
1378		let (s, _guard) = SqlitePersistentStorage::in_memory();
1379		let (deleted, _) = s
1380			.delete_below_version(
1381				EntryKind::Source(StorageId::Table(TableId(999))),
1382				CommitVersion(100),
1383				None,
1384				None,
1385				usize::MAX,
1386			)
1387			.unwrap();
1388		assert_eq!(deleted.len(), 0);
1389	}
1390
1391	#[test]
1392	fn delete_below_version_with_prefix_only_touches_matching_keys() {
1393		let (s, _guard) = SqlitePersistentStorage::in_memory();
1394		let left = EncodedKey::new(vec![0x01, 0xAA]);
1395		let right = EncodedKey::new(vec![0x02, 0xBB]);
1396		s.set(
1397			CommitVersion(1),
1398			HashMap::from([(
1399				table(),
1400				vec![(left.clone(), Some(row(b"l"))), (right.clone(), Some(row(b"r")))],
1401			)]),
1402		)
1403		.unwrap();
1404
1405		let (deleted, _) =
1406			s.delete_below_version(table(), CommitVersion(2), Some(&[0x01]), None, usize::MAX).unwrap();
1407
1408		assert_eq!(deleted.len(), 1, "only the 0x01-prefixed (left) row should be deleted");
1409		assert!(!visible(&s, &left));
1410		assert!(visible(&s, &right), "the 0x02-prefixed (right) row must survive a left-only prefix sweep");
1411	}
1412
1413	#[test]
1414	fn delete_below_version_returns_exactly_the_deleted_keys() {
1415		// GC invalidates the read cache per-key from this return value, so a wrong or empty key set
1416		// silently leaves stale entries or over-clears the cache.
1417		let (s, _guard) = SqlitePersistentStorage::in_memory();
1418		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
1419		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), Some(row(b"b")))])])).unwrap();
1420		s.set(CommitVersion(3), HashMap::from([(table(), vec![(key(3), Some(row(b"c")))])])).unwrap();
1421
1422		let mut got: Vec<Vec<u8>> = s
1423			.delete_below_version(table(), CommitVersion(2), None, None, usize::MAX)
1424			.unwrap()
1425			.0
1426			.iter()
1427			.map(|k| k.to_vec())
1428			.collect();
1429		got.sort();
1430		let mut want = vec![key(1).to_vec(), key(2).to_vec()];
1431		want.sort();
1432		assert_eq!(
1433			got, want,
1434			"delete_below_version must return every key it physically deleted, and only those"
1435		);
1436		assert!(visible(&s, &key(3)), "the row newer than the cutoff must neither be deleted nor returned");
1437	}
1438
1439	#[test]
1440	fn delete_below_version_caps_one_call_and_reports_a_resume_cursor() {
1441		// One call deletes at most `limit` rows and hands back a cursor, so the sole write connection is
1442		// never held for an unbounded delete.
1443		let (s, _guard) = SqlitePersistentStorage::in_memory();
1444		for n in 1..=5u64 {
1445			s.set(CommitVersion(n), HashMap::from([(table(), vec![(key(n), Some(row(b"x")))])])).unwrap();
1446		}
1447		assert_eq!(s.count_current(table()).unwrap(), 5);
1448
1449		let (deleted, cursor) = s.delete_below_version(table(), CommitVersion(5), None, None, 2).unwrap();
1450
1451		assert_eq!(deleted.len(), 2, "a limit of 2 must delete exactly two rows in one call");
1452		assert_eq!(s.count_current(table()).unwrap(), 3, "only the two capped rows may be physically gone");
1453		assert_eq!(
1454			cursor,
1455			Some(key(2)),
1456			"hitting the cap must return the largest deleted key so the next slice resumes above it"
1457		);
1458		assert!(!visible(&s, &key(1)));
1459		assert!(!visible(&s, &key(2)));
1460		assert!(visible(&s, &key(3)), "the first uncapped key must still be present");
1461	}
1462
1463	#[test]
1464	fn delete_below_version_resumes_from_cursor_and_drains_without_gaps() {
1465		// Threading the returned cursor must walk the eligible set exactly once in key order, never
1466		// skipping a key between batches.
1467		let (s, _guard) = SqlitePersistentStorage::in_memory();
1468		for n in 1..=5u64 {
1469			s.set(CommitVersion(n), HashMap::from([(table(), vec![(key(n), Some(row(b"x")))])])).unwrap();
1470		}
1471
1472		let mut cursor = None;
1473		let mut all: Vec<Vec<u8>> = Vec::new();
1474		let mut calls = 0;
1475		loop {
1476			let (deleted, next) =
1477				s.delete_below_version(table(), CommitVersion(5), None, cursor.as_deref(), 2).unwrap();
1478			calls += 1;
1479			all.extend(deleted.iter().map(|k| k.to_vec()));
1480			match next {
1481				Some(k) => cursor = Some(k.to_vec()),
1482				None => break,
1483			}
1484		}
1485
1486		let mut want = (1..=5u64).map(|n| key(n).to_vec()).collect::<Vec<_>>();
1487		want.sort();
1488		all.sort();
1489		assert_eq!(all, want, "resuming from the cursor must delete every eligible key exactly once, no gaps");
1490		assert_eq!(calls, 3, "5 rows at limit 2 must drain in ceil(5/2) = 3 calls (2 + 2 + 1)");
1491		assert_eq!(s.count_current(table()).unwrap(), 0);
1492	}
1493
1494	#[test]
1495	fn reap_then_flush_of_newer_versions_records_no_resurrection() {
1496		// A reap floors on the flush watermark, so every later flush carries a higher version; a
1497		// re-insert of the reaped key is a legitimate fresh write and must not trip the tripwire.
1498		let (s, _guard) = SqlitePersistentStorage::in_memory();
1499		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(1), None)])])).unwrap();
1500		s.reap_tombstones(table(), CommitVersion(2), 100).unwrap();
1501
1502		s.set(
1503			CommitVersion(3),
1504			HashMap::from([(table(), vec![(key(1), Some(row(b"back"))), (key(2), Some(row(b"new")))])]),
1505		)
1506		.unwrap();
1507
1508		assert_eq!(
1509			s.resurrections(),
1510			0,
1511			"writes above the reap high-water are ordinary flushes; counting them would make the tripwire \
1512			 fire on every healthy re-insert"
1513		);
1514		assert!(visible(&s, &key(1)), "a fresh write after a reaped removal must land");
1515	}
1516
1517	#[test]
1518	fn a_reap_high_water_does_not_carry_across_entry_kinds() {
1519		// An entry with nothing pending reaps ahead of one still buffering, so a cutoff must never be charged
1520		// to another entry's first-time insert.
1521		let other = EntryKind::Source(StorageId::Table(TableId(2)));
1522		let (s, _guard) = SqlitePersistentStorage::in_memory();
1523		s.set(CommitVersion(5), HashMap::from([(other, vec![(key(1), None)])])).unwrap();
1524		s.reap_tombstones(other, CommitVersion(5), 100).unwrap();
1525
1526		s.set(CommitVersion(4), HashMap::from([(table(), vec![(key(9), Some(row(b"fresh")))])])).unwrap();
1527
1528		assert_eq!(
1529			s.resurrections(),
1530			0,
1531			"a first-ever insert into an unreaped entry is absent by definition; charging it against \
1532			 another entry's cutoff makes the tripwire fire on healthy writes"
1533		);
1534		assert!(visible(&s, &key(9)), "the fresh row must land");
1535	}
1536
1537	#[test]
1538	fn a_chunked_upsert_batch_reports_exactly_the_keys_that_won_their_cas() {
1539		// a key marked accepted despite losing its CAS is evicted from memory while never persisted
1540		let (s, _guard) = SqlitePersistentStorage::in_memory();
1541
1542		let evens: Vec<_> = (0..170u64).step_by(2).map(|i| (key(i), Some(row(b"seed-even")))).collect();
1543		let odds: Vec<_> = (1..170u64).step_by(2).map(|i| (key(i), Some(row(b"seed-odd")))).collect();
1544		s.set_collecting_accepted(CommitVersion(200), HashMap::from([(table(), evens)])).unwrap();
1545		s.set_collecting_accepted(CommitVersion(50), HashMap::from([(table(), odds)])).unwrap();
1546
1547		let attempt: Vec<_> = (0..170u64).map(|i| (key(i), Some(row(b"attempt")))).collect();
1548		let accepted =
1549			s.set_collecting_accepted(CommitVersion(150), HashMap::from([(table(), attempt)])).unwrap();
1550
1551		let mut accepted_ids: Vec<u64> =
1552			accepted.iter().map(|k| u64::from_be_bytes(k.as_slice().try_into().unwrap())).collect();
1553		accepted_ids.sort();
1554		let expected_odds: Vec<u64> = (1..170u64).step_by(2).collect();
1555		assert_eq!(
1556			accepted_ids, expected_odds,
1557			"only the keys whose stored version (50) lost to this batch's version (150) may be reported \
1558			 accepted"
1559		);
1560
1561		for i in (0..170u64).step_by(2) {
1562			let value = s.get(table(), key(i).as_slice(), CommitVersion(u64::MAX)).unwrap().value();
1563			assert_eq!(
1564				value.as_ref().map(|v| v.as_slice()),
1565				Some(&b"seed-even"[..]),
1566				"key {i} lost its CAS (stored version 200 >= batch version 150) and must be untouched"
1567			);
1568		}
1569		for i in (1..170u64).step_by(2) {
1570			let value = s.get(table(), key(i).as_slice(), CommitVersion(u64::MAX)).unwrap().value();
1571			assert_eq!(
1572				value.as_ref().map(|v| v.as_slice()),
1573				Some(&b"attempt"[..]),
1574				"key {i} won its CAS (stored version 50 < batch version 150) and must carry this batch's \
1575				 value"
1576			);
1577		}
1578	}
1579
1580	#[cfg(reifydb_assertions)]
1581	#[test]
1582	#[should_panic(expected = "resurrection")]
1583	fn flush_below_the_reap_high_water_of_an_absent_key_trips_the_resurrection_assertion() {
1584		// Positive control: silence in the test above could otherwise mean the tripwire is wired to
1585		// nothing. Flushing below the reap high-water is what a reap outrunning the flush watermark does.
1586		let (s, _guard) = SqlitePersistentStorage::in_memory();
1587		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), None)])])).unwrap();
1588		s.reap_tombstones(table(), CommitVersion(5), 100).unwrap();
1589
1590		s.set(CommitVersion(4), HashMap::from([(table(), vec![(key(1), Some(row(b"ghost")))])])).unwrap();
1591	}
1592
1593	#[cfg(reifydb_assertions)]
1594	#[test]
1595	#[should_panic(expected = "resurrection")]
1596	fn sweep_below_the_reap_high_water_of_an_absent_key_trips_the_resurrection_assertion() {
1597		// The sweep flush has its own upsert loop, so the tripwire must exist there independently or a
1598		// sweep-only resurrection passes silently.
1599		let (s, _guard) = SqlitePersistentStorage::in_memory();
1600		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), None)])])).unwrap();
1601		s.reap_tombstones(table(), CommitVersion(5), 100).unwrap();
1602
1603		s.persist_sweep(vec![(
1604			CommitVersion(4),
1605			HashMap::from([(table(), vec![(key(1), Some(row(b"ghost")))])]),
1606		)])
1607		.unwrap();
1608	}
1609
1610	#[test]
1611	fn reap_tombstones_removes_null_valued_rows_and_leaves_live_rows() {
1612		// A tombstone is a row stored with no value; the reaper must physically delete only those, never
1613		// a live row, even one below the cutoff.
1614		let (s, _guard) = SqlitePersistentStorage::in_memory();
1615		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"live")))])])).unwrap();
1616		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), None)])])).unwrap();
1617		s.set(CommitVersion(3), HashMap::from([(table(), vec![(key(3), Some(row(b"live")))])])).unwrap();
1618		assert_eq!(s.count_current(table()).unwrap(), 3, "the tombstone counts as a physical row until reaped");
1619
1620		let (reaped, more) = s.reap_tombstones(table(), CommitVersion(10), 100).unwrap();
1621
1622		assert_eq!(reaped, 1, "only the NULL-valued row is a tombstone");
1623		assert!(!more, "a batch below the limit reports no remaining backlog");
1624		assert_eq!(s.count_current(table()).unwrap(), 2, "the tombstone row must be physically gone");
1625		assert!(visible(&s, &key(1)), "a live row must never be reaped");
1626		assert!(visible(&s, &key(3)), "a live row above the tombstone must never be reaped");
1627	}
1628
1629	#[test]
1630	fn reap_tombstones_respects_the_cutoff() {
1631		// The flush-watermark cutoff exists to stop a tombstone being reaped while its superseding write
1632		// may still be unflushed.
1633		let (s, _guard) = SqlitePersistentStorage::in_memory();
1634		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), None)])])).unwrap();
1635
1636		let (below, _) = s.reap_tombstones(table(), CommitVersion(4), 100).unwrap();
1637		assert_eq!(below, 0, "a tombstone at version 5 must not be reaped under a cutoff of 4");
1638		assert_eq!(s.count_current(table()).unwrap(), 1, "the tombstone must still be present");
1639
1640		let (at, _) = s.reap_tombstones(table(), CommitVersion(5), 100).unwrap();
1641		assert_eq!(at, 1, "the cutoff is inclusive: version 5 is reapable at cutoff 5");
1642		assert_eq!(s.count_current(table()).unwrap(), 0);
1643	}
1644
1645	#[test]
1646	fn reap_tombstones_is_bounded_by_limit_and_reports_more() {
1647		// One reap call may physically delete at most `limit` tombstones so the write connection is never held
1648		// for an unbounded delete; the remaining tombstones are reported as backlog and drain on later calls.
1649		let (s, _guard) = SqlitePersistentStorage::in_memory();
1650		for n in 1..=3u64 {
1651			s.set(CommitVersion(n), HashMap::from([(table(), vec![(key(n), None)])])).unwrap();
1652		}
1653
1654		let (first, more_first) = s.reap_tombstones(table(), CommitVersion(10), 2).unwrap();
1655		assert_eq!(first, 2, "a limit of 2 caps the first call at two tombstones");
1656		assert!(more_first, "hitting the limit must report backlog remaining");
1657
1658		let (second, more_second) = s.reap_tombstones(table(), CommitVersion(10), 2).unwrap();
1659		assert_eq!(second, 1, "the third tombstone drains on the next call");
1660		assert!(!more_second, "a sub-limit batch reports no further backlog");
1661		assert_eq!(s.count_current(table()).unwrap(), 0);
1662	}
1663
1664	#[test]
1665	fn expired_keys_returns_rows_at_or_below_the_cutoff_oldest_first() {
1666		// Eviction drains from the head, so youngest-first would strand the oldest rows forever.
1667		let (s, _guard) = SqlitePersistentStorage::in_memory();
1668		s.set(
1669			CommitVersion(1),
1670			HashMap::from([(
1671				table(),
1672				vec![
1673					(key(1), Some(stamped(300))),
1674					(key(2), Some(stamped(100))),
1675					(key(3), Some(stamped(200))),
1676					(key(4), Some(stamped(500))),
1677				],
1678			)]),
1679		)
1680		.unwrap();
1681
1682		assert_eq!(
1683			expired_at(&s, table(), 300),
1684			vec![2, 3, 1],
1685			"candidates must come back ordered by their own stamp, oldest first, cutoff inclusive"
1686		);
1687		assert_eq!(expired_at(&s, table(), 99), Vec::<u64>::new(), "a cutoff below every stamp yields nothing");
1688	}
1689
1690	#[test]
1691	fn expired_keys_never_returns_a_tombstone() {
1692		// Otherwise the evictor re-deletes a dead key forever and never advances past it.
1693		let (s, _guard) = SqlitePersistentStorage::in_memory();
1694		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(stamped(100)))])])).unwrap();
1695		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(1), None)])])).unwrap();
1696
1697		assert_eq!(
1698			expired_at(&s, table(), 1_000),
1699			Vec::<u64>::new(),
1700			"a valueless row must not surface as an expiry candidate"
1701		);
1702	}
1703
1704	#[test]
1705	fn a_fresh_write_clears_an_earlier_expiry_stamp() {
1706		// Without this the index is unsound under UPDATE: a row rewritten inside its ttl still dies.
1707		let (s, _guard) = SqlitePersistentStorage::in_memory();
1708		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(stamped(100)))])])).unwrap();
1709		assert_eq!(expired_at(&s, table(), 150), vec![1], "precondition: the row starts out expired");
1710
1711		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(1), Some(stamped(900)))])])).unwrap();
1712
1713		assert_eq!(
1714			expired_at(&s, table(), 150),
1715			Vec::<u64>::new(),
1716			"rewriting the row must carry its new stamp into the index, not leave the stale one"
1717		);
1718		assert_eq!(expired_at(&s, table(), 900), vec![1], "and the row expires again against the new stamp");
1719	}
1720
1721	#[test]
1722	fn expired_keys_ignores_entries_whose_rows_carry_no_stamp() {
1723		// Otherwise catalog bytes read as a timestamp hand the evictor arbitrary rows to delete.
1724		let (s, _guard) = SqlitePersistentStorage::in_memory();
1725		s.set(CommitVersion(1), HashMap::from([(EntryKind::Multi, vec![(key(1), Some(stamped(100)))])]))
1726			.unwrap();
1727
1728		assert_eq!(
1729			expired_at(&s, EntryKind::Multi, 1_000),
1730			Vec::<u64>::new(),
1731			"a non-row entry must never produce expiry candidates, whatever its bytes look like"
1732		);
1733	}
1734
1735	#[test]
1736	fn expired_keys_resumes_from_the_cursor_without_gaps_or_repeats() {
1737		// A candidate the evictor cannot remove must never stall every older row behind it.
1738		let (s, _guard) = SqlitePersistentStorage::in_memory();
1739		s.set(
1740			CommitVersion(1),
1741			HashMap::from([(
1742				table(),
1743				vec![
1744					(key(1), Some(stamped(100))),
1745					(key(2), Some(stamped(100))),
1746					(key(3), Some(stamped(200))),
1747				],
1748			)]),
1749		)
1750		.unwrap();
1751
1752		let mut seen = Vec::new();
1753		let mut cursor: Option<(DateTime, EncodedKey)> = None;
1754		loop {
1755			let batch = s
1756				.expired_keys(table(), at(1_000), cursor.as_ref().map(|(a, k)| (*a, k.as_slice())), 1)
1757				.unwrap();
1758			let Some((k, a)) = batch.last().cloned() else {
1759				break;
1760			};
1761			seen.push(u64::from_be_bytes(k.as_slice().try_into().unwrap()));
1762			cursor = Some((a, k));
1763		}
1764
1765		assert_eq!(
1766			seen,
1767			vec![1, 2, 3],
1768			"threading the cursor must walk every candidate exactly once, in order"
1769		);
1770	}
1771
1772	#[test]
1773	fn delete_keys_removes_the_row_outright_leaving_no_tombstone() {
1774		// A reaped row must vanish, not become a tombstone the reaper would then have to clear again.
1775		let (s, _guard) = SqlitePersistentStorage::in_memory();
1776		s.set(
1777			CommitVersion(1),
1778			HashMap::from([(
1779				table(),
1780				vec![
1781					(key(1), Some(stamped(100))),
1782					(key(2), Some(stamped(200))),
1783					(key(3), Some(stamped(500))),
1784				],
1785			)]),
1786		)
1787		.unwrap();
1788
1789		assert_eq!(reap(&s, &[1, 2]), 2, "every named key must be removed");
1790		assert_eq!(stored_keys(&s), vec![3], "a reaped key must leave no row behind, not even a tombstone");
1791		assert_eq!(
1792			expired_at(&s, table(), 1_000),
1793			vec![3],
1794			"a reaped key must not resurface as an expiry candidate"
1795		);
1796	}
1797
1798	#[test]
1799	fn expiry_discovery_uses_the_partial_index() {
1800		// Without the index this full-scans the live set on every batch, the exact cost it removes.
1801		let (s, _guard) = SqlitePersistentStorage::in_memory();
1802		for n in 1..=200u64 {
1803			s.set(CommitVersion(n), HashMap::from([(table(), vec![(key(n), Some(stamped(n)))])])).unwrap();
1804		}
1805		let table_name = s.table_sql(table()).table_name.clone();
1806
1807		let guard = s.inner.conn.lock();
1808		let conn = guard.as_ref().expect("write connection is present");
1809		conn.execute_batch("ANALYZE").unwrap();
1810		let sql = format!("EXPLAIN QUERY PLAN {}", build_expired_keys_sql(&table_name, false, 100));
1811		let details: Vec<String> = conn
1812			.prepare(&sql)
1813			.unwrap()
1814			.query_map([0i64], |r| r.get::<_, String>(3))
1815			.unwrap()
1816			.map(|r| r.unwrap())
1817			.collect();
1818
1819		assert!(
1820			details.iter().any(|d| d.contains(&format!("{table_name}__expiry"))),
1821			"expiry discovery must use the partial expiry index; query plan was {details:?}"
1822		);
1823	}
1824
1825	#[test]
1826	fn tombstone_discovery_uses_the_partial_index() {
1827		// The plain version index would scan the whole live set once most rows are below the cutoff; the
1828		// partial index over valueless rows keeps discovery proportional to the garbage. Asserted through
1829		// the query plan, never by timing.
1830		let (s, _guard) = SqlitePersistentStorage::in_memory();
1831		for n in 1..=200u64 {
1832			s.set(CommitVersion(n), HashMap::from([(table(), vec![(key(n), Some(row(b"live")))])]))
1833				.unwrap();
1834		}
1835		for n in 201..=203u64 {
1836			s.set(CommitVersion(n), HashMap::from([(table(), vec![(key(n), None)])])).unwrap();
1837		}
1838		let table_name = s.table_sql(table()).table_name.clone();
1839
1840		let guard = s.inner.conn.lock();
1841		let conn = guard.as_ref().expect("write connection is present");
1842		conn.execute_batch("ANALYZE").unwrap();
1843		let sql = format!(
1844			"EXPLAIN QUERY PLAN SELECT key FROM \"{0}\" WHERE value IS NULL AND version <= ?1 LIMIT 100",
1845			table_name
1846		);
1847		let zero = [0u8; 8];
1848		let details: Vec<String> = conn
1849			.prepare(&sql)
1850			.unwrap()
1851			.query_map([zero.as_slice()], |r| r.get::<_, String>(3))
1852			.unwrap()
1853			.map(|r| r.unwrap())
1854			.collect();
1855
1856		assert!(
1857			details.iter().any(|d| d.contains(&format!("{table_name}__tombstone"))),
1858			"reap discovery must use the partial tombstone index; query plan was {details:?}"
1859		);
1860	}
1861}