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`] eligibility check,
12//! so an ineligible file is refused before mutation.
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//!
27//! # Journal mode is a property of the file
28//!
29//! Journal mode lives in the database header, not in a connection: a durable
30//! read-write connection to a rollback-journal ("delete") database takes a
31//! database-wide EXCLUSIVE lock for every write, with no reader/writer
32//! separation, however the connection was configured. Which profiles
33//! establish WAL and which leave the file as found is therefore stated once,
34//! as [`ConnectionProfile::journal_policy`], instead of being implied by the
35//! shape of a `match` arm.
36//!
37//! # Reading a store's journal mode by hand
38//!
39//! `PRAGMA journal_mode` is only truthful over a connection that opened the
40//! file normally. Two shapes routinely mislead an operator diagnosing a store
41//! from the outside:
42//!
43//! - A database opened with the `immutable=1` URI parameter reports `delete`
44//! whatever the file actually is: `immutable=1` asserts the file cannot
45//! change, so SQLite ignores WAL entirely. It can never establish a journal
46//! mode, and it must never be pointed at a live database, whose concurrent
47//! writes it also suppresses the locking for.
48//! - Absent `-wal`/`-shm` sidecars are the normal at-rest shape here. These
49//! stores hold no long-lived connection, and SQLite checkpoints and unlinks
50//! both sidecars when the last connection to a WAL database closes.
51//! Sidecars on disk mean a connection is currently open; their absence
52//! means nothing about the mode. Note also that some system SQLite builds
53//! refuse a read-only open of a WAL database whose sidecars are absent
54//! (`SQLITE_CANTOPEN`), which is what tempts a hand probe into
55//! `immutable=1` in the first place.
56
57use std::path::Path;
58use std::time::Duration;
59
60use rusqlite::{Connection, OpenFlags, Transaction, TransactionBehavior};
61
62use crate::error::SqliteStoreError;
63use crate::ledger::SchemaDomain;
64
65/// The one shared busy timeout for production profiles.
66///
67/// Previously `SQLITE_BUSY_TIMEOUT_MS` was defined six times across the
68/// workspace with values of 5s and 60s. 60s is the value the contended
69/// multi-writer store (sessions) was deliberately raised to; the harmonized
70/// default adopts it. Callers with a deliberate different policy pass an
71/// override via [`OpenOptions::busy_timeout`] so the decision stays named at
72/// the call site.
73pub const SHARED_BUSY_TIMEOUT: Duration = Duration::from_millis(60_000);
74
75/// Upper bound on the busy timeout actually handed to SQLite.
76///
77/// `sqlite3_busy_timeout` takes an `int` of milliseconds and rusqlite's
78/// conversion panics past `i32::MAX`; a panic in library code is not an
79/// acceptable outcome for an oversized configuration value, so anything
80/// above this bound (~24.8 days) is clamped to it.
81const MAX_BUSY_TIMEOUT: Duration = Duration::from_millis(i32::MAX as u64);
82
83/// Named connection policy profiles.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ConnectionProfile {
86 /// Production read-write policy: WAL, `synchronous=FULL`, shared busy
87 /// timeout. `create: true` creates parent directories and the file;
88 /// `create: false` refuses to create (no-create writer, for files whose
89 /// creation is owned elsewhere).
90 Primary { create: bool },
91 /// Passive read-only observation. Never creates the database file, never
92 /// mutates pragmas, never alters logical database content. Reading a
93 /// database that is in WAL mode may still create or update its
94 /// `-wal`/`-shm` sidecar files (see
95 /// [`WriteContact::ReadOnlyWalSidecars`]).
96 ReadOnly,
97 /// Fail-fast maintenance access for offline work: zero busy timeout by
98 /// default (a held lock surfaces immediately instead of stalling), never
99 /// creates, never mutates pragmas. `write: false` opens read-only.
100 Maintenance { write: bool },
101}
102
103/// What an open under a profile does to the file's journal mode.
104///
105/// Naming this makes the durable-writer rule checkable instead of accidental:
106/// a profile added later has to choose a policy, and cannot inherit
107/// "not `Primary`, therefore no WAL" from the shape of a `match` arm.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum JournalPolicy {
110 /// Convert-or-confirm `journal_mode=WAL` at open, verified against the
111 /// mode SQLite reports back, and fail the open when WAL cannot be
112 /// established. Every profile that serves durable read-write traffic is
113 /// here; a store that cannot get WAL must not run degraded.
114 EstablishWal,
115 /// Leave the file's journal mode exactly as found.
116 PreserveExisting,
117}
118
119/// The strongest filesystem no-write guarantee an open under a profile can
120/// make. Diagnostic surfaces (doctor) report from this instead of promising
121/// a zero-touch open that SQLite cannot deliver.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum WriteContact {
124 /// The profile writes: mutating pragmas at open (journal-mode
125 /// conversion) and/or subsequent DDL/DML.
126 ReadWrite,
127 /// Logical database content is never modified, but SQLite may still
128 /// create or update the `-wal`/`-shm` sidecar files when the database is
129 /// in WAL mode: coherent WAL reads require the shared-memory index, and
130 /// rusqlite offers no way around that short of `immutable=1`, which is
131 /// only sound when no live writer can exist and is therefore not used
132 /// here. On truly read-only media a WAL database whose sidecars are
133 /// absent fails to read (typed SQLite error) instead of being mutated;
134 /// rollback-journal databases are read with no sidecar contact at all.
135 ReadOnlyWalSidecars,
136}
137
138impl ConnectionProfile {
139 /// Convenience: the common create-capable primary profile.
140 pub const PRIMARY: Self = Self::Primary { create: true };
141
142 fn name(self) -> &'static str {
143 match self {
144 Self::Primary { create: true } => "primary",
145 Self::Primary { create: false } => "primary(no-create)",
146 Self::ReadOnly => "read-only",
147 Self::Maintenance { write: true } => "maintenance(write)",
148 Self::Maintenance { write: false } => "maintenance(read)",
149 }
150 }
151
152 fn default_busy_timeout(self) -> Duration {
153 match self {
154 Self::Primary { .. } | Self::ReadOnly => SHARED_BUSY_TIMEOUT,
155 Self::Maintenance { .. } => Duration::ZERO,
156 }
157 }
158
159 /// What an open under this profile does to the file's journal mode.
160 ///
161 /// `Maintenance { write: true }` is the one read-write profile that
162 /// preserves the mode it finds, and deliberately: it is the offline
163 /// surgeon's profile, taken by the party holding the exclusive
164 /// maintenance fence to bridge ledgers or to archive a file by rename.
165 /// Converting the journal mode of a database another step is about to
166 /// relocate is a mutation outside that mandate, and it would leave
167 /// sidecars beside bytes that are about to move. Durable serving traffic
168 /// therefore never uses that profile; it uses
169 /// [`ConnectionProfile::Primary`], which establishes and verifies WAL or
170 /// refuses the open.
171 pub fn journal_policy(self) -> JournalPolicy {
172 match self {
173 Self::Primary { .. } => JournalPolicy::EstablishWal,
174 Self::ReadOnly | Self::Maintenance { .. } => JournalPolicy::PreserveExisting,
175 }
176 }
177
178 /// The strongest no-write guarantee an open under this profile makes.
179 pub fn write_contact(self) -> WriteContact {
180 match self {
181 Self::Primary { .. } | Self::Maintenance { write: true } => WriteContact::ReadWrite,
182 Self::ReadOnly | Self::Maintenance { write: false } => {
183 WriteContact::ReadOnlyWalSidecars
184 }
185 }
186 }
187}
188
189/// Per-open overrides. The zero-value default defers everything to the
190/// profile.
191#[derive(Debug, Clone, Copy, Default)]
192pub struct OpenOptions {
193 /// Override the profile's busy timeout. A store with a deliberate
194 /// contention policy (for example a lock-arbitration sidecar that waits
195 /// 30s then fails closed) names it here instead of hand-rolling an
196 /// opener. Values beyond SQLite's `int` millisecond range are clamped to
197 /// `i32::MAX` milliseconds.
198 pub busy_timeout: Option<Duration>,
199 /// Schema domains whose version and exact owned catalog are checked
200 /// BEFORE any mutating pragma runs. Future, pre-floor, gap,
201 /// unledgered-owned, and fingerprint-mismatched shapes are refused with
202 /// logical content unmodified — sidecars may still be touched because
203 /// reading the
204 /// ledger of a WAL-mode file over a read-write connection contacts its
205 /// `-wal`/`-shm` files (see [`WriteContact::ReadOnlyWalSidecars`]).
206 /// Without the preflight, a Primary open would convert an ineligible
207 /// database's journal mode before the ledger refusal in
208 /// [`crate::ledger::apply_domain_migrations`] ever fires. The check runs
209 /// unconditionally after every open — never gated on a pre-open
210 /// filesystem probe, which a concurrent creator could race. A missing
211 /// ledger row passes only for a fresh domain with zero owned objects.
212 /// Empty (the default) skips the preflight.
213 pub schema_preflight: &'static [&'static SchemaDomain],
214}
215
216/// Open `path` under `profile` with the profile's defaults.
217pub fn open(path: &Path, profile: ConnectionProfile) -> Result<Connection, SqliteStoreError> {
218 open_with(path, profile, OpenOptions::default())
219}
220
221/// Open `path` under `profile` with per-open overrides.
222pub fn open_with(
223 path: &Path,
224 profile: ConnectionProfile,
225 options: OpenOptions,
226) -> Result<Connection, SqliteStoreError> {
227 let conn = match profile {
228 ConnectionProfile::Primary { create: true } => {
229 if let Some(parent) = path.parent() {
230 std::fs::create_dir_all(parent)?;
231 }
232 Connection::open(path)?
233 }
234 ConnectionProfile::Primary { create: false } => {
235 open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_WRITE)?
236 }
237 ConnectionProfile::ReadOnly => {
238 open_existing(path, profile, OpenFlags::SQLITE_OPEN_READ_ONLY)?
239 }
240 ConnectionProfile::Maintenance { write } => {
241 let base = if write {
242 OpenFlags::SQLITE_OPEN_READ_WRITE
243 } else {
244 OpenFlags::SQLITE_OPEN_READ_ONLY
245 };
246 open_existing(path, profile, base)?
247 }
248 };
249
250 let busy = options
251 .busy_timeout
252 .unwrap_or_else(|| profile.default_busy_timeout())
253 .min(MAX_BUSY_TIMEOUT);
254 conn.busy_timeout(busy)?;
255
256 // Schema eligibility preflight runs before any mutating pragma: a refusal
257 // leaves the database's logical content unmodified (WAL-mode
258 // files may see sidecar contact from the ledger read). It is not gated
259 // on a pre-open filesystem probe — a database created by another process
260 // between such a probe and the open would dodge the check. A fresh
261 // create carries no ledger table and passes inside
262 // `preflight_schema_eligibility` itself.
263 for domain in options.schema_preflight {
264 crate::ledger::preflight_schema_eligibility(&conn, domain)?;
265 }
266
267 match profile.journal_policy() {
268 JournalPolicy::EstablishWal => {
269 set_wal_journal_mode(&conn, path, busy)?;
270 // The durable-writer pair: WAL for reader/writer separation,
271 // `synchronous=FULL` for the commit durability that goes with it.
272 conn.pragma_update(None, "synchronous", "FULL")?;
273 }
274 JournalPolicy::PreserveExisting => {}
275 }
276
277 Ok(conn)
278}
279
280/// Convert (or confirm) the WAL journal mode with a bounded retry, verified
281/// against the effective mode SQLite reports back.
282///
283/// Converting a rollback-journal database to WAL needs an exclusive lock, and
284/// SQLite can return `SQLITE_BUSY` from the journal-mode pragma WITHOUT
285/// consulting the busy handler while another connection holds the file. Once
286/// a file is WAL the pragma is a lock-free no-op, so the retry only ever
287/// spins while an existing rollback-journal file is being converted (the
288/// first-create race, or the one-time conversion of a legacy database).
289///
290/// Both ways this can end are failures of the open, not degraded successes: a
291/// non-WAL effective mode is [`SqliteStoreError::WalNotEstablished`], and an
292/// exhausted retry budget is [`SqliteStoreError::WalConversionContended`].
293fn set_wal_journal_mode(
294 conn: &Connection,
295 path: &Path,
296 busy_timeout: Duration,
297) -> Result<(), SqliteStoreError> {
298 // Bound the retry by the connection's busy policy (with a small floor so
299 // zero-timeout profiles still tolerate the momentary create race).
300 let budget = busy_timeout.max(Duration::from_millis(250));
301 let deadline = std::time::Instant::now() + budget;
302 loop {
303 // `pragma_update` would discard the mode the pragma returns, and
304 // SQLite can decline the conversion without raising an error — so
305 // the returned mode is read back and anything that is not WAL is a
306 // typed failure. In-memory databases have no on-disk journal and
307 // always report `memory`; they are accepted as-is.
308 let result = conn
309 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0));
310 match result {
311 Ok(mode) if mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("memory") => {
312 return Ok(());
313 }
314 Ok(mode) => {
315 return Err(SqliteStoreError::WalNotEstablished {
316 path: path.to_path_buf(),
317 actual: mode,
318 });
319 }
320 Err(error) if crate::error::is_busy_or_locked(&error) => {
321 if std::time::Instant::now() < deadline {
322 std::thread::sleep(Duration::from_millis(5));
323 continue;
324 }
325 // The retry budget is spent and the file is still locked by
326 // someone else. Fail the open typed rather than hand back a
327 // bare "database is locked", which reads as ordinary
328 // contention and hides that the store would otherwise serve
329 // durable traffic from a rollback-journal database.
330 return Err(SqliteStoreError::WalConversionContended {
331 path: path.to_path_buf(),
332 waited_ms: u64::try_from(budget.as_millis()).unwrap_or(u64::MAX),
333 source: error,
334 });
335 }
336 Err(error) => return Err(error.into()),
337 }
338 }
339}
340
341fn open_existing(
342 path: &Path,
343 profile: ConnectionProfile,
344 base: OpenFlags,
345) -> Result<Connection, SqliteStoreError> {
346 if !path.is_file() {
347 return Err(SqliteStoreError::OpenRefused {
348 path: path.to_path_buf(),
349 profile: profile.name(),
350 detail: "database file does not exist".to_string(),
351 });
352 }
353 Ok(Connection::open_with_flags(
354 path,
355 base | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX,
356 )?)
357}
358
359/// Begin an IMMEDIATE transaction. Bounded waiting for the write lock is the
360/// connection busy handler's job (set at open per profile), not a caller
361/// retry loop.
362pub fn begin_immediate(conn: &mut Connection) -> Result<Transaction<'_>, SqliteStoreError> {
363 Ok(conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
364}
365
366#[cfg(test)]
367#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
368mod tests {
369 use std::path::PathBuf;
370
371 use super::*;
372
373 fn sidecar_paths(path: &Path) -> (PathBuf, PathBuf) {
374 let mut wal = path.as_os_str().to_os_string();
375 wal.push("-wal");
376 let mut shm = path.as_os_str().to_os_string();
377 shm.push("-shm");
378 (PathBuf::from(wal), PathBuf::from(shm))
379 }
380
381 fn journal_mode(conn: &Connection) -> String {
382 conn.pragma_query_value(None, "journal_mode", |row| row.get(0))
383 .expect("journal_mode")
384 }
385
386 /// Seed a rollback-journal ("delete") database with one row, exactly the
387 /// shape a host that predates the WAL policy carries on disk.
388 fn seed_delete_mode_database(path: &Path) {
389 let conn = Connection::open(path).expect("create");
390 conn.execute_batch("CREATE TABLE legacy (x INTEGER); INSERT INTO legacy VALUES (7);")
391 .expect("seed");
392 assert_eq!(journal_mode(&conn), "delete", "fixture must start non-WAL");
393 }
394
395 #[test]
396 fn primary_creates_dirs_sets_wal_and_full() {
397 let dir = tempfile::tempdir().expect("tempdir");
398 let path = dir.path().join("nested/sub/test.sqlite3");
399 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
400 let journal: String = conn
401 .pragma_query_value(None, "journal_mode", |r| r.get(0))
402 .expect("journal_mode");
403 assert_eq!(journal, "wal");
404 let sync: i64 = conn
405 .pragma_query_value(None, "synchronous", |r| r.get(0))
406 .expect("synchronous");
407 assert_eq!(sync, 2, "synchronous=FULL");
408 }
409
410 #[test]
411 fn non_creating_profiles_refuse_missing_file() {
412 let dir = tempfile::tempdir().expect("tempdir");
413 let path = dir.path().join("missing.sqlite3");
414 for profile in [
415 ConnectionProfile::Primary { create: false },
416 ConnectionProfile::ReadOnly,
417 ConnectionProfile::Maintenance { write: true },
418 ConnectionProfile::Maintenance { write: false },
419 ] {
420 let err = open(&path, profile).expect_err("must refuse missing file");
421 assert!(
422 matches!(err, SqliteStoreError::OpenRefused { .. }),
423 "{profile:?}"
424 );
425 }
426 assert!(
427 !path.exists(),
428 "no profile may create the file as a side effect"
429 );
430 }
431
432 #[test]
433 fn read_only_profile_rejects_writes_and_preserves_journal_mode() {
434 let dir = tempfile::tempdir().expect("tempdir");
435 let path = dir.path().join("db.sqlite3");
436 {
437 let conn = Connection::open(&path).expect("create");
438 conn.execute_batch("CREATE TABLE t (x INTEGER)")
439 .expect("ddl");
440 }
441 let conn = open(&path, ConnectionProfile::ReadOnly).expect("open ro");
442 let journal: String = conn
443 .pragma_query_value(None, "journal_mode", |r| r.get(0))
444 .expect("journal_mode");
445 assert_eq!(
446 journal, "delete",
447 "reader must not convert the journal mode"
448 );
449 conn.execute("INSERT INTO t VALUES (1)", [])
450 .expect_err("read-only connection must reject writes");
451 }
452
453 #[test]
454 fn maintenance_profile_fails_fast_on_held_lock() {
455 let dir = tempfile::tempdir().expect("tempdir");
456 let path = dir.path().join("db.sqlite3");
457 let mut writer = open(&path, ConnectionProfile::PRIMARY).expect("writer");
458 writer
459 .execute_batch("CREATE TABLE t (x INTEGER)")
460 .expect("ddl");
461 let tx = begin_immediate(&mut writer).expect("hold write lock");
462
463 let maint = open(&path, ConnectionProfile::Maintenance { write: true }).expect("open");
464 let err = maint
465 .execute_batch("BEGIN IMMEDIATE")
466 .expect_err("zero busy timeout must surface the held lock immediately");
467 assert!(crate::error::is_busy_or_locked(&match err {
468 rusqlite::Error::SqliteFailure(..) => err,
469 other => panic!("unexpected error shape: {other}"),
470 }));
471 drop(tx);
472 }
473
474 #[test]
475 fn busy_timeout_override_applies() {
476 let dir = tempfile::tempdir().expect("tempdir");
477 let path = dir.path().join("db.sqlite3");
478 let conn = open_with(
479 &path,
480 ConnectionProfile::PRIMARY,
481 OpenOptions {
482 busy_timeout: Some(Duration::from_millis(1234)),
483 ..OpenOptions::default()
484 },
485 )
486 .expect("open");
487 let timeout: i64 = conn
488 .pragma_query_value(None, "busy_timeout", |r| r.get(0))
489 .expect("busy_timeout");
490 assert_eq!(timeout, 1234);
491 }
492
493 #[test]
494 fn oversized_busy_timeout_is_clamped_not_panicking() {
495 let dir = tempfile::tempdir().expect("tempdir");
496 let path = dir.path().join("db.sqlite3");
497 let conn = open_with(
498 &path,
499 ConnectionProfile::PRIMARY,
500 OpenOptions {
501 busy_timeout: Some(Duration::MAX),
502 ..OpenOptions::default()
503 },
504 )
505 .expect("oversized timeout must clamp, not panic");
506 let timeout: i64 = conn
507 .pragma_query_value(None, "busy_timeout", |r| r.get(0))
508 .expect("busy_timeout");
509 assert_eq!(timeout, i64::from(i32::MAX));
510 }
511
512 fn preflight_base(tx: &Transaction<'_>) -> Result<(), rusqlite::Error> {
513 tx.execute_batch("CREATE TABLE IF NOT EXISTS preflight_t (x INTEGER)")
514 }
515
516 const PREFLIGHT_DOMAIN: SchemaDomain = SchemaDomain {
517 name: "preflight-domain",
518 migrations: &[crate::ledger::Migration {
519 version: 1,
520 name: "base",
521 apply: preflight_base,
522 }],
523 initialize_current: preflight_base,
524 allowed_existing_versions: &[1],
525 bridge_recoverable_versions: &[1],
526 released_predecessors: &[],
527 owned_objects: &[crate::ledger::SchemaObject {
528 kind: crate::ledger::SchemaObjectKind::Table,
529 name: "preflight_t",
530 }],
531 retired_objects: &[],
532 };
533
534 #[test]
535 fn schema_preflight_refuses_future_file_before_wal_conversion() {
536 let dir = tempfile::tempdir().expect("tempdir");
537 let path = dir.path().join("db.sqlite3");
538 {
539 // A rollback-journal file stamped by a "newer binary". The
540 // create-capable profile below must refuse it all the same:
541 // the preflight consults only the opened connection's ledger,
542 // so a file created by another process at any point before the
543 // open (there is no pre-open existence probe to race) is
544 // classified by its content, not by who created it.
545 let conn = Connection::open(&path).expect("create raw");
546 conn.execute_batch(
547 "CREATE TABLE meerkat_schema (domain TEXT PRIMARY KEY, version INTEGER NOT NULL);
548 INSERT INTO meerkat_schema VALUES ('preflight-domain', 999);",
549 )
550 .expect("seed future ledger");
551 }
552 let err = open_with(
553 &path,
554 ConnectionProfile::PRIMARY,
555 OpenOptions {
556 schema_preflight: &[&PREFLIGHT_DOMAIN],
557 ..OpenOptions::default()
558 },
559 )
560 .expect_err("future file must be refused");
561 assert!(matches!(
562 err,
563 SqliteStoreError::SchemaFromTheFuture {
564 found: 999,
565 supported: 1,
566 ..
567 }
568 ));
569 // The refusal fired before the mutating pragma: the file still has
570 // its original rollback journal mode.
571 let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
572 .expect("reopen raw");
573 let journal: String = conn
574 .pragma_query_value(None, "journal_mode", |r| r.get(0))
575 .expect("journal_mode");
576 assert_eq!(journal, "delete");
577 }
578
579 #[test]
580 fn schema_preflight_allows_fresh_create_and_current_files() {
581 let dir = tempfile::tempdir().expect("tempdir");
582 let path = dir.path().join("fresh.sqlite3");
583 let options = OpenOptions {
584 schema_preflight: &[&PREFLIGHT_DOMAIN],
585 ..OpenOptions::default()
586 };
587 // The preflight runs on fresh creates too (no filesystem-based
588 // exemption); it passes because the new file has no ledger table.
589 let mut conn = open_with(&path, ConnectionProfile::PRIMARY, options)
590 .expect("fresh create carries no ledger table and passes preflight");
591 crate::ledger::apply_domain_migrations(&mut conn, &PREFLIGHT_DOMAIN).expect("stamp");
592 drop(conn);
593 open_with(&path, ConnectionProfile::PRIMARY, options)
594 .expect("current file passes preflight");
595 }
596
597 #[test]
598 fn schema_preflight_refuses_current_fingerprint_mismatch_before_wal_conversion() {
599 let dir = tempfile::tempdir().expect("tempdir");
600 let path = dir.path().join("partial.sqlite3");
601 {
602 let conn = Connection::open(&path).expect("create raw");
603 conn.execute_batch(
604 "CREATE TABLE preflight_t (x INTEGER, candidate_only TEXT);
605 CREATE TABLE meerkat_schema (
606 domain TEXT PRIMARY KEY,
607 version INTEGER NOT NULL
608 );
609 INSERT INTO meerkat_schema VALUES ('preflight-domain', 1);",
610 )
611 .expect("seed partial current");
612 }
613 let err = open_with(
614 &path,
615 ConnectionProfile::PRIMARY,
616 OpenOptions {
617 schema_preflight: &[&PREFLIGHT_DOMAIN],
618 ..OpenOptions::default()
619 },
620 )
621 .expect_err("partial current must be refused");
622 assert!(matches!(
623 err,
624 SqliteStoreError::SchemaFingerprintMismatch { version: 1, .. }
625 ));
626 let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY)
627 .expect("reopen raw");
628 let journal: String = conn
629 .pragma_query_value(None, "journal_mode", |row| row.get(0))
630 .expect("journal mode");
631 assert_eq!(journal, "delete");
632 }
633
634 #[test]
635 fn primary_in_memory_reports_memory_journal_and_opens() {
636 // In-memory databases cannot hold WAL; the verified journal-mode
637 // setup accepts their `memory` reading instead of refusing them.
638 let conn = open(Path::new(":memory:"), ConnectionProfile::PRIMARY).expect("open");
639 let journal: String = conn
640 .pragma_query_value(None, "journal_mode", |r| r.get(0))
641 .expect("journal_mode");
642 assert_eq!(journal, "memory");
643 }
644
645 #[test]
646 fn write_contact_names_the_wal_sidecar_caveat() {
647 assert_eq!(
648 ConnectionProfile::PRIMARY.write_contact(),
649 WriteContact::ReadWrite
650 );
651 assert_eq!(
652 ConnectionProfile::Primary { create: false }.write_contact(),
653 WriteContact::ReadWrite
654 );
655 assert_eq!(
656 ConnectionProfile::Maintenance { write: true }.write_contact(),
657 WriteContact::ReadWrite
658 );
659 assert_eq!(
660 ConnectionProfile::ReadOnly.write_contact(),
661 WriteContact::ReadOnlyWalSidecars
662 );
663 assert_eq!(
664 ConnectionProfile::Maintenance { write: false }.write_contact(),
665 WriteContact::ReadOnlyWalSidecars
666 );
667 }
668
669 #[test]
670 fn journal_policy_is_pinned_for_every_profile() {
671 // A new profile must choose a policy here rather than inherit
672 // "not Primary, therefore no WAL" by omission. `Maintenance { write:
673 // true }` is read-write and still preserves the mode it finds: see
674 // `ConnectionProfile::journal_policy` for why.
675 for (profile, expected) in [
676 (ConnectionProfile::PRIMARY, JournalPolicy::EstablishWal),
677 (
678 ConnectionProfile::Primary { create: false },
679 JournalPolicy::EstablishWal,
680 ),
681 (ConnectionProfile::ReadOnly, JournalPolicy::PreserveExisting),
682 (
683 ConnectionProfile::Maintenance { write: true },
684 JournalPolicy::PreserveExisting,
685 ),
686 (
687 ConnectionProfile::Maintenance { write: false },
688 JournalPolicy::PreserveExisting,
689 ),
690 ] {
691 assert_eq!(profile.journal_policy(), expected, "{profile:?}");
692 }
693 }
694
695 #[test]
696 fn primary_open_converts_an_existing_delete_mode_database_and_keeps_its_content() {
697 // The migration path for a host whose file predates the WAL policy:
698 // the ordinary production open converts it, in place, on open.
699 let dir = tempfile::tempdir().expect("tempdir");
700 let path = dir.path().join("legacy.sqlite3");
701 seed_delete_mode_database(&path);
702
703 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
704 assert_eq!(journal_mode(&conn), "wal");
705 let row: i64 = conn
706 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
707 .expect("content survives the conversion");
708 assert_eq!(row, 7);
709 drop(conn);
710
711 // The conversion is a property of the file, so it outlives the
712 // connection that performed it.
713 let reopened = open(&path, ConnectionProfile::ReadOnly).expect("reopen");
714 assert_eq!(journal_mode(&reopened), "wal");
715 }
716
717 #[test]
718 fn non_wal_profiles_leave_an_existing_delete_mode_file_alone() {
719 // The deliberate exclusion, pinned behaviorally: the offline
720 // surgeon's read-write profile must not convert a file another
721 // maintenance step may be about to archive or relocate.
722 let dir = tempfile::tempdir().expect("tempdir");
723 let path = dir.path().join("legacy.sqlite3");
724 seed_delete_mode_database(&path);
725
726 for profile in [
727 ConnectionProfile::Maintenance { write: true },
728 ConnectionProfile::Maintenance { write: false },
729 ConnectionProfile::ReadOnly,
730 ] {
731 let conn = open(&path, profile).expect("open");
732 assert_eq!(journal_mode(&conn), "delete", "{profile:?}");
733 }
734 }
735
736 #[test]
737 fn wal_database_at_rest_has_no_sidecars_and_still_reports_wal() {
738 // The at-rest shape an operator diagnoses by hand. These stores keep
739 // no long-lived connection, so the steady state of a healthy WAL
740 // database is: no `-wal`, no `-shm`, WAL recorded in the header.
741 // Absent sidecars are not evidence of a rollback-journal file.
742 let dir = tempfile::tempdir().expect("tempdir");
743 let path = dir.path().join("at-rest.sqlite3");
744 {
745 let conn = open(&path, ConnectionProfile::PRIMARY).expect("open");
746 conn.execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
747 .expect("write");
748 assert_eq!(journal_mode(&conn), "wal");
749 }
750
751 let (wal, shm) = sidecar_paths(&path);
752 assert!(!wal.exists(), "a closed WAL database keeps no -wal");
753 assert!(!shm.exists(), "a closed WAL database keeps no -shm");
754
755 // Header bytes 18/19 are the file-format read/write versions; 2 means
756 // WAL. This is the byte-level authority the pragma reports from.
757 let header = std::fs::read(&path).expect("read header");
758 assert_eq!(header.get(18).copied(), Some(2), "read version");
759 assert_eq!(header.get(19).copied(), Some(2), "write version");
760
761 // Both ordinary opens report the truth with no sidecars present.
762 assert_eq!(
763 journal_mode(&open(&path, ConnectionProfile::ReadOnly).expect("read-only reopen")),
764 "wal"
765 );
766 assert_eq!(
767 journal_mode(&open(&path, ConnectionProfile::PRIMARY).expect("primary reopen")),
768 "wal"
769 );
770 }
771
772 #[test]
773 fn wal_conversion_that_cannot_win_the_lock_fails_typed() {
774 let dir = tempfile::tempdir().expect("tempdir");
775 let path = dir.path().join("contended.sqlite3");
776 seed_delete_mode_database(&path);
777
778 // A reader parked inside a read transaction holds the SHARED lock
779 // that converting to WAL needs to escalate past. Only a
780 // rollback-journal file can contend here: once a file is WAL the
781 // pragma is a lock-free no-op.
782 let reader = Connection::open(&path).expect("reader");
783 reader
784 .execute_batch("BEGIN; SELECT count(*) FROM legacy;")
785 .expect("hold the shared lock");
786
787 let err = open_with(
788 &path,
789 ConnectionProfile::PRIMARY,
790 OpenOptions {
791 busy_timeout: Some(Duration::ZERO),
792 ..OpenOptions::default()
793 },
794 )
795 .expect_err("a durable read-write open must fail closed, not run in delete mode");
796 assert!(
797 matches!(
798 err,
799 SqliteStoreError::WalConversionContended { path: ref refused, waited_ms, .. }
800 if refused == &path && waited_ms >= 250
801 ),
802 "{err}"
803 );
804
805 // The refusal left the database exactly as found.
806 reader.execute_batch("COMMIT").expect("release");
807 assert_eq!(journal_mode(&reader), "delete");
808 let row: i64 = reader
809 .query_row("SELECT x FROM legacy", [], |row| row.get(0))
810 .expect("content untouched");
811 assert_eq!(row, 7);
812 }
813}