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::{AtomicUsize, Ordering},
11	},
12};
13
14use reifydb_core::{
15	common::CommitVersion, encoded::key::EncodedKey, error::diagnostic::internal::internal,
16	interface::store::EntryKind,
17};
18use reifydb_runtime::{
19	shutdown::Shutdown,
20	sync::{
21		map::Map,
22		mutex::{Mutex, MutexGuard},
23	},
24};
25use reifydb_sqlite::{
26	SqliteConfig, SqliteTempPathGuard,
27	connection::{connect, convert_flags, resolve_db_path},
28	pragma,
29};
30use reifydb_value::{Result, error, util::cowvec::CowVec, value::duration::Duration};
31use rusqlite::{
32	Connection, Error::QueryReturnedNoRows, Result as SqliteResult, Row, ToSql, Transaction, TransactionBehavior,
33	params, params_from_iter,
34};
35use tracing::{instrument, warn};
36
37use crate::{
38	MultiVersionScope,
39	tier::{
40		HistoricalCursor, RangeBatch, RangeCursor, RawEntry, TierBackend, TierBatch, TierStorage,
41		VersionedGetResult,
42		persistent::{
43			CheckpointOutcome,
44			sqlite::{
45				entry::current_table_name,
46				query::{
47					build_create_current_sql, build_delete_below_version_sql,
48					build_delete_keys_sql, build_get_current_sql, build_get_many_current_sql,
49					build_range_consistent_sql, build_range_current_sql, build_upsert_current_sql,
50					prefix_upper_bound, version_from_bytes, version_to_bytes,
51				},
52			},
53		},
54	},
55};
56
57const GET_MANY_CHUNK: usize = 900;
58
59const GET_MANY_BUCKETS: [usize; 5] = [1, 8, 64, 512, GET_MANY_CHUNK];
60
61fn bucket_key_count(len: usize) -> usize {
62	for &bucket in GET_MANY_BUCKETS.iter() {
63		if len <= bucket {
64			return bucket;
65		}
66	}
67	GET_MANY_CHUNK
68}
69
70const BUSY_TIMEOUT: Duration = Duration::from_milliseconds_const(200);
71
72#[derive(Clone)]
73pub struct SqlitePersistentStorage {
74	inner: Arc<SqlitePersistentStorageInner>,
75}
76
77struct SqlitePersistentStorageInner {
78	conn: Mutex<Option<Connection>>,
79	readers: ReadPool,
80	checkpoint_threshold_frames: u32,
81	table_sql: Map<EntryKind, Arc<TableSql>>,
82}
83
84struct TableSql {
85	table_name: String,
86	get_sql: String,
87	upsert_sql: String,
88	create_sql: String,
89}
90
91impl TableSql {
92	fn build(table: EntryKind) -> Self {
93		let table_name = current_table_name(table);
94		let get_sql = build_get_current_sql(&table_name);
95		let upsert_sql = build_upsert_current_sql(&table_name);
96		let create_sql = build_create_current_sql(&table_name);
97		Self {
98			table_name,
99			get_sql,
100			upsert_sql,
101			create_sql,
102		}
103	}
104}
105
106struct ReadPool {
107	conns: Vec<Mutex<Option<Connection>>>,
108	next: AtomicUsize,
109}
110
111impl ReadPool {
112	fn acquire(&self) -> MutexGuard<'_, Option<Connection>> {
113		let n = self.conns.len();
114		let start = self.next.fetch_add(1, Ordering::Relaxed) % n;
115		for i in 0..n {
116			if let Some(guard) = self.conns[(start + i) % n].try_lock() {
117				return guard;
118			}
119		}
120		self.conns[start].lock()
121	}
122
123	fn shutdown(&self) {
124		for slot in &self.conns {
125			drop(slot.lock().take());
126		}
127	}
128}
129
130impl SqlitePersistentStorage {
131	#[instrument(name = "store::multi::persistent::sqlite::new", level = "debug", skip(config), fields(
132		db_path = ?config.path,
133		page_size = config.page_size.as_bytes(),
134		journal_mode = %config.journal_mode.as_str()
135	))]
136	pub fn new(config: SqliteConfig) -> Self {
137		let db_path = resolve_db_path(config.path.clone(), "persistent.db");
138		let flags = convert_flags(&config.flags);
139
140		let conn = connect(&db_path, flags).expect("Failed to connect to persistent database");
141		pragma::apply(&conn, &config).expect("Failed to configure persistent SQLite pragmas");
142		conn.busy_timeout(BUSY_TIMEOUT.to_std()).expect("Failed to set persistent busy timeout");
143
144		let pool_size = config.read_pool_size.max(1) as usize;
145		let mut conns = Vec::with_capacity(pool_size);
146		for _ in 0..pool_size {
147			let reader = connect(&db_path, flags).expect("Failed to open persistent read connection");
148			pragma::apply_read_only(&reader, &config)
149				.expect("Failed to configure persistent read connection");
150			reader.busy_timeout(BUSY_TIMEOUT.to_std()).expect("Failed to set persistent read busy timeout");
151			conns.push(Mutex::new(Some(reader)));
152		}
153
154		Self {
155			inner: Arc::new(SqlitePersistentStorageInner {
156				conn: Mutex::new(Some(conn)),
157				readers: ReadPool {
158					conns,
159					next: AtomicUsize::new(0),
160				},
161				checkpoint_threshold_frames: config.wal_autocheckpoint,
162				table_sql: Map::new(),
163			}),
164		}
165	}
166
167	pub fn maybe_checkpoint(&self) -> Result<CheckpointOutcome> {
168		let guard = self.inner.conn.lock();
169		let Some(conn) = guard.as_ref() else {
170			return Ok(CheckpointOutcome {
171				log_frames: 0,
172				restarted: false,
173			});
174		};
175
176		let mut log_frames: i64 = 0;
177		conn.pragma(None, "wal_checkpoint", "PASSIVE", |row| {
178			log_frames = row.get(1)?;
179			Ok(())
180		})
181		.map_err(|e| error!(internal(format!("Failed to query persistent WAL size: {}", e))))?;
182
183		let log_frames = log_frames.max(0) as u32;
184		if log_frames <= self.inner.checkpoint_threshold_frames {
185			return Ok(CheckpointOutcome {
186				log_frames,
187				restarted: false,
188			});
189		}
190
191		let mut busy: i64 = 1;
192		if let Err(e) = conn.pragma(None, "wal_checkpoint", "RESTART", |row| {
193			busy = row.get(0)?;
194			Ok(())
195		}) {
196			warn!(error = %e, "persistent checkpoint: RESTART failed");
197		}
198
199		Ok(CheckpointOutcome {
200			log_frames,
201			restarted: busy == 0,
202		})
203	}
204
205	pub fn reclaim(&self) -> Result<()> {
206		let guard = self.inner.conn.lock();
207		let Some(conn) = guard.as_ref() else {
208			return Ok(());
209		};
210		pragma::incremental_vacuum(conn)
211			.map_err(|e| error!(internal(format!("Failed to reclaim persistent free pages: {}", e))))?;
212		Ok(())
213	}
214
215	pub fn in_memory() -> (Self, SqliteTempPathGuard) {
216		let (config, guard) = SqliteConfig::in_memory();
217		(Self::new(config), guard)
218	}
219
220	fn table_sql(&self, table: EntryKind) -> Arc<TableSql> {
221		self.inner.table_sql.get_or_insert_with(table, || Arc::new(TableSql::build(table)))
222	}
223
224	pub fn count_current(&self, table: EntryKind) -> Result<u64> {
225		let table_sql = self.table_sql(table);
226		let guard = self.inner.readers.acquire();
227		let Some(conn) = guard.as_ref() else {
228			return Ok(0);
229		};
230		let sql = format!("SELECT COUNT(*) FROM \"{}\"", table_sql.table_name);
231		match conn.query_row(&sql, [], |row| row.get::<_, i64>(0)) {
232			Ok(c) => Ok(c as u64),
233			Err(e) if e.to_string().contains("no such table") => Ok(0),
234			Err(e) => Err(error!(internal(format!("Failed to count persistent current: {}", e)))),
235		}
236	}
237
238	pub fn delete_below_version(
239		&self,
240		table: EntryKind,
241		cutoff_version: CommitVersion,
242		prefix: Option<&[u8]>,
243	) -> Result<Vec<EncodedKey>> {
244		let table_sql = self.table_sql(table);
245		let sql = build_delete_below_version_sql(&table_sql.table_name, prefix.is_some());
246		let cutoff = version_to_bytes(cutoff_version);
247		let guard = self.inner.conn.lock();
248		let Some(conn) = guard.as_ref() else {
249			return Ok(Vec::new());
250		};
251		let mut stmt = match conn.prepare_cached(&sql) {
252			Ok(stmt) => stmt,
253			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
254			Err(e) => {
255				return Err(error!(internal(format!(
256					"Failed to prepare delete expired for {}: {}",
257					table_sql.table_name, e
258				))));
259			}
260		};
261		let map_key = |row: &Row| row.get::<_, Vec<u8>>(0);
262		let rows = match prefix {
263			Some(prefix) => {
264				let upper = prefix_upper_bound(prefix);
265				stmt.query_map(params![cutoff.as_slice(), prefix, upper.as_slice()], map_key)
266			}
267			None => stmt.query_map(params![cutoff.as_slice()], map_key),
268		};
269		let rows = match rows {
270			Ok(rows) => rows,
271			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
272			Err(e) => {
273				return Err(error!(internal(format!(
274					"Failed to delete expired persistent rows from {}: {}",
275					table_sql.table_name, e
276				))));
277			}
278		};
279		let mut deleted = Vec::new();
280		for row in rows {
281			match row {
282				Ok(key) => deleted.push(EncodedKey::new(key)),
283				Err(e) => {
284					return Err(error!(internal(format!(
285						"Failed to read deleted key from {}: {}",
286						table_sql.table_name, e
287					))));
288				}
289			}
290		}
291		Ok(deleted)
292	}
293
294	pub fn delete_keys(&self, table: EntryKind, keys: &[EncodedKey]) -> Result<u64> {
295		if keys.is_empty() {
296			return Ok(0);
297		}
298		let table_sql = self.table_sql(table);
299		let guard = self.inner.conn.lock();
300		let Some(conn) = guard.as_ref() else {
301			return Ok(0);
302		};
303		let mut total = 0u64;
304		for chunk in keys.chunks(GET_MANY_CHUNK) {
305			let sql = build_delete_keys_sql(&table_sql.table_name, chunk.len());
306			match conn.execute(&sql, params_from_iter(chunk.iter().map(|k| k.as_slice()))) {
307				Ok(n) => total += n as u64,
308				Err(e) if e.to_string().contains("no such table") => return Ok(total),
309				Err(e) => {
310					return Err(error!(internal(format!(
311						"Failed to delete keys from {}: {}",
312						table_sql.table_name, e
313					))));
314				}
315			}
316		}
317		Ok(total)
318	}
319
320	#[instrument(name = "store::multi::persistent::sqlite::set", level = "debug", skip(self, batches), fields(table_count = batches.len(), version = version.0))]
321	pub fn set_collecting_accepted(&self, version: CommitVersion, batches: TierBatch) -> Result<Vec<EncodedKey>> {
322		let mut accepted = Vec::new();
323		if batches.is_empty() {
324			return Ok(accepted);
325		}
326
327		let guard = self.inner.conn.lock();
328		let Some(conn) = guard.as_ref() else {
329			return Ok(accepted);
330		};
331		let tx = Transaction::new_unchecked(conn, TransactionBehavior::Immediate)
332			.map_err(|e| error!(internal(format!("Failed to start persistent transaction: {}", e))))?;
333
334		let new_version_bytes = version_to_bytes(version);
335
336		for (table, entries) in batches {
337			let table_sql = self.table_sql(table);
338			Self::create_table_if_needed(&tx, &table_sql.create_sql)
339				.map_err(|e| error!(internal(format!("Failed to ensure persistent table: {}", e))))?;
340
341			let mut stmt = tx
342				.prepare_cached(&table_sql.upsert_sql)
343				.map_err(|e| error!(internal(format!("Failed to prepare persistent upsert: {}", e))))?;
344
345			for (key, value) in entries {
346				let value_slice = value.as_ref().map(|v| v.as_slice());
347				let affected = stmt
348					.execute(params![key.as_slice(), new_version_bytes.as_slice(), value_slice])
349					.map_err(|e| {
350						error!(internal(format!("Failed to upsert persistent row: {}", e)))
351					})?;
352				if affected > 0 {
353					accepted.push(key);
354				}
355			}
356		}
357
358		tx.commit().map_err(|e| error!(internal(format!("Failed to commit persistent transaction: {}", e))))?;
359		Ok(accepted)
360	}
361
362	fn create_table_if_needed(conn: &Connection, create_sql: &str) -> SqliteResult<()> {
363		conn.execute_batch(create_sql)?;
364		Ok(())
365	}
366
367	fn range_chunk(&self, cursor: &mut RangeCursor, req: RangeChunkRequest<'_>) -> Result<RangeBatch> {
368		if cursor.exhausted {
369			return Ok(RangeBatch::empty());
370		}
371
372		let table_sql = self.table_sql(req.table);
373		let guard = self.inner.readers.acquire();
374		let Some(conn) = guard.as_ref() else {
375			cursor.exhausted = true;
376			return Ok(RangeBatch::empty());
377		};
378
379		let sql = build_range_current_sql(
380			&table_sql.table_name,
381			bound_shape(req.start),
382			bound_shape(req.end),
383			cursor.last_key.is_some(),
384			req.descending,
385		);
386
387		let mut stmt = match conn.prepare_cached(&sql) {
388			Ok(s) => s,
389			Err(e) if e.to_string().contains("no such table") => {
390				cursor.exhausted = true;
391				return Ok(RangeBatch::empty());
392			}
393			Err(e) => return Err(error!(internal(format!("Failed to prepare persistent range: {}", e)))),
394		};
395
396		let version_bytes = version_to_bytes(req.scope.read()).to_vec();
397		let limit_i64 = req.batch_size as i64;
398		let mut params: Vec<Box<dyn ToSql>> = Vec::new();
399		match req.start {
400			Bound::Included(s) | Bound::Excluded(s) => params.push(Box::new(s.to_vec())),
401			Bound::Unbounded => {}
402		}
403		match req.end {
404			Bound::Included(e) | Bound::Excluded(e) => params.push(Box::new(e.to_vec())),
405			Bound::Unbounded => {}
406		}
407		if let Some(k) = cursor.last_key.as_deref() {
408			params.push(Box::new(k.to_vec()));
409		}
410		params.push(Box::new(version_bytes));
411		params.push(Box::new(limit_i64));
412
413		let raw: Vec<RawEntry> = match stmt.query_map(params_from_iter(params), |row| {
414			let key: Vec<u8> = row.get(0)?;
415			let version_blob: Vec<u8> = row.get(1)?;
416			let value: Option<Vec<u8>> = row.get(2)?;
417			Ok(RawEntry {
418				key: EncodedKey::new(key),
419				version: version_from_bytes(&version_blob),
420				value: value.map(CowVec::new),
421			})
422		}) {
423			Ok(rows) => rows
424				.collect::<SqliteResult<Vec<_>>>()
425				.map_err(|e| error!(internal(format!("Failed to read persistent row: {}", e))))?,
426			Err(e) if e.to_string().contains("no such table") => {
427				cursor.exhausted = true;
428				return Ok(RangeBatch::empty());
429			}
430			Err(e) => return Err(error!(internal(format!("Failed to scan persistent range: {}", e)))),
431		};
432		let entries: Vec<RawEntry> = raw.into_iter().filter(|e| req.scope.contains(e.version)).collect();
433
434		if entries.len() < req.batch_size {
435			cursor.exhausted = true;
436		}
437		if let Some(last) = entries.last() {
438			cursor.last_key = Some(last.key.clone());
439		}
440
441		let has_more = !cursor.exhausted;
442		Ok(RangeBatch {
443			entries,
444			has_more,
445		})
446	}
447
448	pub fn load_range_consistent(
449		&self,
450		table: EntryKind,
451		start: Bound<&[u8]>,
452		end: Bound<&[u8]>,
453		read: CommitVersion,
454	) -> Result<Vec<RawEntry>> {
455		let table_sql = self.table_sql(table);
456		let guard = self.inner.readers.acquire();
457		let Some(conn) = guard.as_ref() else {
458			return Ok(Vec::new());
459		};
460
461		let sql = build_range_consistent_sql(&table_sql.table_name, bound_shape(start), bound_shape(end));
462
463		let mut stmt = match conn.prepare_cached(&sql) {
464			Ok(s) => s,
465			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
466			Err(e) => {
467				return Err(error!(internal(format!(
468					"Failed to prepare persistent consistent range: {}",
469					e
470				))));
471			}
472		};
473
474		let version_bytes = version_to_bytes(read).to_vec();
475		let mut params: Vec<Box<dyn ToSql>> = Vec::new();
476		match start {
477			Bound::Included(s) | Bound::Excluded(s) => params.push(Box::new(s.to_vec())),
478			Bound::Unbounded => {}
479		}
480		match end {
481			Bound::Included(e) | Bound::Excluded(e) => params.push(Box::new(e.to_vec())),
482			Bound::Unbounded => {}
483		}
484		params.push(Box::new(version_bytes));
485
486		let raw: Vec<RawEntry> = match stmt.query_map(params_from_iter(params), |row| {
487			let key: Vec<u8> = row.get(0)?;
488			let version_blob: Vec<u8> = row.get(1)?;
489			let value: Option<Vec<u8>> = row.get(2)?;
490			Ok(RawEntry {
491				key: EncodedKey::new(key),
492				version: version_from_bytes(&version_blob),
493				value: value.map(CowVec::new),
494			})
495		}) {
496			Ok(rows) => rows.collect::<SqliteResult<Vec<_>>>().map_err(|e| {
497				error!(internal(format!("Failed to read persistent consistent row: {}", e)))
498			})?,
499			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
500			Err(e) => {
501				return Err(error!(internal(format!(
502					"Failed to scan persistent consistent range: {}",
503					e
504				))));
505			}
506		};
507
508		Ok(raw)
509	}
510}
511
512fn bound_shape(b: Bound<&[u8]>) -> Bound<()> {
513	match b {
514		Bound::Included(_) => Bound::Included(()),
515		Bound::Excluded(_) => Bound::Excluded(()),
516		Bound::Unbounded => Bound::Unbounded,
517	}
518}
519
520struct RangeChunkRequest<'a> {
521	table: EntryKind,
522	start: Bound<&'a [u8]>,
523	end: Bound<&'a [u8]>,
524	scope: MultiVersionScope,
525	batch_size: usize,
526	descending: bool,
527}
528
529impl SqlitePersistentStorage {
530	#[instrument(name = "store::multi::persistent::sqlite::get::operator", level = "trace", skip(self), fields(key_len = key.len(), version = version.0))]
531	fn get_operator(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
532		self.get_impl(table, key, version)
533	}
534
535	#[instrument(name = "store::multi::persistent::sqlite::get::source", level = "trace", skip(self), fields(key_len = key.len(), version = version.0))]
536	fn get_source(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
537		self.get_impl(table, key, version)
538	}
539
540	#[instrument(name = "store::multi::persistent::sqlite::get::multi", level = "trace", skip(self), fields(key_len = key.len(), version = version.0))]
541	fn get_multi(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
542		self.get_impl(table, key, version)
543	}
544
545	fn get_impl(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
546		let table_sql = self.table_sql(table);
547		let guard = self.inner.readers.acquire();
548		let Some(conn) = guard.as_ref() else {
549			return Ok(VersionedGetResult::NotFound);
550		};
551
552		let result = match conn.prepare_cached(&table_sql.get_sql) {
553			Ok(mut stmt) => stmt.query_row(params![key], |row| {
554				let version_bytes: Vec<u8> = row.get(0)?;
555				let value: Option<Vec<u8>> = row.get(1)?;
556				Ok((version_from_bytes(&version_bytes), value))
557			}),
558			Err(e) if e.to_string().contains("no such table") => Err(QueryReturnedNoRows),
559			Err(e) => return Err(error!(internal(format!("Failed to prepare persistent get: {}", e)))),
560		};
561
562		match result {
563			Ok((stored_version, value)) if stored_version <= version => Ok(match value {
564				Some(v) => VersionedGetResult::Value {
565					value: CowVec::new(v),
566					version: stored_version,
567				},
568				None => VersionedGetResult::Tombstone,
569			}),
570			Ok(_) => Ok(VersionedGetResult::NotFound),
571			Err(QueryReturnedNoRows) => Ok(VersionedGetResult::NotFound),
572			Err(e) if e.to_string().contains("no such table") => Ok(VersionedGetResult::NotFound),
573			Err(e) => Err(error!(internal(format!("Failed to read persistent: {}", e)))),
574		}
575	}
576
577	#[instrument(name = "store::multi::persistent::sqlite::get_many::operator", level = "trace", skip(self, keys), fields(key_count = keys.len(), version = version.0))]
578	fn get_many_operator(
579		&self,
580		table: EntryKind,
581		keys: &[&[u8]],
582		version: CommitVersion,
583	) -> Result<Vec<VersionedGetResult>> {
584		self.get_many_impl(table, keys, version)
585	}
586
587	#[instrument(name = "store::multi::persistent::sqlite::get_many::source", level = "trace", skip(self, keys), fields(key_count = keys.len(), version = version.0))]
588	fn get_many_source(
589		&self,
590		table: EntryKind,
591		keys: &[&[u8]],
592		version: CommitVersion,
593	) -> Result<Vec<VersionedGetResult>> {
594		self.get_many_impl(table, keys, version)
595	}
596
597	#[instrument(name = "store::multi::persistent::sqlite::get_many::multi", level = "trace", skip(self, keys), fields(key_count = keys.len(), version = version.0))]
598	fn get_many_multi(
599		&self,
600		table: EntryKind,
601		keys: &[&[u8]],
602		version: CommitVersion,
603	) -> Result<Vec<VersionedGetResult>> {
604		self.get_many_impl(table, keys, version)
605	}
606
607	fn get_many_impl(
608		&self,
609		table: EntryKind,
610		keys: &[&[u8]],
611		version: CommitVersion,
612	) -> Result<Vec<VersionedGetResult>> {
613		let mut out = vec![VersionedGetResult::NotFound; keys.len()];
614		if keys.is_empty() {
615			return Ok(out);
616		}
617
618		let index: HashMap<&[u8], usize> = keys.iter().enumerate().map(|(i, &k)| (k, i)).collect();
619		let table_sql = self.table_sql(table);
620		let guard = self.inner.readers.acquire();
621		let Some(conn) = guard.as_ref() else {
622			return Ok(out);
623		};
624
625		for chunk in keys.chunks(GET_MANY_CHUNK) {
626			let bucket = bucket_key_count(chunk.len());
627			let sql = build_get_many_current_sql(&table_sql.table_name, bucket);
628			let mut stmt = match conn.prepare_cached(&sql) {
629				Ok(stmt) => stmt,
630				Err(e) if e.to_string().contains("no such table") => return Ok(out),
631				Err(e) => {
632					return Err(error!(internal(format!(
633						"Failed to prepare persistent get_many: {}",
634						e
635					))));
636				}
637			};
638
639			let pad_key = chunk[0];
640			let padded = chunk.iter().copied().chain(repeat_n(pad_key, bucket - chunk.len()));
641			let mut rows = stmt
642				.query(params_from_iter(padded))
643				.map_err(|e| error!(internal(format!("Failed to query persistent get_many: {}", e))))?;
644
645			while let Some(row) = rows.next().map_err(|e| {
646				error!(internal(format!("Failed to read persistent get_many row: {}", e)))
647			})? {
648				let key_ref = row.get_ref(0).map_err(|e| {
649					error!(internal(format!("Failed to read persistent get_many key: {}", e)))
650				})?;
651				let key = key_ref.as_blob().map_err(|e| {
652					error!(internal(format!("Failed to decode persistent get_many key: {}", e)))
653				})?;
654				let Some(&i) = index.get(key) else {
655					continue;
656				};
657				let version_ref = row.get_ref(1).map_err(|e| {
658					error!(internal(format!("Failed to read persistent get_many version: {}", e)))
659				})?;
660				let version_bytes = version_ref.as_blob().map_err(|e| {
661					error!(internal(format!("Failed to decode persistent get_many version: {}", e)))
662				})?;
663				let stored_version = version_from_bytes(version_bytes);
664				if stored_version > version {
665					continue;
666				}
667				let value: Option<Vec<u8>> = row.get(2).map_err(|e| {
668					error!(internal(format!("Failed to read persistent get_many value: {}", e)))
669				})?;
670				out[i] = match value {
671					Some(v) => VersionedGetResult::Value {
672						value: CowVec::new(v),
673						version: stored_version,
674					},
675					None => VersionedGetResult::Tombstone,
676				};
677			}
678		}
679
680		Ok(out)
681	}
682}
683
684impl TierStorage for SqlitePersistentStorage {
685	fn get(&self, table: EntryKind, key: &[u8], version: CommitVersion) -> Result<VersionedGetResult> {
686		match table {
687			EntryKind::Operator(_) => self.get_operator(table, key, version),
688			EntryKind::Source(_) => self.get_source(table, key, version),
689			_ => self.get_multi(table, key, version),
690		}
691	}
692
693	fn get_many(
694		&self,
695		table: EntryKind,
696		keys: &[&[u8]],
697		version: CommitVersion,
698	) -> Result<Vec<VersionedGetResult>> {
699		match table {
700			EntryKind::Operator(_) => self.get_many_operator(table, keys, version),
701			EntryKind::Source(_) => self.get_many_source(table, keys, version),
702			_ => self.get_many_multi(table, keys, version),
703		}
704	}
705
706	fn set(&self, version: CommitVersion, batches: TierBatch) -> Result<()> {
707		self.set_collecting_accepted(version, batches)?;
708		Ok(())
709	}
710
711	#[instrument(name = "store::multi::persistent::sqlite::range", level = "trace", skip(self, cursor, start, end), fields(table = ?table, batch_size = batch_size))]
712	fn range_next(
713		&self,
714		table: EntryKind,
715		cursor: &mut RangeCursor,
716		start: Bound<&[u8]>,
717		end: Bound<&[u8]>,
718		scope: MultiVersionScope,
719		batch_size: usize,
720	) -> Result<RangeBatch> {
721		self.range_chunk(
722			cursor,
723			RangeChunkRequest {
724				table,
725				start,
726				end,
727				scope,
728				batch_size,
729				descending: false,
730			},
731		)
732	}
733
734	#[instrument(name = "store::multi::persistent::sqlite::range_rev", level = "trace", skip(self, cursor, start, end), fields(table = ?table, batch_size = batch_size))]
735	fn range_rev_next(
736		&self,
737		table: EntryKind,
738		cursor: &mut RangeCursor,
739		start: Bound<&[u8]>,
740		end: Bound<&[u8]>,
741		scope: MultiVersionScope,
742		batch_size: usize,
743	) -> Result<RangeBatch> {
744		self.range_chunk(
745			cursor,
746			RangeChunkRequest {
747				table,
748				start,
749				end,
750				scope,
751				batch_size,
752				descending: true,
753			},
754		)
755	}
756
757	fn ensure_table(&self, table: EntryKind) -> Result<()> {
758		let table_sql = self.table_sql(table);
759		let guard = self.inner.conn.lock();
760		let Some(conn) = guard.as_ref() else {
761			return Ok(());
762		};
763		Self::create_table_if_needed(conn, &table_sql.create_sql)
764			.map_err(|e| error!(internal(format!("Failed to ensure persistent table: {}", e))))
765	}
766
767	fn clear_table(&self, table: EntryKind) -> Result<()> {
768		let table_sql = self.table_sql(table);
769		let guard = self.inner.conn.lock();
770		let Some(conn) = guard.as_ref() else {
771			return Ok(());
772		};
773		let result = conn.execute(&format!("DELETE FROM \"{}\"", table_sql.table_name), []);
774		if let Err(e) = result
775			&& !e.to_string().contains("no such table")
776		{
777			return Err(error!(internal(format!(
778				"Failed to clear persistent {}: {}",
779				table_sql.table_name, e
780			))));
781		}
782		Ok(())
783	}
784
785	fn drop(&self, _batches: HashMap<EntryKind, Vec<(EncodedKey, CommitVersion)>>) -> Result<()> {
786		// TODO: change the TierStorage interface so persistent doesn't have to expose
787
788		panic!("SqlitePersistentStorage::drop: persistent tier has no historical chain to drop versions from");
789	}
790
791	#[instrument(name = "store::multi::persistent::sqlite::get_all_versions", level = "trace", skip(self, key), fields(table = ?table, key_len = key.len()))]
792	fn get_all_versions(&self, table: EntryKind, key: &[u8]) -> Result<Vec<(CommitVersion, Option<CowVec<u8>>)>> {
793		// TODO: change the TierStorage interface to remove the
794
795		let table_sql = self.table_sql(table);
796		let guard = self.inner.readers.acquire();
797		let Some(conn) = guard.as_ref() else {
798			return Ok(Vec::new());
799		};
800
801		let result = match conn.prepare_cached(&table_sql.get_sql) {
802			Ok(mut stmt) => stmt.query_row(params![key], |row| {
803				let version_bytes: Vec<u8> = row.get(0)?;
804				let value: Option<Vec<u8>> = row.get(1)?;
805				Ok((version_from_bytes(&version_bytes), value.map(CowVec::new)))
806			}),
807			Err(e) if e.to_string().contains("no such table") => return Ok(Vec::new()),
808			Err(e) => {
809				return Err(error!(internal(format!(
810					"Failed to prepare persistent get_all_versions: {}",
811					e
812				))));
813			}
814		};
815
816		match result {
817			Ok(row) => Ok(vec![row]),
818			Err(QueryReturnedNoRows) => Ok(Vec::new()),
819			Err(e) if e.to_string().contains("no such table") => Ok(Vec::new()),
820			Err(e) => Err(error!(internal(format!("Failed to read persistent versions: {}", e)))),
821		}
822	}
823
824	fn scan_historical_below(
825		&self,
826		_table: EntryKind,
827		_cutoff: CommitVersion,
828		_cursor: &mut HistoricalCursor,
829		_batch_size: usize,
830	) -> Result<Vec<(EncodedKey, CommitVersion)>> {
831		// TODO: change the TierStorage interface so persistent doesn't have to expose
832
833		panic!("SqlitePersistentStorage::scan_historical_below: persistent tier has no historical chain");
834	}
835}
836
837impl TierBackend for SqlitePersistentStorage {}
838
839impl Shutdown for SqlitePersistentStorage {
840	fn shutdown(&self) {
841		if let Some(conn) = self.inner.conn.lock().take() {
842			if let Err(e) = pragma::shutdown(&conn) {
843				warn!(error = %e, "persistent close: pragma shutdown failed");
844			}
845			drop(conn);
846		}
847		self.inner.readers.shutdown();
848	}
849}
850
851#[cfg(test)]
852mod tests {
853	use std::collections::HashMap;
854
855	use reifydb_core::interface::catalog::{id::TableId, shape::ShapeId};
856
857	use super::*;
858
859	fn table() -> EntryKind {
860		EntryKind::Source(ShapeId::Table(TableId(1)))
861	}
862
863	fn key(n: u64) -> EncodedKey {
864		EncodedKey::new(n.to_be_bytes().to_vec())
865	}
866
867	fn row(payload: &[u8]) -> CowVec<u8> {
868		CowVec::new(payload.to_vec())
869	}
870
871	fn visible(s: &SqlitePersistentStorage, k: &EncodedKey) -> bool {
872		s.get(table(), k.as_slice(), CommitVersion(u64::MAX)).unwrap().value().is_some()
873	}
874
875	#[test]
876	fn delete_below_version_removes_rows_at_or_below_cutoff() {
877		let (s, _guard) = SqlitePersistentStorage::in_memory();
878		// Each key written at a distinct commit version (separate set calls).
879		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
880		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), Some(row(b"b")))])])).unwrap();
881		s.set(CommitVersion(3), HashMap::from([(table(), vec![(key(3), Some(row(b"c")))])])).unwrap();
882		assert_eq!(s.count_current(table()).unwrap(), 3);
883
884		let deleted = s.delete_below_version(table(), CommitVersion(2), None).unwrap();
885
886		assert_eq!(deleted.len(), 2, "rows whose version is <= cutoff(2) must be physically deleted");
887		assert_eq!(
888			s.count_current(table()).unwrap(),
889			1,
890			"deletion must reclaim sqlite rows, not tombstone them"
891		);
892		assert!(!visible(&s, &key(1)));
893		assert!(!visible(&s, &key(2)));
894		assert!(visible(&s, &key(3)), "a row written after the cutoff version must survive");
895	}
896
897	#[test]
898	fn create_table_indexes_the_version_column() {
899		// Phase 2b: after the created_nanos/updated_nanos indices were dropped, the version-anchored TTL
900		// delete (DELETE WHERE version <= cutoff) needs an index on `version`, or GC full-scans the live
901		// set on every tick. The two timestamp indices must stay gone.
902		let (s, _guard) = SqlitePersistentStorage::in_memory();
903		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
904
905		let table_name = s.table_sql(table()).table_name.clone();
906		let guard = s.inner.conn.lock();
907		let conn = guard.as_ref().expect("write connection is present");
908
909		let indices: Vec<String> = conn
910			.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?1")
911			.unwrap()
912			.query_map([table_name.as_str()], |r| r.get::<_, String>(0))
913			.unwrap()
914			.map(|r| r.unwrap())
915			.collect();
916
917		assert!(
918			indices.contains(&format!("{table_name}__version")),
919			"the version column must be indexed so the TTL delete seeks instead of scanning, got {indices:?}"
920		);
921		assert!(
922			!indices.iter().any(|n| n.ends_with("__created_nanos") || n.ends_with("__updated_nanos")),
923			"the dropped timestamp indices must not be recreated, got {indices:?}"
924		);
925	}
926
927	#[test]
928	fn delete_below_version_keeps_rows_written_after_the_cutoff() {
929		let (s, _guard) = SqlitePersistentStorage::in_memory();
930		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), Some(row(b"stale")))])])).unwrap();
931		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), Some(row(b"fresh")))])])).unwrap();
932
933		let deleted = s.delete_below_version(table(), CommitVersion(3), None).unwrap();
934
935		assert_eq!(deleted.len(), 1, "only the row whose last write is at or below the cutoff is evicted");
936		assert!(visible(&s, &key(1)), "a row written after the cutoff version must NOT be evicted");
937		assert!(!visible(&s, &key(2)));
938	}
939
940	#[test]
941	fn delete_below_version_boundary_is_inclusive() {
942		let (s, _guard) = SqlitePersistentStorage::in_memory();
943		s.set(CommitVersion(5), HashMap::from([(table(), vec![(key(1), Some(row(b"v5")))])])).unwrap();
944
945		// Cutoff exactly equal to the row's version: the row IS deleted (version <= cutoff).
946		let deleted = s.delete_below_version(table(), CommitVersion(5), None).unwrap();
947		assert_eq!(
948			deleted.len(),
949			1,
950			"a row whose version equals the cutoff is evicted (the bound is inclusive)"
951		);
952		assert!(!visible(&s, &key(1)));
953	}
954
955	#[test]
956	fn delete_below_version_on_missing_table_is_noop() {
957		let (s, _guard) = SqlitePersistentStorage::in_memory();
958		let deleted = s
959			.delete_below_version(EntryKind::Source(ShapeId::Table(TableId(999))), CommitVersion(100), None)
960			.unwrap();
961		assert_eq!(deleted.len(), 0);
962	}
963
964	#[test]
965	fn delete_below_version_with_prefix_only_touches_matching_keys() {
966		let (s, _guard) = SqlitePersistentStorage::in_memory();
967		// Two "sides" distinguished by a leading prefix byte, both written at v1.
968		let left = EncodedKey::new(vec![0x01, 0xAA]);
969		let right = EncodedKey::new(vec![0x02, 0xBB]);
970		s.set(
971			CommitVersion(1),
972			HashMap::from([(
973				table(),
974				vec![(left.clone(), Some(row(b"l"))), (right.clone(), Some(row(b"r")))],
975			)]),
976		)
977		.unwrap();
978
979		let deleted = s.delete_below_version(table(), CommitVersion(2), Some(&[0x01])).unwrap();
980
981		assert_eq!(deleted.len(), 1, "only the 0x01-prefixed (left) row should be deleted");
982		assert!(!visible(&s, &left));
983		assert!(visible(&s, &right), "the 0x02-prefixed (right) row must survive a left-only prefix sweep");
984	}
985
986	#[test]
987	fn delete_below_version_returns_exactly_the_deleted_keys() {
988		let (s, _guard) = SqlitePersistentStorage::in_memory();
989		s.set(CommitVersion(1), HashMap::from([(table(), vec![(key(1), Some(row(b"a")))])])).unwrap();
990		s.set(CommitVersion(2), HashMap::from([(table(), vec![(key(2), Some(row(b"b")))])])).unwrap();
991		s.set(CommitVersion(3), HashMap::from([(table(), vec![(key(3), Some(row(b"c")))])])).unwrap();
992
993		// The surgical GC invalidation depends on delete_below_version returning the exact keys it deleted,
994		// so the read cache is invalidated per-key instead of cleared wholesale. A wrong/empty key set
995		// would silently leave stale entries (or over-clear) and this assertion would catch it.
996		let mut got: Vec<Vec<u8>> = s
997			.delete_below_version(table(), CommitVersion(2), None)
998			.unwrap()
999			.iter()
1000			.map(|k| k.to_vec())
1001			.collect();
1002		got.sort();
1003		let mut want = vec![key(1).to_vec(), key(2).to_vec()];
1004		want.sort();
1005		assert_eq!(
1006			got, want,
1007			"delete_below_version must return every key it physically deleted, and only those"
1008		);
1009		assert!(visible(&s, &key(3)), "the row newer than the cutoff must neither be deleted nor returned");
1010	}
1011}