Skip to main content

meerkat_sqlite/
profile.rs

1//! DDL-free connection opening under named policy profiles.
2//!
3//! Every profile maps to a policy that already existed somewhere in the
4//! workspace; the profiles name those policies instead of erasing them:
5//!
6//! - [`ConnectionProfile::Primary`]: the production writer policy
7//!   (WAL + `synchronous=FULL` + the one shared busy timeout). With
8//!   `create: false` it is the no-create writer variant previously
9//!   hand-rolled for identity databases. WAL establishment is verified
10//!   against the mode SQLite reports back, and the journal-mode conversion
11//!   only runs after the [`OpenOptions::schema_preflight`] ledger check, so
12//!   an old binary refuses a future file before mutating it.
13//! - [`ConnectionProfile::ReadOnly`]: passive observation
14//!   (`SQLITE_OPEN_READ_ONLY | URI | NO_MUTEX`, shared busy timeout, no
15//!   pragma mutation — a reader must not convert a foreign file's journal
16//!   mode). The precise no-write guarantee is typed as
17//!   [`WriteContact::ReadOnlyWalSidecars`]: logical content is never
18//!   altered, but WAL sidecars may be required to read a WAL database.
19//! - [`ConnectionProfile::Maintenance`]: fail-fast offline access (zero busy
20//!   timeout, never creates, no pragma mutation). This is the profile for
21//!   migration/diagnostic work performed by the party holding the exclusive
22//!   maintenance fence; it deliberately does not take a shared fence guard.
23//!
24//! Opening a connection never runs schema DDL. Stores apply their
25//! [`crate::ledger`] domain after opening.
26
27use std::path::Path;
28use std::time::Duration;
29
30use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
31
32use crate::error::SqliteStoreError;
33use crate::ledger::SchemaDomain;
34
35/// The one shared busy timeout for production profiles.
36///
37/// Previously `SQLITE_BUSY_TIMEOUT_MS` was defined six times across the
38/// workspace with values of 5s and 60s. 60s is the value the contended
39/// multi-writer store (sessions) was deliberately raised to; the harmonized
40/// default adopts it. Callers with a deliberate different policy pass an
41/// override via [`OpenOptions::busy_timeout`] so the decision stays named at
42/// the call site.
43pub const SHARED_BUSY_TIMEOUT: Duration = Duration::from_millis(60_000);
44
45/// Upper bound on the busy timeout actually handed to SQLite.
46///
47/// `sqlite3_busy_timeout` takes an `int` of milliseconds and rusqlite's
48/// conversion panics past `i32::MAX`; a panic in library code is not an
49/// acceptable outcome for an oversized configuration value, so anything
50/// above this bound (~24.8 days) is clamped to it.
51const MAX_BUSY_TIMEOUT: Duration = Duration::from_millis(i32::MAX as u64);
52
53/// Named connection policy profiles.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ConnectionProfile {
56    /// Production read-write policy: WAL, `synchronous=FULL`, shared busy
57    /// timeout. `create: true` creates parent directories and the file;
58    /// `create: false` refuses to create (no-create writer, for files whose
59    /// creation is owned elsewhere).
60    Primary { create: bool },
61    /// Passive read-only observation. Never creates the database file, never
62    /// mutates pragmas, never alters logical database content. Reading a
63    /// database that is in WAL mode may still create or update its
64    /// `-wal`/`-shm` sidecar files (see
65    /// [`WriteContact::ReadOnlyWalSidecars`]).
66    ReadOnly,
67    /// Fail-fast maintenance access for offline work: zero busy timeout by
68    /// default (a held lock surfaces immediately instead of stalling), never
69    /// creates, never mutates pragmas. `write: false` opens read-only.
70    Maintenance { write: bool },
71}
72
73/// The strongest filesystem no-write guarantee an open under a profile can
74/// make. Diagnostic surfaces (doctor) report from this instead of promising
75/// a zero-touch open that SQLite cannot deliver.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum WriteContact {
78    /// The profile writes: mutating pragmas at open (journal-mode
79    /// conversion) and/or subsequent DDL/DML.
80    ReadWrite,
81    /// Logical database content is never modified, but SQLite may still
82    /// create or update the `-wal`/`-shm` sidecar files when the database is
83    /// in WAL mode: coherent WAL reads require the shared-memory index, and
84    /// rusqlite offers no way around that short of `immutable=1`, which is
85    /// only sound when no live writer can exist and is therefore not used
86    /// here. On truly read-only media a WAL database whose sidecars are
87    /// absent fails to read (typed SQLite error) instead of being mutated;
88    /// rollback-journal databases are read with no sidecar contact at all.
89    ReadOnlyWalSidecars,
90}
91
92impl ConnectionProfile {
93    /// Convenience: the common create-capable primary profile.
94    pub const PRIMARY: Self = Self::Primary { create: true };
95
96    fn name(self) -> &'static str {
97        match self {
98            Self::Primary { create: true } => "primary",
99            Self::Primary { create: false } => "primary(no-create)",
100            Self::ReadOnly => "read-only",
101            Self::Maintenance { write: true } => "maintenance(write)",
102            Self::Maintenance { write: false } => "maintenance(read)",
103        }
104    }
105
106    fn default_busy_timeout(self) -> Duration {
107        match self {
108            Self::Primary { .. } | Self::ReadOnly => SHARED_BUSY_TIMEOUT,
109            Self::Maintenance { .. } => Duration::ZERO,
110        }
111    }
112
113    /// The strongest no-write guarantee an open under this profile makes.
114    pub fn write_contact(self) -> WriteContact {
115        match self {
116            Self::Primary { .. } | Self::Maintenance { write: true } => WriteContact::ReadWrite,
117            Self::ReadOnly | Self::Maintenance { write: false } => {
118                WriteContact::ReadOnlyWalSidecars
119            }
120        }
121    }
122}
123
124/// Per-open overrides. The zero-value default defers everything to the
125/// profile.
126#[derive(Debug, Clone, Copy, Default)]
127pub struct OpenOptions {
128    /// Override the profile's busy timeout. A store with a deliberate
129    /// contention policy (for example a lock-arbitration sidecar that waits
130    /// 30s then fails closed) names it here instead of hand-rolling an
131    /// opener. Values beyond SQLite's `int` millisecond range are clamped to
132    /// `i32::MAX` milliseconds.
133    pub busy_timeout: Option<Duration>,
134    /// Schema domains checked against the file's `meerkat_schema` ledger
135    /// BEFORE any mutating pragma runs. A file whose ledger records a
136    /// version newer than a listed domain supports is refused
137    /// ([`SqliteStoreError::SchemaFromTheFuture`]) with its logical content
138    /// unmodified — sidecars may still be touched, because reading the
139    /// ledger of a WAL-mode file over a read-write connection contacts its
140    /// `-wal`/`-shm` files (see [`WriteContact::ReadOnlyWalSidecars`]).
141    /// Without the preflight, a Primary open by an old binary would convert
142    /// a future database's journal mode before the ledger refusal in
143    /// [`crate::ledger::apply_domain_migrations`] ever fires. The check runs
144    /// unconditionally after every open — never gated on a pre-open
145    /// filesystem probe, which a concurrent creator could race — and passes
146    /// trivially on files with no ledger table (fresh creates, pre-ledger
147    /// files). Empty (the default) skips the preflight.
148    pub schema_preflight: &'static [&'static SchemaDomain],
149}
150
151/// Open `path` under `profile` with the profile's defaults.
152pub fn open(path: &Path, profile: ConnectionProfile) -> Result<Connection, SqliteStoreError> {
153    open_with(path, profile, OpenOptions::default())
154}
155
156/// Open `path` under `profile` with per-open overrides.
157pub fn open_with(
158    path: &Path,
159    profile: ConnectionProfile,
160    options: OpenOptions,
161) -> Result<Connection, SqliteStoreError> {
162    let conn = match profile {
163        ConnectionProfile::Primary { create: true } => {
164            if let Some(parent) = path.parent() {
165                std::fs::create_dir_all(parent)?;
166            }
167            Connection::open(path)?
168        }
169        ConnectionProfile::Primary { create: false } => {
170            open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_WRITE)?
171        }
172        ConnectionProfile::ReadOnly => {
173            open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_ONLY)?
174        }
175        ConnectionProfile::Maintenance { write } => {
176            let base = if write {
177                OpenFlags::SQLITE_OPEN_READ_WRITE
178            } else {
179                OpenFlags::SQLITE_OPEN_READ_ONLY
180            };
181            open_existing(path, profile, base)?
182        }
183    };
184
185    let busy = options
186        .busy_timeout
187        .unwrap_or_else(|| profile.default_busy_timeout())
188        .min(MAX_BUSY_TIMEOUT);
189    conn.busy_timeout(busy)?;
190
191    // The future-schema preflight runs before any mutating pragma: a refusal
192    // leaves a foreign (newer) file's logical content unmodified (WAL-mode
193    // files may see sidecar contact from the ledger read). It is not gated
194    // on a pre-open filesystem probe — a database created by another process
195    // between such a probe and the open would dodge the check. A fresh
196    // create carries no ledger table and passes inside
197    // `refuse_future_schema` itself.
198    for domain in options.schema_preflight {
199        crate::ledger::refuse_future_schema(&conn, domain)?;
200    }
201
202    if let ConnectionProfile::Primary { .. } = profile {
203        set_wal_journal_mode(&conn, path, busy)?;
204        conn.pragma_update(None, "synchronous", "FULL")?;
205    }
206
207    Ok(conn)
208}
209
210/// Convert (or confirm) the WAL journal mode with a bounded retry, verified
211/// against the effective mode SQLite reports back.
212///
213/// Converting a fresh rollback-journal database to WAL needs an exclusive
214/// lock, and SQLite can return `SQLITE_BUSY` from the journal-mode pragma
215/// WITHOUT consulting the busy handler while concurrent creators race the
216/// conversion. Once a file is WAL the pragma is a lock-free no-op, so the
217/// retry only ever spins during the first-create race.
218fn set_wal_journal_mode(
219    conn: &Connection,
220    path: &Path,
221    busy_timeout: Duration,
222) -> Result<(), SqliteStoreError> {
223    // Bound the retry by the connection's busy policy (with a small floor so
224    // zero-timeout profiles still tolerate the momentary create race).
225    let deadline = std::time::Instant::now() + busy_timeout.max(Duration::from_millis(250));
226    loop {
227        // `pragma_update` would discard the mode the pragma returns, and
228        // SQLite can decline the conversion without raising an error — so
229        // the returned mode is read back and anything that is not WAL is a
230        // typed failure. In-memory databases have no on-disk journal and
231        // always report `memory`; they are accepted as-is.
232        let result = conn
233            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0));
234        match result {
235            Ok(mode) if mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("memory") => {
236                return Ok(());
237            }
238            Ok(mode) => {
239                return Err(SqliteStoreError::WalNotEstablished {
240                    path: path.to_path_buf(),
241                    actual: mode,
242                });
243            }
244            Err(error)
245                if crate::error::is_busy_or_locked(&error)
246                    && std::time::Instant::now() < deadline =>
247            {
248                std::thread::sleep(Duration::from_millis(5));
249            }
250            Err(error) => return Err(error.into()),
251        }
252    }
253}
254
255fn open_existing(
256    path: &Path,
257    profile: ConnectionProfile,
258    base: OpenFlags,
259) -> Result<Connection, SqliteStoreError> {
260    if !path.is_file() {
261        return Err(SqliteStoreError::OpenRefused {
262            path: path.to_path_buf(),
263            profile: profile.name(),
264            detail: "database file does not exist".to_string(),
265        });
266    }
267    Ok(Connection::open_with_flags(
268        path,
269        base | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
270    )?)
271}
272
273/// Begin an IMMEDIATE transaction. Bounded waiting for the write lock is the
274/// connection busy handler's job (set at open per profile), not a caller
275/// retry loop.
276pub fn begin_immediate(conn: &mut Connection) -> Result<Transaction<'_>, SqliteStoreError> {
277    Ok(conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
278}
279
280#[cfg(test)]
281#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn primary_creates_dirs_sets_wal_and_full() {
287        let dir = tempfile::tempdir().expect("tempdir");
288        let path = dir.path().join("nested/sub/test.sqlite3");
289        let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
290        let journal: String = conn
291            .pragma_query_value(None, "journal_mode", |r| r.get(0))
292            .expect("journal_mode");
293        assert_eq!(journal, "wal");
294        let sync: i64 = conn
295            .pragma_query_value(None, "synchronous", |r| r.get(0))
296            .expect("synchronous");
297        assert_eq!(sync, 2, "synchronous=FULL");
298    }
299
300    #[test]
301    fn non_creating_profiles_refuse_missing_file() {
302        let dir = tempfile::tempdir().expect("tempdir");
303        let path = dir.path().join("missing.sqlite3");
304        for profile in [
305            ConnectionProfile::Primary { create: false },
306            ConnectionProfile::ReadOnly,
307            ConnectionProfile::Maintenance { write: true },
308            ConnectionProfile::Maintenance { write: false },
309        ] {
310            let err = open(&path, profile).expect_err("must refuse missing file");
311            assert!(
312                matches!(err, SqliteStoreError::OpenRefused { .. }),
313                "{profile:?}"
314            );
315        }
316        assert!(
317            !path.exists(),
318            "no profile may create the file as a side effect"
319        );
320    }
321
322    #[test]
323    fn read_only_profile_rejects_writes_and_preserves_journal_mode() {
324        let dir = tempfile::tempdir().expect("tempdir");
325        let path = dir.path().join("db.sqlite3");
326        {
327            let conn = Connection::open(&path).expect("create");
328            conn.execute_batch("CREATE TABLE t (x INTEGER)")
329                .expect("ddl");
330        }
331        let conn = open(&path, ConnectionProfile::ReadOnly).expect("open ro");
332        let journal: String = conn
333            .pragma_query_value(None, "journal_mode", |r| r.get(0))
334            .expect("journal_mode");
335        assert_eq!(
336            journal, "delete",
337            "reader must not convert the journal mode"
338        );
339        conn.execute("INSERT INTO t VALUES (1)", [])
340            .expect_err("read-only connection must reject writes");
341    }
342
343    #[test]
344    fn maintenance_profile_fails_fast_on_held_lock() {
345        let dir = tempfile::tempdir().expect("tempdir");
346        let path = dir.path().join("db.sqlite3");
347        let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
348        writer
349            .execute_batch("CREATE TABLE t (x INTEGER)")
350            .expect("ddl");
351        let tx = begin_immediate(&mut writer).expect("hold write lock");
352
353        let maint = open(&path, ConnectionProfile::Maintenance { write: true }).expect("open");
354        let err = maint
355            .execute_batch("BEGIN IMMEDIATE")
356            .expect_err("zero busy timeout must surface the held lock immediately");
357        assert!(crate::error::is_busy_or_locked(&match err {
358            rusqlite::Error::SqliteFailure(..) => err,
359            other => panic!("unexpected error shape: {other}"),
360        }));
361        drop(tx);
362    }
363
364    #[test]
365    fn busy_timeout_override_applies() {
366        let dir = tempfile::tempdir().expect("tempdir");
367        let path = dir.path().join("db.sqlite3");
368        let conn = open_with(
369            &path,
370            ConnectionProfile::PRIMARY,
371            OpenOptions {
372                busy_timeout: Some(Duration::from_millis(1234)),
373                ..OpenOptions::default()
374            },
375        )
376        .expect("open");
377        let timeout: i64 = conn
378            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
379            .expect("busy_timeout");
380        assert_eq!(timeout, 1234);
381    }
382
383    #[test]
384    fn oversized_busy_timeout_is_clamped_not_panicking() {
385        let dir = tempfile::tempdir().expect("tempdir");
386        let path = dir.path().join("db.sqlite3");
387        let conn = open_with(
388            &path,
389            ConnectionProfile::PRIMARY,
390            OpenOptions {
391                busy_timeout: Some(Duration::MAX),
392                ..OpenOptions::default()
393            },
394        )
395        .expect("oversized timeout must clamp, not panic");
396        let timeout: i64 = conn
397            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
398            .expect("busy_timeout");
399        assert_eq!(timeout, i64::from(i32::MAX));
400    }
401
402    fn preflight_base(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
403        tx.execute_batch("CREATE TABLE IF NOT EXISTS preflight_t (x INTEGER)")
404    }
405
406    const PREFLIGHT_DOMAIN: SchemaDomain = SchemaDomain {
407        name: "preflight-domain",
408        migrations: &[crate::ledger::Migration {
409            version: 1,
410            name: "base",
411            apply: preflight_base,
412        }],
413    };
414
415    #[test]
416    fn schema_preflight_refuses_future_file_before_wal_conversion() {
417        let dir = tempfile::tempdir().expect("tempdir");
418        let path = dir.path().join("db.sqlite3");
419        {
420            // A rollback-journal file stamped by a "newer binary". The
421            // create-capable profile below must refuse it all the same:
422            // the preflight consults only the opened connection's ledger,
423            // so a file created by another process at any point before the
424            // open (there is no pre-open existence probe to race) is
425            // classified by its content, not by who created it.
426            let conn = Connection::open(&path).expect("create raw");
427            conn.execute_batch(
428                "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
429                 INSERT INTO meerkat_schema VALUES ('preflight-domain', 999);",
430            )
431            .expect("seed future ledger");
432        }
433        let err = open_with(
434            &path,
435            ConnectionProfile::PRIMARY,
436            OpenOptions {
437                schema_preflight: &[&PREFLIGHT_DOMAIN],
438                ..OpenOptions::default()
439            },
440        )
441        .expect_err("future file must be refused");
442        assert!(matches!(
443            err,
444            SqliteStoreError::SchemaFromTheFuture {
445                found: 999,
446                supported: 1,
447                ..
448            }
449        ));
450        // The refusal fired before the mutating pragma: the file still has
451        // its original rollback journal mode.
452        let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
453            .expect("reopen raw");
454        let journal: String = conn
455            .pragma_query_value(None, "journal_mode", |r| r.get(0))
456            .expect("journal_mode");
457        assert_eq!(journal, "delete");
458    }
459
460    #[test]
461    fn schema_preflight_allows_fresh_create_and_current_files() {
462        let dir = tempfile::tempdir().expect("tempdir");
463        let path = dir.path().join("fresh.sqlite3");
464        let options = OpenOptions {
465            schema_preflight: &[&PREFLIGHT_DOMAIN],
466            ..OpenOptions::default()
467        };
468        // The preflight runs on fresh creates too (no filesystem-based
469        // exemption); it passes because the new file has no ledger table.
470        let mut conn = open_with(&path, ConnectionProfile::PRIMARY, options)
471            .expect("fresh create carries no ledger table and passes preflight");
472        crate::ledger::apply_domain_migrations(&mut conn, &PREFLIGHT_DOMAIN).expect("stamp");
473        drop(conn);
474        open_with(&path, ConnectionProfile::PRIMARY, options)
475            .expect("current file passes preflight");
476    }
477
478    #[test]
479    fn primary_in_memory_reports_memory_journal_and_opens() {
480        // In-memory databases cannot hold WAL; the verified journal-mode
481        // setup accepts their `memory` reading instead of refusing them.
482        let conn = open(Path::new(":memory:"), ConnectionProfile::PRIMARY).expect("open");
483        let journal: String = conn
484            .pragma_query_value(None, "journal_mode", |r| r.get(0))
485            .expect("journal_mode");
486        assert_eq!(journal, "memory");
487    }
488
489    #[test]
490    fn write_contact_names_the_wal_sidecar_caveat() {
491        assert_eq!(
492            ConnectionProfile::PRIMARY.write_contact(),
493            WriteContact::ReadWrite
494        );
495        assert_eq!(
496            ConnectionProfile::Primary { create: false }.write_contact(),
497            WriteContact::ReadWrite
498        );
499        assert_eq!(
500            ConnectionProfile::Maintenance { write: true }.write_contact(),
501            WriteContact::ReadWrite
502        );
503        assert_eq!(
504            ConnectionProfile::ReadOnly.write_contact(),
505            WriteContact::ReadOnlyWalSidecars
506        );
507        assert_eq!(
508            ConnectionProfile::Maintenance { write: false }.write_contact(),
509            WriteContact::ReadOnlyWalSidecars
510        );
511    }
512}