Rindle docs and package mapSkip to main content

rindle_replica/
maintenance.rs

1//! Online database maintenance — bounded, non-blocking upkeep the server runs on a timer
2//! between commits, so planner stats stay semi-fresh and the file does not bloat under churn,
3//! without ever taking a long lock the way a full `VACUUM` / `ANALYZE` would.
4//!
5//! A maintenance pass ([`run`]) does up to three independently-gated things on one connection, in
6//! this order:
7//!
8//! 1. **`PRAGMA optimize`** — refresh stale `sqlite_stat*` so the cost model keeps choosing good
9//!    plans on a write-heavy replica. `optimize` self-gates per table on a change counter (most
10//!    ticks are near-no-ops), and the [`ANALYSIS_LIMIT`] set at connection open caps how many
11//!    rows the ANALYZE it *does* run samples per index — so a refresh stays sub-millisecond
12//!    regardless of table size. Its only writes land in `sqlite_stat*`, which the CDC capture
13//!    hook skips, so running it on the hooked writer never fabricates a change event.
14//! 2. **`PRAGMA incremental_vacuum(N)`** — return up to `N` freelist pages to the OS, but only once
15//!    at least `freelist_threshold_pages` have accumulated, and only on a database already on
16//!    incremental auto-vacuum (see below). Bounded work, no exclusive lock — unlike a full `VACUUM`
17//!    (a whole-file rewrite under an exclusive lock).
18//! 3. **`PRAGMA wal_checkpoint(PASSIVE)`** — fold WAL frames (including the vacuum's truncation)
19//!    back into the main file, shrinking it on disk and bounding WAL growth. PASSIVE never blocks a
20//!    reader or the writer; a long-lived reader just defers the rest to a later tick.
21//!
22//! ## The vacuum step is opt-in
23//! The runtime does not change `auto_vacuum` at open. A database with `auto_vacuum=NONE`
24//! skips this step and reuses freed pages internally, without returning them to the OS.
25//! Converting an existing database to incremental auto-vacuum requires a `VACUUM` file
26//! rewrite. If that behavior is needed, close the runtime and configure the database
27//! out of band with `PRAGMA auto_vacuum=INCREMENTAL; VACUUM;` before reopening it.
28//! The `optimize` and checkpoint steps run regardless. See
29//! [`Cluster::maintain`](crate::Cluster::maintain).
30
31use rusqlite::Connection;
32
33use crate::ReplicaError;
34
35/// Rows `ANALYZE` / `PRAGMA optimize` samples per index. Set as `PRAGMA analysis_limit` on every
36/// connection at open (the connection ritual lives with the apply plane, which owns the const —
37/// design 309) so a stats refresh on the live writer is bounded to a few hundred rows per
38/// index — fast enough to slot between commits — instead of a full index scan. SQLite's own
39/// recommended value for "good enough" stats; stale stats only ever revert a plan toward the
40/// un-analyzed choice, never worse than no stats.
41pub use crate::apply::ANALYSIS_LIMIT;
42
43/// Which steps a maintenance pass performs and the thresholds that gate them. [`Default`] is the
44/// server's tuning: optimize on, reclaim up to 1000 freelist pages once ≥1000 have built up
45/// (~4 MiB at a 4 KiB page), passive checkpoint on.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct MaintenanceOptions {
48    /// Run `PRAGMA optimize` to refresh stale planner statistics.
49    pub run_optimize: bool,
50    /// Max freelist pages to return to the OS per pass (the `N` in `incremental_vacuum(N)`).
51    /// `0` disables the vacuum step.
52    pub incremental_vacuum_pages: u32,
53    /// Only run the vacuum step once the freelist has at least this many pages — so a quiet
54    /// database is left untouched and the step is not churned for a handful of pages.
55    pub freelist_threshold_pages: u32,
56    /// Take a `PRAGMA wal_checkpoint(PASSIVE)` to bound WAL growth.
57    pub wal_checkpoint: bool,
58}
59
60impl Default for MaintenanceOptions {
61    fn default() -> MaintenanceOptions {
62        MaintenanceOptions {
63            run_optimize: true,
64            incremental_vacuum_pages: 1000,
65            freelist_threshold_pages: 1000,
66            wal_checkpoint: true,
67        }
68    }
69}
70
71/// The three integers a `PRAGMA wal_checkpoint` returns. Moved to the apply plane
72/// (`ApplyStore::checkpoint_truncate` is the TRUNCATE flavor's home — design 309);
73/// re-exported here so `rindle_replica::WalCheckpoint` and the maintenance report are
74/// unchanged.
75pub use crate::apply::WalCheckpoint;
76
77/// What a maintenance pass actually did — returned for observability/tests. A pass that ran while
78/// a write transaction was open reports `skipped`.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub struct MaintenanceReport {
81    /// The pass was a no-op because a write transaction was open (best-effort: maintenance never
82    /// disturbs an in-flight txn). All other fields are at their defaults.
83    pub skipped: bool,
84    /// `PRAGMA optimize` ran.
85    pub optimized: bool,
86    /// Freelist page count observed before the vacuum step (0 if the step did not inspect it).
87    pub freelist_before: u64,
88    /// Pages actually returned to the OS this pass.
89    pub pages_reclaimed: u64,
90    /// The vacuum step ran (the freelist was over threshold on an incremental-vacuum database).
91    pub vacuum_ran: bool,
92    /// The checkpoint result, if the checkpoint step ran.
93    pub wal_checkpoint: Option<WalCheckpoint>,
94}
95
96impl MaintenanceReport {
97    pub(crate) fn skipped() -> MaintenanceReport {
98        MaintenanceReport {
99            skipped: true,
100            ..MaintenanceReport::default()
101        }
102    }
103
104    /// Whether the pass performed any work (ran and did at least one step).
105    pub fn did_work(&self) -> bool {
106        !self.skipped && (self.optimized || self.vacuum_ran || self.wal_checkpoint.is_some())
107    }
108}
109
110/// Run a **full** `ANALYZE` on `conn` (deep planner statistics), temporarily lifting the
111/// connection's [`ANALYSIS_LIMIT`] so SQLite builds the per-value `sqlite_stat4` histogram — not
112/// just the `sqlite_stat1` row counts. The bounded limit is restored afterward (always, even if
113/// `ANALYZE` fails) so the between-commits maintenance tick stays fast.
114///
115/// Why this exists (GitHub #68): with a non-zero `analysis_limit` set on every connection,
116/// `ANALYZE` / `PRAGMA optimize` write `sqlite_stat1` but **zero** `sqlite_stat4` rows, and at the
117/// time the planner needed `stat4` to make an `ORDER BY … LIMIT n` displacement re-fetch's
118/// OR-of-ranges start bound seekable at all — without it, a full covering-index scan per insert.
119///
120/// **That is no longer the reason.** `rindle-sqlite`'s query builder now lifts a redundant
121/// sargable bound on the leading sort column out of that OR-of-ANDs (`f6423231`, 2026-07-01), so
122/// the displacement seeks with no `stat4` present — measured, and pinned by
123/// `rindle-replica/tests/limit_push_cluster.rs`. `stat4` still sharpens the cost model generally
124/// (see the planner designs) and `rindle-loadgen` still calls this, but do NOT reintroduce a claim
125/// that the LIMIT push depends on it.
126///
127/// `stat4` is a slow-changing value-distribution histogram, so it is built deliberately/rarely
128/// (after a bulk load, or on demand) rather than on every cheap maintenance tick. The caller must
129/// hold no open write transaction.
130pub(crate) fn analyze_full(conn: &Connection) -> Result<(), ReplicaError> {
131    conn.execute_batch("PRAGMA analysis_limit = 0")
132        .map_err(|e| ReplicaError::sqlite("analyze_full lift analysis_limit", e))?;
133    // Restore the bounded limit no matter what ANALYZE does, so a later `PRAGMA optimize` tick
134    // stays sub-millisecond regardless of table size.
135    let analyzed = conn.execute_batch("ANALYZE");
136    let restored = conn.execute_batch(&format!("PRAGMA analysis_limit = {ANALYSIS_LIMIT}"));
137    analyzed.map_err(|e| ReplicaError::sqlite("analyze_full ANALYZE", e))?;
138    restored.map_err(|e| ReplicaError::sqlite("analyze_full restore analysis_limit", e))?;
139    Ok(())
140}
141
142/// Run one maintenance pass on `conn` per `opts`. The connection must be in autocommit (no open
143/// transaction) — the callers ([`Cluster::maintain`](crate::Cluster::maintain) /
144/// [`Db::maintain`](crate::Db::maintain)) guarantee this by skipping when a write txn is open.
145pub(crate) fn run(
146    conn: &Connection,
147    opts: &MaintenanceOptions,
148) -> Result<MaintenanceReport, ReplicaError> {
149    let mut report = MaintenanceReport::default();
150
151    if opts.run_optimize {
152        conn.execute_batch("PRAGMA optimize")
153            .map_err(|e| ReplicaError::sqlite("PRAGMA optimize", e))?;
154        report.optimized = true;
155    }
156
157    if opts.incremental_vacuum_pages > 0 {
158        report.pages_reclaimed = incremental_vacuum(conn, opts, &mut report)?;
159    }
160
161    // Checkpoint last so it folds the vacuum's truncation (and the tick's WAL frames) back into the
162    // main file, shrinking it on disk and bounding WAL growth. PASSIVE never blocks a reader or the
163    // writer; a long-lived reader just defers the rest to a later tick.
164    if opts.wal_checkpoint {
165        let (busy, log, ckpt): (i64, i64, i64) = conn
166            .query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |r| {
167                Ok((r.get(0)?, r.get(1)?, r.get(2)?))
168            })
169            .map_err(|e| ReplicaError::sqlite("PRAGMA wal_checkpoint", e))?;
170        report.wal_checkpoint = Some(WalCheckpoint {
171            busy: busy != 0,
172            log_frames: log,
173            checkpointed_frames: ckpt,
174        });
175    }
176
177    Ok(report)
178}
179
180/// Reclaim up to `incremental_vacuum_pages` freelist pages, gated on the freelist being over
181/// `freelist_threshold_pages` and the database being on incremental auto-vacuum. Returns the page
182/// count actually reclaimed (and records `freelist_before` / `vacuum_ran` on `report`).
183fn incremental_vacuum(
184    conn: &Connection,
185    opts: &MaintenanceOptions,
186    report: &mut MaintenanceReport,
187) -> Result<u64, ReplicaError> {
188    // auto_vacuum: 0 = NONE, 1 = FULL, 2 = INCREMENTAL. `incremental_vacuum` only does anything on a
189    // non-NONE database. The runtime does not convert an existing database's page layout at open;
190    // an operator configures this out of band. The default mode returns without work.
191    let mode: i64 = conn
192        .query_row("PRAGMA auto_vacuum", [], |r| r.get(0))
193        .map_err(|e| ReplicaError::sqlite("PRAGMA auto_vacuum", e))?;
194    if mode == 0 {
195        return Ok(0);
196    }
197    let before: i64 = conn
198        .query_row("PRAGMA freelist_count", [], |r| r.get(0))
199        .map_err(|e| ReplicaError::sqlite("PRAGMA freelist_count", e))?;
200    report.freelist_before = before.max(0) as u64;
201    if report.freelist_before < u64::from(opts.freelist_threshold_pages) {
202        return Ok(0);
203    }
204    // `PRAGMA incremental_vacuum(N)` emits ONE result row per freed page and must be stepped to
205    // completion — `execute_batch` would step only once (freeing a single page). Counting the rows
206    // is the exact reclaimed-page count, and the `(N)` bound caps the work per pass.
207    let mut stmt = conn
208        .prepare(&format!(
209            "PRAGMA incremental_vacuum({})",
210            opts.incremental_vacuum_pages
211        ))
212        .map_err(|e| ReplicaError::sqlite("prepare incremental_vacuum", e))?;
213    let mut rows = stmt
214        .query([])
215        .map_err(|e| ReplicaError::sqlite("incremental_vacuum", e))?;
216    let mut reclaimed = 0u64;
217    while rows
218        .next()
219        .map_err(|e| ReplicaError::sqlite("incremental_vacuum step", e))?
220        .is_some()
221    {
222        reclaimed += 1;
223    }
224    report.vacuum_ran = true;
225    Ok(reclaimed)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// Open a lone WAL connection (the D5 default mode) with the same auto-vacuum/analysis
233    /// pragmas the engine sets, so the reclaim mechanic can be tested without any worker
234    /// holding a read mark on the freelist.
235    fn open(path: &std::path::Path) -> Connection {
236        let conn = Connection::open(path).unwrap();
237        conn.execute_batch(&format!(
238            "PRAGMA auto_vacuum=INCREMENTAL;\n\
239             PRAGMA journal_mode=wal;\n\
240             PRAGMA analysis_limit={ANALYSIS_LIMIT};"
241        ))
242        .unwrap();
243        conn
244    }
245
246    #[test]
247    fn incremental_vacuum_drains_the_freelist_when_stepped() {
248        let dir = tempfile::tempdir().unwrap();
249        let conn = open(&dir.path().join("m.db"));
250        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
251            .unwrap();
252        let pad = "x".repeat(200);
253        for id in 0..5_000i64 {
254            conn.execute(
255                "INSERT INTO t(id, v) VALUES (?, ?)",
256                rusqlite::params![id, format!("{pad}-{id}")],
257            )
258            .unwrap();
259        }
260        conn.execute("DELETE FROM t", []).unwrap();
261        let freelist: i64 = conn
262            .query_row("PRAGMA freelist_count", [], |r| r.get(0))
263            .unwrap();
264        assert!(
265            freelist > 50,
266            "sanity: mass delete built a freelist ({freelist})"
267        );
268
269        let opts = MaintenanceOptions {
270            run_optimize: false,
271            incremental_vacuum_pages: 1_000_000, // ≥ freelist, so one pass drains it
272            freelist_threshold_pages: 1,
273            wal_checkpoint: true,
274        };
275
276        // The vacuum step steps `incremental_vacuum` to completion (one row per page), so on a lone
277        // connection (no pinned reader) a single pass returns the whole freelist to the OS.
278        let r1 = run(&conn, &opts).unwrap();
279        assert!(r1.vacuum_ran, "freelist over threshold");
280        assert_eq!(
281            r1.pages_reclaimed as i64, freelist,
282            "stepped incremental_vacuum should reclaim the entire freelist in one pass"
283        );
284
285        let r2 = run(&conn, &opts).unwrap();
286        assert_eq!(
287            r2.pages_reclaimed, 0,
288            "freelist already drained — a second pass reclaims nothing"
289        );
290    }
291
292    #[test]
293    fn incremental_vacuum_is_bounded_by_the_page_cap() {
294        let dir = tempfile::tempdir().unwrap();
295        let conn = open(&dir.path().join("cap.db"));
296        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
297            .unwrap();
298        let pad = "x".repeat(200);
299        for id in 0..5_000i64 {
300            conn.execute(
301                "INSERT INTO t(id, v) VALUES (?, ?)",
302                rusqlite::params![id, format!("{pad}-{id}")],
303            )
304            .unwrap();
305        }
306        conn.execute("DELETE FROM t", []).unwrap();
307        let freelist: i64 = conn
308            .query_row("PRAGMA freelist_count", [], |r| r.get(0))
309            .unwrap();
310        assert!(freelist > 100, "sanity ({freelist})");
311
312        // A small cap bounds the work per pass — exactly `incremental_vacuum_pages` reclaimed.
313        let r = run(
314            &conn,
315            &MaintenanceOptions {
316                run_optimize: false,
317                incremental_vacuum_pages: 25,
318                freelist_threshold_pages: 1,
319                wal_checkpoint: false,
320            },
321        )
322        .unwrap();
323        assert_eq!(
324            r.pages_reclaimed, 25,
325            "the page cap bounds reclaim per pass"
326        );
327    }
328
329    /// The vacuum step no-ops on a database still at `auto_vacuum=NONE` (e.g. a file created before
330    /// the engine set INCREMENTAL) rather than erroring — it just needs a one-time full VACUUM.
331    #[test]
332    fn vacuum_step_noops_on_auto_vacuum_none() {
333        let dir = tempfile::tempdir().unwrap();
334        let conn = Connection::open(dir.path().join("none.db")).unwrap();
335        // Explicitly NONE, then create a table so the setting is locked in.
336        conn.execute_batch(
337            "PRAGMA auto_vacuum=NONE; PRAGMA journal_mode=wal2; CREATE TABLE t (id INTEGER PRIMARY KEY)",
338        )
339        .unwrap();
340        let mode: i64 = conn
341            .query_row("PRAGMA auto_vacuum", [], |r| r.get(0))
342            .unwrap();
343        assert_eq!(mode, 0, "sanity: db is auto_vacuum=NONE");
344
345        let report = run(
346            &conn,
347            &MaintenanceOptions {
348                run_optimize: false,
349                incremental_vacuum_pages: 1_000,
350                freelist_threshold_pages: 1,
351                wal_checkpoint: false,
352            },
353        )
354        .unwrap();
355        assert!(
356            !report.vacuum_ran,
357            "incremental_vacuum must be skipped on an auto_vacuum=NONE file"
358        );
359    }
360}