Skip to main content

agentmux_srv/backend/storage/filestore/
checkpointer.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Background WAL checkpoints for a file-backed [`FileStore`] (Phase 5a-3,
5//! SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.3.7 item 5).
6//!
7//! With SQLite's automatic checkpoint, whichever write crosses the WAL
8//! threshold (1000 pages) runs the checkpoint itself — and every transcript
9//! append rewrites a whole 64 KB part, so that happens about every 60 lines,
10//! holding that line's event (which since 5a-3 waits for its write) for the
11//! checkpoint's duration: the p99 tail. Store connections therefore run with
12//! `wal_autocheckpoint=0`, and this thread checkpoints instead, on its own
13//! connection: in WAL mode a PASSIVE checkpoint runs alongside writers and
14//! readers, taking no lock they wait on.
15//!
16//! PASSIVE copies what it can and never blocks; pages a reader still needs
17//! stay in the WAL until a later pass. The srv's 30-minute TRUNCATE loop
18//! (`bootstrap::spawn_wal_checkpoint_loop`) still resets the file.
19
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::Arc;
23use std::time::Duration;
24
25/// Checkpoint once the WAL file is at least this large (SQLite's default
26/// automatic threshold is 1000 pages of 4 KB).
27const WAL_THRESHOLD_BYTES: u64 = 4 * 1024 * 1024;
28/// How often the WAL size is looked at.
29const POLL: Duration = Duration::from_millis(250);
30
31pub(super) struct Checkpointer {
32    stop: Arc<AtomicBool>,
33    thread: Option<std::thread::JoinHandle<()>>,
34}
35
36impl Checkpointer {
37    /// Start checkpointing `db`. `None` if the thread's connection can't be
38    /// opened — the caller then keeps SQLite's automatic checkpoints.
39    pub(super) fn start(db: &Path) -> Option<Self> {
40        let conn = rusqlite::Connection::open(db).ok()?;
41        conn.execute_batch("PRAGMA busy_timeout=5000;").ok()?;
42        let wal = wal_path(db);
43        let stop = Arc::new(AtomicBool::new(false));
44        let thread_stop = Arc::clone(&stop);
45        let thread = std::thread::Builder::new()
46            .name("filestore-checkpoint".into())
47            .spawn(move || {
48                while !thread_stop.load(Ordering::Relaxed) {
49                    std::thread::park_timeout(POLL);
50                    if thread_stop.load(Ordering::Relaxed) {
51                        break;
52                    }
53                    let big = std::fs::metadata(&wal).map(|m| m.len() >= WAL_THRESHOLD_BYTES).unwrap_or(false);
54                    if big {
55                        if let Err(e) = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);") {
56                            tracing::debug!(error = %e, "filestore: background checkpoint failed; retrying next poll");
57                        }
58                    }
59                }
60            })
61            .ok()?;
62        Some(Self { stop, thread: Some(thread) })
63    }
64}
65
66impl Drop for Checkpointer {
67    fn drop(&mut self) {
68        self.stop.store(true, Ordering::Relaxed);
69        if let Some(thread) = self.thread.take() {
70            thread.thread().unpark();
71            let _ = thread.join();
72        }
73    }
74}
75
76fn wal_path(db: &Path) -> PathBuf {
77    let mut name = db.as_os_str().to_os_string();
78    name.push("-wal");
79    PathBuf::from(name)
80}