Skip to main content

khive_db/
checkpoint.rs

1//! Periodic WAL checkpoint task for the connection pool.
2//!
3//! Issues `PRAGMA wal_checkpoint(PASSIVE)` on every tick — including when the
4//! WAL page count exceeds the high-water mark. PASSIVE is the only mode the
5//! periodic task ever uses.
6//!
7//! Non-contending design: `checkpoint_once` uses `try_writer_nowait` (zero-wait
8//! `try_lock`) so a tick is skipped immediately when any writer holds the mutex,
9//! rather than blocking for up to `checkout_timeout`. The checkpoint task must
10//! never stall active write traffic — a skipped tick is always preferable.
11//!
12//! Why TRUNCATE is excluded from the periodic path: TRUNCATE inherits RESTART
13//! semantics — it waits for active readers to release their WAL snapshots and
14//! invokes the busy handler before acquiring the exclusive lock needed to reset
15//! the WAL file. With PoolConfig's 30 s busy_timeout, the task could sit inside
16//! SQLite holding the sole writer connection for up to 30 s, stalling all normal
17//! write traffic. PASSIVE never waits for readers; it checkpoints as many frames
18//! as currently possible and returns promptly. When WAL pressure is sustained
19//! (high_water_pages exceeded), the task emits a WARNING so an operator or
20//! scheduler can perform a blocking TRUNCATE at a safe moment outside normal
21//! traffic.
22//!
23//! Threshold-crossing WARN semantics: both the `warn_pages` and `high_water_pages`
24//! warnings fire at most once per below→above crossing. Skipped ticks (writer
25//! busy) leave the crossing state unchanged so that a skip cannot spuriously
26//! re-arm the rate limit while WAL pressure is still elevated.
27
28use std::sync::Arc;
29use std::time::Duration;
30
31use crate::pool::ConnectionPool;
32
33/// Outcome of a single checkpoint attempt.
34///
35/// `Skipped` is returned when the writer mutex is already held (the tick is a
36/// no-op). `Observed` carries the WAL page count read during the tick. The
37/// distinction matters for threshold-crossing WARN rate-limiting: a skipped tick
38/// must leave the above/below state unchanged so that a busy tick cannot
39/// spuriously re-arm the rate limit while WAL pressure is still elevated.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CheckpointTick {
42    /// The writer mutex was busy; no checkpoint was issued this tick.
43    Skipped,
44    /// A checkpoint was issued; the value is the observed WAL page count.
45    Observed(u64),
46}
47
48/// Configuration for the WAL checkpoint background task.
49///
50/// All fields default to conservative production values. Override via the
51/// environment variables documented on each field.
52#[derive(Clone, Debug)]
53pub struct CheckpointConfig {
54    /// How often to run a passive checkpoint when there is no active write.
55    ///
56    /// Overridable via `KHIVE_CHECKPOINT_INTERVAL_MS` (milliseconds).
57    /// Default: 500 ms.
58    pub interval: Duration,
59
60    /// WAL page count above which a warning is logged.
61    ///
62    /// Overridable via `KHIVE_WAL_WARN_PAGES`.
63    /// Default: 2000 pages (~8 MB at 4 KiB page size).
64    pub warn_pages: u64,
65
66    /// WAL page count above which a high-pressure WARNING is logged.
67    ///
68    /// The periodic task always runs PASSIVE regardless; this threshold signals
69    /// that a long-lived reader may be pinning an old WAL snapshot that PASSIVE
70    /// cannot reclaim. An operator can then schedule a blocking TRUNCATE at a
71    /// safe moment outside normal write traffic.
72    ///
73    /// Overridable via `KHIVE_WAL_HIGH_WATER_PAGES`.
74    /// Default: 6000 pages (~24 MB at 4 KiB page size).
75    pub high_water_pages: u64,
76}
77
78impl Default for CheckpointConfig {
79    fn default() -> Self {
80        Self {
81            interval: Duration::from_millis(500),
82            warn_pages: 2000,
83            high_water_pages: 6000,
84        }
85    }
86}
87
88impl CheckpointConfig {
89    /// Build a `CheckpointConfig` from the environment.
90    ///
91    /// Unset or unparseable variables fall back to the compiled-in defaults.
92    pub fn from_env() -> Self {
93        let mut cfg = Self::default();
94
95        if let Ok(ms) = std::env::var("KHIVE_CHECKPOINT_INTERVAL_MS") {
96            if let Ok(v) = ms.parse::<u64>() {
97                if v > 0 {
98                    cfg.interval = Duration::from_millis(v);
99                }
100            }
101        }
102
103        if let Ok(v) = std::env::var("KHIVE_WAL_WARN_PAGES") {
104            if let Ok(n) = v.parse::<u64>() {
105                if n > 0 {
106                    cfg.warn_pages = n;
107                }
108            }
109        }
110
111        if let Ok(v) = std::env::var("KHIVE_WAL_HIGH_WATER_PAGES") {
112            if let Ok(n) = v.parse::<u64>() {
113                if n > 0 {
114                    cfg.high_water_pages = n;
115                }
116            }
117        }
118
119        cfg
120    }
121}
122
123/// Run the WAL checkpoint background task.
124///
125/// This is a long-running async task that should be spawned with
126/// `tokio::spawn`. It loops until the pool is dropped (the `Arc` count
127/// falls to one, meaning this task holds the last reference).
128///
129/// The task issues `PRAGMA wal_checkpoint(PASSIVE)` on every tick. PASSIVE is
130/// the only checkpoint mode used; see the module-level doc for why TRUNCATE is
131/// excluded. A WARNING is emitted once on threshold crossing (wal_pages
132/// transitions from below a threshold to at/above) rather than on every tick,
133/// preventing log spam when a long-lived reader pins a WAL snapshot.
134///
135/// Skipped ticks (writer mutex busy) leave both crossing-state flags unchanged
136/// so that a skip cannot spuriously re-arm the rate limit while WAL pressure is
137/// still elevated.
138///
139/// Uses `try_writer_nowait` (zero-wait try-lock) so a busy writer causes the
140/// current tick to be skipped rather than stalling write traffic.
141pub async fn run_checkpoint_task(pool: Arc<ConnectionPool>, config: CheckpointConfig) {
142    let mut interval = tokio::time::interval(config.interval);
143    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
144    let mut was_above_warn = false;
145    let mut was_above_high_water = false;
146
147    loop {
148        interval.tick().await;
149
150        // Stop looping when this task is the sole Arc holder — the daemon is
151        // shutting down and the pool will be dropped imminently.
152        if Arc::strong_count(&pool) <= 1 {
153            break;
154        }
155
156        let tick = checkpoint_once(&pool);
157        // Skipped ticks leave crossing state unchanged — a busy tick must not
158        // re-arm the rate limit while WAL pressure is still elevated.
159        let wal_pages = match tick {
160            CheckpointTick::Skipped => continue,
161            CheckpointTick::Observed(n) => n,
162        };
163
164        let in_warn_band = wal_pages >= config.warn_pages && wal_pages < config.high_water_pages;
165        if crossing_warn(in_warn_band, &mut was_above_warn) {
166            tracing::warn!(
167                wal_pages,
168                warn_threshold = config.warn_pages,
169                "WAL page count approaching checkpoint threshold"
170            );
171        }
172
173        let above_high_water = wal_pages >= config.high_water_pages;
174        if crossing_warn(above_high_water, &mut was_above_high_water) {
175            tracing::warn!(
176                wal_pages,
177                high_water = config.high_water_pages,
178                "WAL high-water mark exceeded; sustained WAL pressure — \
179                 a long-lived reader may be pinning an old snapshot that PASSIVE cannot reclaim"
180            );
181        }
182    }
183}
184
185/// Issue one checkpoint cycle against the writer connection.
186///
187/// Returns [`CheckpointTick::Skipped`] when the writer mutex is already held
188/// (the tick is a no-op) and [`CheckpointTick::Observed`] with the WAL page
189/// count otherwise. All checkpoint errors are logged at warn level and treated
190/// as non-fatal; the next tick retries.
191///
192/// Uses `try_writer_nowait` so that a busy active writer causes this tick to
193/// be skipped immediately rather than stalling for up to `checkout_timeout`.
194/// The caller (`run_checkpoint_task`) owns all threshold-crossing WARN logging
195/// so that warnings fire at most once per crossing, not every tick.
196pub fn checkpoint_once(pool: &ConnectionPool) -> CheckpointTick {
197    let writer = match pool.try_writer_nowait() {
198        Ok(w) => w,
199        Err(_) => return CheckpointTick::Skipped,
200    };
201
202    let wal_pages = query_wal_pages(writer.conn());
203
204    if let Err(e) = writer
205        .conn()
206        .execute_batch("PRAGMA wal_checkpoint(PASSIVE)")
207    {
208        tracing::warn!(error = %e, "WAL checkpoint failed");
209    } else {
210        tracing::debug!(wal_pages, "WAL checkpoint issued");
211    }
212
213    CheckpointTick::Observed(wal_pages)
214}
215
216/// Evaluate whether a threshold-crossing WARN should fire and advance the
217/// crossing-state flag.
218///
219/// Returns `true` on a false→true transition in `now_above` (first observed
220/// above-threshold tick after a below-threshold tick), `false` on any other
221/// tick. The `was_above` flag is updated in-place to track state across calls.
222/// Used by `run_checkpoint_task` for both the `warn_pages` band and the
223/// `high_water_pages` threshold.
224fn crossing_warn(now_above: bool, was_above: &mut bool) -> bool {
225    let fire = now_above && !*was_above;
226    *was_above = now_above;
227    fire
228}
229
230/// Query the current WAL frame count via `PRAGMA wal_checkpoint`.
231///
232/// The pragma returns a 3-column row `(busy, log, checkpointed)`, where `log`
233/// (column index 1) is the number of frames currently in the WAL file — the
234/// backlog the high-water threshold keys off. (Column 2 is `checkpointed`, the
235/// frames moved *by this call*, which is not the WAL size.) The no-arg pragma
236/// also performs a PASSIVE checkpoint as a side effect; the subsequent explicit
237/// `PRAGMA wal_checkpoint(PASSIVE)` in `checkpoint_once` is a deliberate second
238/// pass that can checkpoint any frames written between the two calls.
239///
240/// Returns 0 on any error (e.g. in-memory DB where WAL is not active, which
241/// reports `log = -1`).
242fn query_wal_pages(conn: &rusqlite::Connection) -> u64 {
243    conn.query_row("PRAGMA wal_checkpoint", [], |row| row.get::<_, i64>(1))
244        .unwrap_or(0)
245        .max(0) as u64
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::pool::PoolConfig;
252    use serial_test::serial;
253
254    fn file_pool(path: &std::path::Path) -> Arc<ConnectionPool> {
255        let cfg = PoolConfig {
256            path: Some(path.to_path_buf()),
257            ..PoolConfig::default()
258        };
259        Arc::new(ConnectionPool::new(cfg).expect("pool open"))
260    }
261
262    #[test]
263    fn checkpoint_once_succeeds_on_file_backed_pool() {
264        let dir = tempfile::tempdir().unwrap();
265        let path = dir.path().join("wal_test.db");
266        let pool = file_pool(&path);
267
268        // Create a table so the DB is not completely empty.
269        {
270            let writer = pool.try_writer().unwrap();
271            writer
272                .conn()
273                .execute_batch("CREATE TABLE IF NOT EXISTS t (x INTEGER);")
274                .unwrap();
275            writer
276                .conn()
277                .execute_batch("INSERT INTO t VALUES (1);")
278                .unwrap();
279        }
280
281        checkpoint_once(&pool);
282    }
283
284    #[test]
285    fn checkpoint_once_is_noop_on_in_memory_pool() {
286        // In-memory databases do not use WAL; checkpoint_once must not panic.
287        let cfg = PoolConfig {
288            path: None,
289            ..PoolConfig::default()
290        };
291        let pool = Arc::new(ConnectionPool::new(cfg).expect("in-memory pool"));
292        checkpoint_once(&pool);
293    }
294
295    #[tokio::test]
296    async fn checkpoint_task_exits_when_pool_dropped() {
297        let dir = tempfile::tempdir().unwrap();
298        let path = dir.path().join("wal_task_drop.db");
299        let pool = file_pool(&path);
300
301        // Use a very short interval so the task ticks quickly in the test.
302        let cfg = CheckpointConfig {
303            interval: Duration::from_millis(10),
304            ..Default::default()
305        };
306
307        let weak = Arc::downgrade(&pool);
308        let task_pool = Arc::clone(&pool);
309        let handle = tokio::spawn(run_checkpoint_task(task_pool, cfg));
310
311        // Drop our copy — only the task holds the Arc now.
312        drop(pool);
313
314        // The task detects strong_count == 1 on its next tick and exits.
315        tokio::time::timeout(Duration::from_secs(1), handle)
316            .await
317            .expect("checkpoint task should exit within 1s")
318            .expect("checkpoint task panicked");
319
320        assert!(weak.upgrade().is_none(), "pool should be fully dropped");
321    }
322
323    #[test]
324    #[serial]
325    fn checkpoint_config_env_override() {
326        std::env::set_var("KHIVE_CHECKPOINT_INTERVAL_MS", "250");
327        std::env::set_var("KHIVE_WAL_WARN_PAGES", "1500");
328        std::env::set_var("KHIVE_WAL_HIGH_WATER_PAGES", "8000");
329
330        let cfg = CheckpointConfig::from_env();
331
332        std::env::remove_var("KHIVE_CHECKPOINT_INTERVAL_MS");
333        std::env::remove_var("KHIVE_WAL_WARN_PAGES");
334        std::env::remove_var("KHIVE_WAL_HIGH_WATER_PAGES");
335
336        assert_eq!(cfg.interval, Duration::from_millis(250));
337        assert_eq!(cfg.warn_pages, 1500);
338        assert_eq!(cfg.high_water_pages, 8000);
339    }
340
341    #[test]
342    #[serial]
343    fn checkpoint_config_defaults_on_invalid_env() {
344        let default = CheckpointConfig::default();
345
346        std::env::set_var("KHIVE_CHECKPOINT_INTERVAL_MS", "not_a_number");
347        std::env::set_var("KHIVE_WAL_WARN_PAGES", "");
348        std::env::set_var("KHIVE_WAL_HIGH_WATER_PAGES", "0");
349
350        let cfg = CheckpointConfig::from_env();
351
352        std::env::remove_var("KHIVE_CHECKPOINT_INTERVAL_MS");
353        std::env::remove_var("KHIVE_WAL_WARN_PAGES");
354        std::env::remove_var("KHIVE_WAL_HIGH_WATER_PAGES");
355
356        assert_eq!(cfg.interval, default.interval);
357        assert_eq!(cfg.warn_pages, default.warn_pages);
358        assert_eq!(cfg.high_water_pages, default.high_water_pages);
359    }
360
361    /// Regression: a high-water tick must NOT block behind an active read transaction.
362    ///
363    /// Isomorphism guarantee: this test FAILS if `checkpoint_once` regresses to
364    /// `PRAGMA wal_checkpoint(TRUNCATE)`. Confirmed by reasoning: TRUNCATE inherits
365    /// RESTART semantics and will invoke the busy handler (sleeping up to
366    /// `busy_timeout`) while waiting for the open reader snapshot to release.
367    /// With `busy_timeout = 2000ms` a TRUNCATE regression causes the call to take
368    /// ~2000ms, blowing the <500ms assertion. PASSIVE returns in <1ms even with an
369    /// open reader, because PASSIVE never waits for readers.
370    ///
371    /// Why `busy_timeout = 2000ms` and threshold `< 500ms`: the original 200ms
372    /// busy_timeout / 50ms threshold was too tight for contended CI runners where
373    /// PASSIVE legitimately takes 50-200ms under parallel-test load. Raising the
374    /// busy_timeout to 2000ms keeps the PASSIVE path well below 500ms while a
375    /// TRUNCATE regression blocks for ~2000ms — a 4x safety margin on both sides.
376    #[test]
377    fn checkpoint_high_water_does_not_block_behind_reader() {
378        let dir = tempfile::tempdir().unwrap();
379        let path = dir.path().join("high_water_test.db");
380
381        // busy_timeout = 2000ms: a TRUNCATE regression blocks ~2s (clearly caught by
382        // the <500ms assertion below), but PASSIVE returns well within 500ms even on
383        // a heavily loaded CI runner. 4x margin on both sides vs. the old 200ms/50ms.
384        let pool = Arc::new(
385            ConnectionPool::new(PoolConfig {
386                path: Some(path.clone()),
387                busy_timeout: Duration::from_millis(2000),
388                ..PoolConfig::default()
389            })
390            .expect("pool open"),
391        );
392
393        // Write data so the WAL has frames to checkpoint.
394        {
395            let writer = pool.try_writer().unwrap();
396            writer
397                .conn()
398                .execute_batch(
399                    "CREATE TABLE IF NOT EXISTS t (x INTEGER); INSERT INTO t VALUES (1);",
400                )
401                .unwrap();
402        }
403
404        // Open a reader and start a real read transaction so it holds a WAL
405        // snapshot. An idle connection (no BEGIN) does NOT pin frames and would
406        // not cause TRUNCATE to wait — the transaction is required for isomorphism.
407        let reader = pool.reader().expect("reader");
408        reader
409            .execute_batch("BEGIN DEFERRED; SELECT * FROM t;")
410            .expect("begin read tx");
411
412        // Write another row AFTER the snapshot is established. These new WAL
413        // frames are now pinned by the open reader snapshot — TRUNCATE cannot
414        // reclaim them without waiting; PASSIVE skips them and returns immediately.
415        {
416            let writer = pool.try_writer().unwrap();
417            writer
418                .conn()
419                .execute_batch("INSERT INTO t VALUES (2);")
420                .unwrap();
421        }
422
423        let start = std::time::Instant::now();
424        checkpoint_once(&pool);
425        let elapsed = start.elapsed();
426
427        // Commit and release the read snapshot only after checkpoint_once returns.
428        reader.execute_batch("COMMIT;").ok();
429        drop(reader);
430
431        // PASSIVE returns in <1ms even with an open reader snapshot.
432        // A TRUNCATE regression would block ~busy_timeout (2000ms) and fail here.
433        // 500ms threshold is generous for CI jitter while staying well below 2000ms.
434        assert!(
435            elapsed < std::time::Duration::from_millis(500),
436            "checkpoint_once with active reader snapshot took {:?}; \
437             expected <500ms (PASSIVE must not block on readers; \
438             a TRUNCATE regression would block ~2000ms)",
439            elapsed
440        );
441    }
442
443    #[test]
444    #[serial]
445    fn checkpoint_config_rejects_zero_for_all_fields() {
446        let default = CheckpointConfig::default();
447        std::env::set_var("KHIVE_CHECKPOINT_INTERVAL_MS", "0");
448        std::env::set_var("KHIVE_WAL_WARN_PAGES", "0");
449        std::env::set_var("KHIVE_WAL_HIGH_WATER_PAGES", "0");
450
451        let cfg = CheckpointConfig::from_env();
452
453        std::env::remove_var("KHIVE_CHECKPOINT_INTERVAL_MS");
454        std::env::remove_var("KHIVE_WAL_WARN_PAGES");
455        std::env::remove_var("KHIVE_WAL_HIGH_WATER_PAGES");
456
457        assert_eq!(
458            cfg.interval, default.interval,
459            "zero interval must fall back to default"
460        );
461        assert_eq!(
462            cfg.warn_pages, default.warn_pages,
463            "zero warn_pages must fall back to default"
464        );
465        assert_eq!(
466            cfg.high_water_pages, default.high_water_pages,
467            "zero high_water_pages must fall back to default"
468        );
469    }
470
471    /// Regression (Finding 1): a Skipped tick must NOT reset was_above_high_water.
472    ///
473    /// Before the fix, `checkpoint_once` returned `0` on both a genuinely-empty
474    /// WAL and a writer-busy skip. The task treated `0` as an observed page count
475    /// and reset `was_above_high_water`, re-arming the rate limit on every busy
476    /// tick. With the fix, `CheckpointTick::Skipped` leaves crossing state
477    /// unchanged.
478    ///
479    /// This test drives `crossing_warn` directly (the pure function that owns the
480    /// decision) rather than going through the async task, which would require a
481    /// logging harness.
482    #[test]
483    fn skipped_tick_does_not_reset_high_water_crossing_state() {
484        let mut was_above = false;
485
486        // First observed tick: above threshold — fires WARN, sets was_above=true.
487        assert!(
488            crossing_warn(true, &mut was_above),
489            "should fire on first crossing"
490        );
491        assert!(was_above);
492
493        // Simulate several skipped ticks: crossing state must remain true.
494        // (In the task, Skipped causes `continue` so crossing_warn is never called.)
495        // We verify by calling crossing_warn with the SAME above=true value, which
496        // is what Observed(high_count) would produce — but a Skipped tick skips
497        // the call entirely, so was_above stays as-is. Test the invariant directly:
498        // if we leave was_above unchanged (no call at all), was_above remains true.
499        assert!(was_above, "was_above must stay true across skipped ticks");
500
501        // Another observed tick still above threshold — must NOT re-fire.
502        let fired = crossing_warn(true, &mut was_above);
503        assert!(!fired, "WARN must not re-fire while still above threshold");
504
505        // Observed tick below threshold — resets was_above.
506        let fired = crossing_warn(false, &mut was_above);
507        assert!(!fired);
508        assert!(!was_above);
509
510        // Next observed tick above threshold — fires again (legitimate new crossing).
511        let fired = crossing_warn(true, &mut was_above);
512        assert!(fired, "WARN must fire again on a new below→above crossing");
513    }
514
515    /// Regression (Finding 2): warn_pages WARN fires once on crossing, not every tick.
516    ///
517    /// Before the fix, the WARN was emitted inside `checkpoint_once` on every tick
518    /// while WAL sat in the warn band — log spam under sustained moderate pressure.
519    /// With the fix, `crossing_warn` gates the WARN on the first in-band tick only;
520    /// subsequent ticks while still in the band return false.
521    #[test]
522    fn warn_pages_fires_once_on_crossing_not_every_tick() {
523        let mut was_above_warn = false;
524
525        // Simulate three consecutive ticks with WAL in the warn band.
526        let fired_1 = crossing_warn(true, &mut was_above_warn);
527        let fired_2 = crossing_warn(true, &mut was_above_warn);
528        let fired_3 = crossing_warn(true, &mut was_above_warn);
529
530        assert!(fired_1, "WARN must fire on the first in-band tick");
531        assert!(
532            !fired_2,
533            "WARN must not fire on the second consecutive in-band tick"
534        );
535        assert!(
536            !fired_3,
537            "WARN must not fire on the third consecutive in-band tick"
538        );
539
540        // Drop below warn band — resets state.
541        crossing_warn(false, &mut was_above_warn);
542        assert!(!was_above_warn);
543
544        // Re-enter warn band — fires again.
545        let fired_reentry = crossing_warn(true, &mut was_above_warn);
546        assert!(
547            fired_reentry,
548            "WARN must fire again on re-entry into warn band"
549        );
550    }
551}