Skip to main content

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}