meerkat_sqlite/lock_profile.rs
1//! The process-wide lock profile of the bundled SQLite.
2//!
3//! Three build-time defaults of the bundled SQLite put one process-wide mutex
4//! in front of every connection in the process, so many sessions in one host
5//! process queue on each other:
6//!
7//! - allocator statistics (`SQLITE_DEFAULT_MEMSTATUS` defaults to 1): every
8//! `sqlite3_malloc` and `sqlite3_free` takes `SQLITE_MUTEX_STATIC_MEM`.
9//! Opening a connection and parsing its schema is the heavy case: thousands
10//! of allocations per open.
11//! - the shared page cache: `libsqlite3-sys` builds with
12//! `-DSQLITE_ENABLE_MEMORY_MANAGEMENT` (SQLite itself leaves it off), which
13//! puts every connection's pages in one cache group behind
14//! `SQLITE_MUTEX_STATIC_LRU`, taken on every page fetch, unpin and release.
15//! - page-cache overflow statistics: every page buffer allocated or freed on
16//! the heap takes `SQLITE_MUTEX_STATIC_PMEM` unless
17//! `-DSQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS` is set.
18//!
19//! [`SqliteLockProfile::REQUIRED`] has all three off, and
20//! [`REQUIRED_LIBSQLITE3_FLAGS`] is the one build setting that produces it.
21//! The repository's `.cargo/config.toml` and `MODULE.bazel` carry exactly that
22//! string; a build outside the repository (`cargo install`, an embedding
23//! application) sets the `LIBSQLITE3_FLAGS` environment variable to it.
24//! Nothing Meerkat calls reads what these switches turn off (memory usage and
25//! page-cache overflow statistics, heap limits, `sqlite3_release_memory`).
26//!
27//! Allocator statistics are the only one of the three with a runtime switch:
28//! [`disable_memory_statistics_at_process_start`] turns them off in a build
29//! without the flags, when it runs before anything uses SQLite. The host
30//! binaries call it as the first statement of `main`.
31//!
32//! A process whose profile is not the required one says so twice: once as a
33//! warning at the first connection opened through [`crate::profile`], and in
34//! `rkat storage doctor`, which reports the profile it runs with.
35
36use std::ffi::CStr;
37use std::fmt;
38use std::os::raw::c_int;
39use std::sync::{Once, OnceLock};
40
41use rusqlite::ffi;
42
43/// The `LIBSQLITE3_FLAGS` value that builds the bundled SQLite with
44/// [`SqliteLockProfile::REQUIRED`]: allocator statistics off, one page cache
45/// per connection, page-cache overflow statistics off.
46pub const REQUIRED_LIBSQLITE3_FLAGS: &str = "-DSQLITE_DEFAULT_MEMSTATUS=0 \
47 -USQLITE_ENABLE_MEMORY_MANAGEMENT -DSQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS";
48
49/// Which of SQLite's three process-wide locks this process takes. `true` means
50/// the lock is in use: every connection in the process queues on it.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct SqliteLockProfile {
53 /// Allocator statistics: `SQLITE_MUTEX_STATIC_MEM` on every allocation.
54 pub memory_statistics: bool,
55 /// One page-cache group for every connection: `SQLITE_MUTEX_STATIC_LRU`
56 /// on every page operation.
57 pub shared_page_cache: bool,
58 /// Page-cache overflow statistics: `SQLITE_MUTEX_STATIC_PMEM` on every
59 /// heap page buffer.
60 pub page_cache_overflow_statistics: bool,
61}
62
63impl SqliteLockProfile {
64 /// The profile Meerkat requires: none of the three locks.
65 pub const REQUIRED: Self = Self {
66 memory_statistics: false,
67 shared_page_cache: false,
68 page_cache_overflow_statistics: false,
69 };
70
71 /// The profile the linked SQLite was compiled with. Reads SQLite's
72 /// compile-option table only; it never initializes SQLite.
73 #[must_use]
74 pub fn compiled() -> Self {
75 Self {
76 memory_statistics: !compile_option_used(c"DEFAULT_MEMSTATUS=0"),
77 shared_page_cache: compile_option_used(c"ENABLE_MEMORY_MANAGEMENT"),
78 page_cache_overflow_statistics: !compile_option_used(
79 c"DISABLE_PAGECACHE_OVERFLOW_STATS",
80 ),
81 }
82 }
83
84 /// The profile this process runs with: [`Self::compiled`], with allocator
85 /// statistics off when [`disable_memory_statistics_at_process_start`]
86 /// applied.
87 #[must_use]
88 pub fn effective() -> Self {
89 let mut profile = Self::compiled();
90 if memory_statistics_outcome() == Some(MemoryStatisticsOutcome::Applied) {
91 profile.memory_statistics = false;
92 }
93 profile
94 }
95
96 /// Whether this is [`Self::REQUIRED`].
97 #[must_use]
98 pub fn is_required(self) -> bool {
99 self == Self::REQUIRED
100 }
101}
102
103impl fmt::Display for SqliteLockProfile {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 let state = |on: bool| if on { "on" } else { "off" };
106 write!(
107 f,
108 "memory statistics {}, shared page cache {}, page-cache overflow statistics {}",
109 state(self.memory_statistics),
110 state(self.shared_page_cache),
111 state(self.page_cache_overflow_statistics),
112 )
113 }
114}
115
116/// The process runs SQLite with at least one process-wide lock in use.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
118#[error(
119 "SQLite runs with process-wide locks ({effective}); build with \
120 LIBSQLITE3_FLAGS=\"{REQUIRED_LIBSQLITE3_FLAGS}\""
121)]
122pub struct SqliteLockProfileMismatch {
123 /// What this process runs with.
124 pub effective: SqliteLockProfile,
125}
126
127/// Check that this process runs SQLite with [`SqliteLockProfile::REQUIRED`].
128///
129/// # Errors
130///
131/// [`SqliteLockProfileMismatch`] naming the effective profile when any of the
132/// three locks is in use.
133pub fn check_lock_profile() -> Result<(), SqliteLockProfileMismatch> {
134 let effective = SqliteLockProfile::effective();
135 if effective.is_required() {
136 Ok(())
137 } else {
138 Err(SqliteLockProfileMismatch { effective })
139 }
140}
141
142/// What [`disable_memory_statistics_at_process_start`] did: SQLite either
143/// accepted the switch (and allocator statistics were on or already off) or
144/// refused it.
145///
146/// Exhaustive on purpose: SQLite accepts or refuses the switch, and a build
147/// has the statistics on or off, so these three are every outcome there is,
148/// and a new one would fail to compile at each exhaustive match on it (the
149/// storage doctor's report is one) instead of falling into a wildcard arm.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub enum MemoryStatisticsOutcome {
152 /// This build has allocator statistics on; they are now off.
153 Applied,
154 /// This build compiles allocator statistics off; nothing changed.
155 AlreadyOff,
156 /// SQLite was already in use, so it refused the switch: allocator
157 /// statistics stay as built. A degrade, never a startup failure.
158 TooLate,
159}
160
161impl fmt::Display for MemoryStatisticsOutcome {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 f.write_str(match self {
164 Self::Applied => "applied",
165 Self::AlreadyOff => "already off",
166 Self::TooLate => "too late",
167 })
168 }
169}
170
171static MEMORY_STATISTICS_OUTCOME: OnceLock<MemoryStatisticsOutcome> = OnceLock::new();
172
173/// Turn SQLite's allocator statistics off for this process, and record the
174/// outcome ([`memory_statistics_outcome`]). Only the first call acts; later
175/// calls return the first outcome.
176///
177/// For builds without [`REQUIRED_LIBSQLITE3_FLAGS`]: allocator statistics are
178/// the one process-wide lock with a runtime switch, and SQLite accepts the
179/// switch only before it initializes. The shared page cache and the
180/// page-cache overflow statistics need the build flags.
181///
182/// # Safety
183///
184/// `sqlite3_config` is not thread-safe. No other thread may use SQLite (open,
185/// use or close a connection, or call any `sqlite3_*` function) while this
186/// runs. Call it as the first statement of `main`, before any thread exists.
187#[allow(unsafe_code)]
188pub unsafe fn disable_memory_statistics_at_process_start() -> MemoryStatisticsOutcome {
189 *MEMORY_STATISTICS_OUTCOME.get_or_init(|| {
190 // SAFETY: the caller guarantees that no other thread uses SQLite
191 // while this runs; SQLITE_CONFIG_MEMSTATUS takes one `int`.
192 let rc = unsafe { ffi::sqlite3_config(ffi::SQLITE_CONFIG_MEMSTATUS, 0 as c_int) };
193 if rc != ffi::SQLITE_OK {
194 MemoryStatisticsOutcome::TooLate
195 } else if SqliteLockProfile::compiled().memory_statistics {
196 MemoryStatisticsOutcome::Applied
197 } else {
198 MemoryStatisticsOutcome::AlreadyOff
199 }
200 })
201}
202
203/// The recorded outcome of [`disable_memory_statistics_at_process_start`];
204/// `None` when this process never called it.
205#[must_use]
206pub fn memory_statistics_outcome() -> Option<MemoryStatisticsOutcome> {
207 MEMORY_STATISTICS_OUTCOME.get().copied()
208}
209
210static FIRST_OPEN: Once = Once::new();
211
212/// At the first connection [`crate::profile`] opens in this process, warn
213/// when SQLite runs with any of the three process-wide locks.
214pub(crate) fn check_at_first_open() {
215 FIRST_OPEN.call_once(|| {
216 if let Some(warning) =
217 lock_profile_warning(SqliteLockProfile::effective(), memory_statistics_outcome())
218 {
219 tracing::warn!("{warning}");
220 }
221 });
222}
223
224/// The first-open warning for `effective`, or `None` for the required
225/// profile. It names the build setting that fixes it.
226fn lock_profile_warning(
227 effective: SqliteLockProfile,
228 switch: Option<MemoryStatisticsOutcome>,
229) -> Option<String> {
230 if effective.is_required() {
231 return None;
232 }
233 let switch = switch.map_or_else(|| "not called".to_string(), |outcome| outcome.to_string());
234 Some(format!(
235 "{}; concurrent connections in this process queue on these locks \
236 (allocator statistics switch at process start: {switch})",
237 SqliteLockProfileMismatch { effective }
238 ))
239}
240
241fn compile_option_used(name: &CStr) -> bool {
242 // SAFETY: sqlite3_compileoption_used only reads SQLite's static
243 // compile-option table (it never initializes SQLite), and `name` is a
244 // NUL-terminated string that outlives the call.
245 #[allow(unsafe_code)]
246 let used = unsafe { ffi::sqlite3_compileoption_used(name.as_ptr()) };
247 used != 0
248}
249
250/// Whether this process has run the first-open check.
251#[cfg(test)]
252pub(crate) fn checked_at_first_open() -> bool {
253 FIRST_OPEN.is_completed()
254}
255
256#[cfg(test)]
257#[allow(clippy::expect_used)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn first_open_warning_names_the_build_setting_unless_the_profile_is_required() {
263 assert_eq!(
264 lock_profile_warning(SqliteLockProfile::REQUIRED, None),
265 None
266 );
267
268 let shared_page_cache = SqliteLockProfile {
269 shared_page_cache: true,
270 ..SqliteLockProfile::REQUIRED
271 };
272 let warning =
273 lock_profile_warning(shared_page_cache, Some(MemoryStatisticsOutcome::Applied))
274 .expect("a shared page cache is warned about");
275 for expected in [
276 "memory statistics off, shared page cache on, page-cache overflow statistics off",
277 &format!("LIBSQLITE3_FLAGS=\"{REQUIRED_LIBSQLITE3_FLAGS}\""),
278 "allocator statistics switch at process start: applied",
279 ] {
280 assert!(warning.contains(expected), "{expected:?} in {warning:?}");
281 }
282
283 let memory_statistics = SqliteLockProfile {
284 memory_statistics: true,
285 ..SqliteLockProfile::REQUIRED
286 };
287 let warning = lock_profile_warning(memory_statistics, None)
288 .expect("allocator statistics are warned about");
289 assert!(
290 warning.contains("allocator statistics switch at process start: not called"),
291 "{warning:?}"
292 );
293 }
294}