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}