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