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