Skip to main content

reifydb_sqlite/
pragma.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use rusqlite::{Connection, ToSql};
5
6use crate::{
7	SqliteConfig,
8	error::{SqliteError, SqliteResult},
9};
10
11pub fn apply(conn: &Connection, config: &SqliteConfig) -> SqliteResult<()> {
12	if let Some(page_size) = config.page_size {
13		set(conn, "page_size", page_size.as_bytes() as u32)?;
14	}
15	set(conn, "secure_delete", "FAST")?;
16	if let Some(journal_mode) = config.journal_mode {
17		set(conn, "journal_mode", journal_mode.as_str())?;
18	}
19	if let Some(synchronous_mode) = config.synchronous_mode {
20		set(conn, "synchronous", synchronous_mode.as_str())?;
21	}
22	if let Some(temp_store) = config.temp_store {
23		set(conn, "temp_store", temp_store.as_str())?;
24	}
25	if let Some(cache_size) = config.cache_size {
26		set(conn, "cache_size", -(cache_size.as_kib() as i32))?;
27	}
28	if let Some(wal_autocheckpoint) = config.wal_autocheckpoint {
29		set(conn, "wal_autocheckpoint", wal_autocheckpoint)?;
30	}
31	if let Some(mmap_size) = config.mmap_size {
32		set(conn, "mmap_size", mmap_size.as_bytes() as i64)?;
33	}
34	conn.set_prepared_statement_cache_capacity(config.prepared_statement_cache_capacity as usize);
35	Ok(())
36}
37
38pub fn apply_read_only(conn: &Connection, config: &SqliteConfig) -> SqliteResult<()> {
39	set(conn, "query_only", true)?;
40	if let Some(temp_store) = config.temp_store {
41		set(conn, "temp_store", temp_store.as_str())?;
42	}
43	if let Some(cache_size) = config.cache_size {
44		set(conn, "cache_size", -(cache_size.as_kib() as i32))?;
45	}
46	if let Some(mmap_size) = config.mmap_size {
47		set(conn, "mmap_size", mmap_size.as_bytes() as i64)?;
48	}
49	conn.set_prepared_statement_cache_capacity(config.prepared_statement_cache_capacity as usize);
50	Ok(())
51}
52
53pub fn shrink_memory(conn: &Connection) -> SqliteResult<()> {
54	set(conn, "shrink_memory", 0)
55}
56
57pub fn shutdown(conn: &Connection) -> SqliteResult<()> {
58	set(conn, "wal_checkpoint", "TRUNCATE")?;
59	set(conn, "cache_size", 0)?;
60	Ok(())
61}
62
63fn set<V: ToSql>(conn: &Connection, name: &str, value: V) -> SqliteResult<()> {
64	conn.pragma_update(None, name, value).map_err(|source| SqliteError::Pragma {
65		name: name.into(),
66		source,
67	})
68}
69
70#[cfg(test)]
71mod tests {
72	use std::{env::temp_dir, fs::remove_file, path::PathBuf};
73
74	use reifydb_value::byte_size::ByteSize;
75	use rusqlite::Connection;
76	use uuid::Uuid;
77
78	use super::{apply, apply_read_only};
79	use crate::SqliteConfig;
80
81	fn scratch(name: &str) -> (Connection, PathBuf) {
82		// Pragma defaults differ between file-backed and in-memory databases; these tests need a file.
83		let path = temp_dir().join(format!("reifydb_pragma_{name}_{}.db", Uuid::new_v4()));
84		let conn = Connection::open(&path).unwrap();
85		(conn, path)
86	}
87
88	fn cleanup(conn: Connection, path: PathBuf) {
89		drop(conn);
90		let _ = remove_file(&path);
91		let _ = remove_file(path.with_extension("db-wal"));
92		let _ = remove_file(path.with_extension("db-shm"));
93	}
94
95	#[test]
96	fn a_none_pragma_is_never_issued_and_sqlites_own_default_survives() {
97		// None must mean "leave this setting alone", not "use a stand-in". 4321 is distinctive because
98		// new()'s own default of 2000 KiB also reads back as -2000, so asserting -2000 against an
99		// untouched new() would pass even if apply fell back to the config instead of skipping.
100		let (conn, path) = scratch("none");
101		let config = SqliteConfig::new(&path).cache_size(ByteSize::from_kib(4321)).cache_size(None);
102
103		apply(&conn, &config).unwrap();
104
105		let cache_size: i64 = conn.pragma_query_value(None, "cache_size", |r| r.get(0)).unwrap();
106		assert_eq!(cache_size, -2000, "an unset cache_size must leave SQLite's own default in place");
107
108		cleanup(conn, path);
109	}
110
111	#[test]
112	fn journal_mode_none_leaves_a_fresh_database_on_a_rollback_journal() {
113		// WAL lives in the database header, so an unset journal_mode looks harmless against every
114		// already-created database and only detonates on a clean install. Outside WAL, store-multi's
115		// concurrent reader pool takes locks that block its writer.
116		let (unset_conn, unset_path) = scratch("journal_none");
117		apply(&unset_conn, &SqliteConfig::new(&unset_path).journal_mode(None)).unwrap();
118		let unset: String = unset_conn.pragma_query_value(None, "journal_mode", |r| r.get(0)).unwrap();
119
120		let (default_conn, default_path) = scratch("journal_default");
121		apply(&default_conn, &SqliteConfig::new(&default_path)).unwrap();
122		let default: String = default_conn.pragma_query_value(None, "journal_mode", |r| r.get(0)).unwrap();
123
124		assert_eq!(unset, "delete", "an unset journal_mode drops a fresh database to a rollback journal");
125		assert_eq!(default, "wal", "the constructors must keep shipping WAL");
126
127		cleanup(unset_conn, unset_path);
128		cleanup(default_conn, default_path);
129	}
130
131	#[test]
132	fn an_explicit_zero_still_issues_the_pragma() {
133		// ZERO and None are different instructions: zero asks SQLite to retain no page cache, None
134		// declines to configure it at all. Treating ZERO as unset would silently restore the 2000 KiB
135		// per-connection default.
136		let (conn, path) = scratch("zero");
137
138		apply(&conn, &SqliteConfig::new(&path).cache_size(ByteSize::ZERO)).unwrap();
139
140		let cache_size: i64 = conn.pragma_query_value(None, "cache_size", |r| r.get(0)).unwrap();
141		assert_eq!(cache_size, 0, "an explicit zero must be issued, not mistaken for unset");
142
143		cleanup(conn, path);
144	}
145
146	#[test]
147	fn the_read_only_path_decides_each_pragma_independently() {
148		// apply_read_only runs once per pooled connection, so each field must be decided on its own:
149		// skipping one must not skip the next, and issuing one must not drag the other along.
150		let (conn, path) = scratch("readonly");
151		let config = SqliteConfig::new(&path).mmap_size(None).cache_size(ByteSize::from_kib(1500));
152
153		apply_read_only(&conn, &config).unwrap();
154
155		let mmap_size: i64 = conn.pragma_query_value(None, "mmap_size", |r| r.get(0)).unwrap();
156		let cache_size: i64 = conn.pragma_query_value(None, "cache_size", |r| r.get(0)).unwrap();
157
158		assert_eq!(mmap_size, 0, "an unset mmap_size must leave SQLite's default of 0");
159		assert_eq!(cache_size, -1500, "a set cache_size must still be issued alongside a skipped one");
160
161		cleanup(conn, path);
162	}
163
164	#[test]
165	fn test_apply_converts_units_for_pragmas() {
166		let path = temp_dir().join(format!("reifydb_pragma_{}.db", Uuid::new_v4()));
167		let conn = Connection::open(&path).unwrap();
168
169		// new(..) defaults: cache_size 2000 KiB, page_size 4096 bytes, mmap_size 64 MiB.
170		apply(&conn, &SqliteConfig::new(&path)).unwrap();
171
172		let cache_size: i64 = conn.pragma_query_value(None, "cache_size", |r| r.get(0)).unwrap();
173		let page_size: i64 = conn.pragma_query_value(None, "page_size", |r| r.get(0)).unwrap();
174		let mmap_size: i64 = conn.pragma_query_value(None, "mmap_size", |r| r.get(0)).unwrap();
175		let secure_delete: i64 = conn.pragma_query_value(None, "secure_delete", |r| r.get(0)).unwrap();
176
177		assert_eq!(cache_size, -2000, "cache_size must be the KiB count negated");
178		assert_eq!(page_size, 4096, "page_size must be raw bytes");
179		assert_eq!(mmap_size, 67_108_864, "mmap_size must be raw bytes (64 MiB)");
180		// FAST (2) skips the extra I/O of zeroing wholly-freed overflow pages on DELETE, which was
181		// the dominant cost of CDC eviction and persist_sweep; 1 (ON) would reintroduce that tax.
182		assert_eq!(secure_delete, 2, "secure_delete must be FAST (2), not ON (1)");
183
184		drop(conn);
185		let _ = remove_file(&path);
186		let _ = remove_file(path.with_extension("db-wal"));
187		let _ = remove_file(path.with_extension("db-shm"));
188	}
189}