Rindle docs and package mapSkip to main content

rindle_cdc_apply/
connection.rs

1//! The journal-aware connection-opening ritual every apply-plane (and engine) connection
2//! goes through: journal-mode negotiation (design 306 D3/D5), the hardening/planner
3//! pragmas, and the `regexp` registration that keeps every read surface answering the
4//! same SQL the write master does.
5//!
6//! Lifted out of `rindle-replica`'s `parallel` module by the `rindle-cdc-apply`
7//! extraction (design 309): the ritual is `rusqlite` + `rindle-regex`, engine-free, and
8//! the headless apply store must open its connections EXACTLY the way the live
9//! follower's coordinator does — same journal negotiation, same pragmas — or the
10//! byte-identity argument between the two hosts breaks at the connection boundary.
11
12use std::path::Path;
13
14use rusqlite::{Connection, OpenFlags};
15
16use rindle_writeplane::{set_foreign_keys, ForeignKeys, JournalMode, ReplicaError};
17
18/// Cap how many rows `ANALYZE` / `PRAGMA optimize` samples per index, so a maintenance
19/// tick's stats refresh on the live writer stays sub-millisecond regardless of table
20/// size (see `rindle-replica`'s `maintenance` module, which restores this bound after
21/// its full-`ANALYZE` escape hatch). Applied to every connection opened here.
22pub const ANALYSIS_LIMIT: u32 = 400;
23
24/// True for the WAL-family modes the engine runs on (either accepted — D3).
25fn is_wal_family(mode: &str) -> bool {
26    mode.eq_ignore_ascii_case("wal") || mode.eq_ignore_ascii_case("wal2")
27}
28
29/// Open one read-write connection (mirrors `Db::open`'s per-connection setup). Used for
30/// the apply store's writer, the cluster coordinator's writer, and each IVM worker.
31///
32/// `foreign_keys` is the connection's enforcement posture and has no default here: an
33/// **origin** write plane (the standalone daemon, the HCTree master) says
34/// [`ForeignKeys::Enforced`], an **apply** plane (a follower, a headless replay) says
35/// [`ForeignKeys::Unenforced`] because the rows it writes were already validated by the
36/// authority whose cascades are themselves in the stream. See
37/// [`rindle_writeplane::foreign_keys`] for the full argument, and
38/// [`rindle_writeplane::foreign_key_check`] for the audit that works either way.
39///
40/// Journal handling (design 306 D3/D5): under the default `Wal` request a file
41/// already in `wal` or `wal2` is accepted as-is; anything else (a fresh file's
42/// `delete`, a rollback-mode file) is converted to the requested mode. An explicit
43/// `Wal2` request additionally attempts to convert a plain-`wal` file and — because
44/// the pragma silently reports `wal` when the switch cannot happen (an
45/// un-checkpointed WAL, other connections; `docs/SQLITE_WAL2.md` §10.3) — **verifies
46/// the outcome and fails loudly** rather than running a wal2-contracted daemon on a
47/// plain-wal store (the post-condition the retired `open_wal2` enforced).
48pub fn open_journal(
49    path: &Path,
50    journal: JournalMode,
51    foreign_keys: ForeignKeys,
52) -> Result<Connection, ReplicaError> {
53    let conn = Connection::open(path).map_err(|e| ReplicaError::sqlite("open", e))?;
54    let mut mode: String = conn
55        .query_row("PRAGMA journal_mode", [], |r| r.get(0))
56        .map_err(|e| ReplicaError::sqlite("read journal_mode", e))?;
57    let wal2_required = journal == JournalMode::Wal2;
58    if !is_wal_family(&mode) || (wal2_required && !mode.eq_ignore_ascii_case("wal2")) {
59        mode = conn
60            .query_row(journal.pragma(), [], |r| r.get(0))
61            .map_err(|e| ReplicaError::sqlite("set journal_mode", e))?;
62    }
63    if !is_wal_family(&mode) {
64        return Err(ReplicaError::Open(format!(
65            "expected a wal/wal2 journal mode, got {mode:?} (use a file-backed path)"
66        )));
67    }
68    if wal2_required && !mode.eq_ignore_ascii_case("wal2") {
69        return Err(ReplicaError::Open(format!(
70            "journal_mode=wal2 was requested but the store is {mode:?} and could not be \
71             converted (an un-checkpointed wal file or a concurrent connection — \
72             docs/SQLITE_WAL2.md §10.3); refusing to run outside the wal2 fleet contract"
73        )));
74    }
75    let conn = configure_engine_connection(conn)?;
76    set_foreign_keys(&conn, foreign_keys)?;
77    Ok(conn)
78}
79
80/// Set `PRAGMA wal_autocheckpoint = pages` on one connection — the WAL-growth knob a
81/// host that wants to own its checkpoint schedule passes at open
82/// (`OpenOptions::wal_autocheckpoint`; design 410 §3.5).
83///
84/// `0` disables SQLite's automatic checkpoint entirely, so the WAL only ever folds back
85/// on an explicit `PRAGMA wal_checkpoint` (the replica's `maintain`). That is the mobile
86/// posture: a phone would otherwise pay an unpredictable multi-megabyte checkpoint
87/// inside whichever commit happens to cross the threshold — mid-gesture, at 60 Hz — and
88/// would rather take it at a moment of its choosing (gesture end, idle, backgrounding).
89/// A host that does NOT set this keeps SQLite's default of 1000 pages, which is what
90/// every server-side opener wants.
91///
92/// Connection-local (it is not persisted in the file) and only meaningful on the
93/// connection that actually writes, so callers apply it to the writer.
94pub fn set_wal_autocheckpoint(conn: &Connection, pages: u32) -> Result<(), ReplicaError> {
95    conn.execute_batch(&format!("PRAGMA wal_autocheckpoint = {pages}"))
96        .map_err(|e| ReplicaError::sqlite("wal_autocheckpoint", e))
97}
98
99/// Open a physically read-only connection for the public `read` surface. `PRAGMA
100/// query_only` is only a mutable connection setting; opening with
101/// `SQLITE_OPEN_READ_ONLY` is what prevents a callback from turning it off and mutating
102/// the durable database behind CDC.
103///
104/// The writer must have opened the database first, so a WAL-family journal mode is
105/// already persistent on the file. This opener verifies that (accepting `wal` or `wal2`
106/// — D3) without trying to change it, then applies the same connection-local
107/// planner/read pragmas as every other engine connection.
108///
109/// Takes no [`ForeignKeys`] posture, unlike its read-write peers: `PRAGMA foreign_keys`
110/// governs whether a *write* is refused and whether a declared `ON DELETE`/`ON UPDATE`
111/// action fires, and this connection can do neither. `PRAGMA foreign_key_check` — the
112/// audit — reads the same answer here whatever the pragma says
113/// ([`rindle_writeplane::foreign_key_check`]), which is precisely why the audit is the
114/// read surface's tool and the pragma is not.
115pub fn open_journal_read_only(path: &Path) -> Result<Connection, ReplicaError> {
116    let conn = Connection::open_with_flags(
117        path,
118        OpenFlags::SQLITE_OPEN_READ_ONLY
119            | OpenFlags::SQLITE_OPEN_NO_MUTEX
120            | OpenFlags::SQLITE_OPEN_URI,
121    )
122    .map_err(|e| ReplicaError::sqlite("open read-only", e))?;
123    // An attached schema gets its own open flags and could otherwise re-open this same file
124    // read-write under another schema name. Keep the raw-Connection callback confined to `main`.
125    conn.set_limit(rusqlite::limits::Limit::SQLITE_LIMIT_ATTACHED, 0);
126    let mode: String = conn
127        .query_row("PRAGMA journal_mode", [], |r| r.get(0))
128        .map_err(|e| ReplicaError::sqlite("read journal_mode", e))?;
129    if !is_wal_family(&mode) {
130        return Err(ReplicaError::Open(format!(
131            "expected a wal/wal2 journal mode, got {mode:?} (use a file-backed path)"
132        )));
133    }
134    configure_engine_connection(conn)
135}
136
137/// Open the single connection of a **scratch** store: a database that is DERIVED from
138/// somewhere else (today, `rindle-backup`'s producer scratch, rebuilt from the archive)
139/// and is thrown away and rebuilt whenever it is not known to have been closed cleanly.
140///
141/// It trades the two durability properties the shared ritual above buys — and nothing
142/// else; every other pragma is identical, so the store it materializes is the same store:
143///
144/// * `journal_mode = memory` — the rollback journal lives in RAM, so a commit writes each
145///   page exactly once (no WAL, and therefore no checkpoint rewriting every page into the
146///   main file). `ROLLBACK` still works, so a failed frame still unwinds cleanly; what is
147///   lost is only crash recovery, since a process that dies mid-transaction leaves no hot
148///   journal on disk to roll back. Connection-local and NOT persisted in the file, so
149///   every reopen (a DDL bounce, the next quantum) must come back through here.
150/// * `synchronous = OFF` — no fsync on the commit or the checkpoint path.
151///
152/// Neither is safe for a database anyone must be able to recover: after an OS crash or
153/// power loss the file may be torn in ways `PRAGMA quick_check` cannot see (a lost leaf
154/// page is structurally valid and silently stale). **The caller owes the other half of
155/// the bargain**: it must record cleanliness out of band and rebuild from source rather
156/// than reuse a scratch that is not known-clean — see `rindle_backup_sqlite::producer`'s
157/// `ScratchState::clean`, which is written and fsynced only after a successful seal.
158pub fn open_scratch(path: &Path, foreign_keys: ForeignKeys) -> Result<Connection, ReplicaError> {
159    let conn = Connection::open(path).map_err(|e| ReplicaError::sqlite("open scratch", e))?;
160    // Like the wal2 request above, the pragma reports the mode it actually ended up in
161    // rather than failing, so verify it: a scratch that silently stayed in wal/wal2 would
162    // quietly cost the double write this opener exists to avoid.
163    let mode: String = conn
164        .query_row("PRAGMA journal_mode = memory", [], |r| r.get(0))
165        .map_err(|e| ReplicaError::sqlite("set journal_mode=memory", e))?;
166    if !mode.eq_ignore_ascii_case("memory") {
167        return Err(ReplicaError::Open(format!(
168            "scratch journal_mode=memory was requested but the file is {mode:?} and could \
169             not be converted (an un-checkpointed wal file or a concurrent connection)"
170        )));
171    }
172    conn.execute_batch("PRAGMA synchronous = OFF;")
173        .map_err(|e| ReplicaError::sqlite("synchronous=OFF", e))?;
174    let conn = configure_shared_pragmas(conn)?;
175    set_foreign_keys(&conn, foreign_keys)?;
176    Ok(conn)
177}
178
179fn configure_engine_connection(conn: Connection) -> Result<Connection, ReplicaError> {
180    // Lever A — follower durability (REPLICATOR-THROUGHPUT-AND-STREAMING-DESIGN.md §2.1): drop the
181    // per-commit WAL fsync, matching `WriteMaster` on the relay side. `synchronous=NORMAL` is
182    // crash-safe under wal/wal2 (no corruption ever; survives process death) and only relaxes
183    // durability against power loss / OS crash — the last not-yet-checkpointed commits. A follower
184    // is fully recoverable from the master's stream + its durable cursor, so that loss is already
185    // covered out of band; `FULL` (the WAL default we'd otherwise inherit) would fsync every commit
186    // for a guarantee this topology discards. `NORMAL` is a checkpoint-sync relaxation, orthogonal
187    // to the wal/wal2 file layout. Set on every connection here — the coordinator's writer/reader
188    // and each worker — since they all open through `open_journal`.
189    conn.execute_batch("PRAGMA synchronous = NORMAL;")
190        .map_err(|e| ReplicaError::sqlite("synchronous=NORMAL", e))?;
191    configure_shared_pragmas(conn)
192}
193
194/// Everything on an engine connection that is independent of its durability posture —
195/// shared verbatim by [`configure_engine_connection`] and [`open_scratch`], so the two
196/// differ ONLY in journal mode and `synchronous` and answer the same SQL as each other
197/// and as the write master.
198fn configure_shared_pragmas(conn: Connection) -> Result<Connection, ReplicaError> {
199    // Cap how many rows `ANALYZE` / `PRAGMA optimize` samples per index, so the maintenance tick's
200    // stats refresh on the live writer stays sub-millisecond regardless of table size (see the
201    // `maintenance` module). Per-connection; harmless on the readers/workers that never optimize.
202    //
203    // NOTE: we intentionally do NOT set `auto_vacuum=INCREMENTAL` or `busy_timeout` here. Both
204    // perturb the verified concurrency core — incremental auto-vacuum changes the page allocation
205    // BEGIN CONCURRENT's conflict detection / fault recovery were validated against, and a long
206    // `busy_timeout` turns a fast-fail lock contention into a stall. The maintenance vacuum step is
207    // therefore opt-in per database (`maintenance` module docs).
208    conn.execute_batch(&format!("PRAGMA analysis_limit = {ANALYSIS_LIMIT}"))
209        .map_err(|e| ReplicaError::sqlite("analysis_limit", e))?;
210    // Bare `LIKE` must agree with the engine's in-memory matcher and `rindle-d2s` (both
211    // case-sensitive; the IVM `TableSource` connection sets the same pragma) — otherwise a
212    // mutator-session read or raw `/execute-sql-read` silently matches case-insensitively
213    // while every maintained view matches case-sensitively. Per-connection; set on every
214    // cluster connection since they all open through these openers.
215    conn.execute_batch("PRAGMA case_sensitive_like = ON;")
216        .map_err(|e| ReplicaError::sqlite("case_sensitive_like", e))?;
217    // `regexp` for the same reason as the pragma above: SQLite has no built-in function for
218    // the operator, so without this every read surface that reaches a store through these
219    // openers answers `x REGEXP y` with "no such function" while the master's writer answers
220    // it. Registered on every cluster connection since they all open through here.
221    rindle_regex::register(&conn).map_err(|e| ReplicaError::sqlite("register regexp", e))?;
222    // Query Planner Stability Guarantee. The engine reuses cached prepared statements
223    // (`prepare_cached`) for the IVM leaf seeks, and re-runs each one — same shape, new
224    // bound key — millions of times across derivation. Without QPSG, this SQLite build
225    // lets the planner consult bound-parameter VALUES (STAT4 and other value-based
226    // optimizations), which sets the statement's `expmask`; re-binding then EXPIRES the
227    // cached VM, and the next `step()` does a full SQLite re-PARSE (`sqlite3Reprepare` →
228    // `sqlite3RunParser`). Measured on the derivation fetch path that cost ~2x the
229    // statement's actual execution — the dominant term in the follower's per-write derive,
230    // and it scales O(writes x pipelines). QPSG makes plans value-independent (stable per
231    // shape) so a cached seek is prepared once and reused — exactly what an IVM engine
232    // wants; the equality/range index plans the engine emits are optimal regardless of the
233    // bound value, so QPSG forfeits nothing here. Per-connection; set on every cluster
234    // connection (workers + coordinator) since they all open through here.
235    conn.set_db_config(
236        rusqlite::config::DbConfig::SQLITE_DBCONFIG_ENABLE_QPSG,
237        true,
238    )
239    .map_err(|e| ReplicaError::sqlite("enable QPSG", e))?;
240    Ok(conn)
241}