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}