Skip to main content

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}