khive_db/checkpoint.rs
1//! Periodic WAL checkpoint task for the connection pool (ADR-091; dedicated
2//! checkpoint connection amendment, see below).
3//!
4//! Issues `PRAGMA wal_checkpoint(PASSIVE)` on every tick — non-blocking, never
5//! waits for readers. A rare, separately-gated escalation may additionally run
6//! `PRAGMA wal_checkpoint(TRUNCATE)` once WAL pressure crosses
7//! `truncate_high_water_pages` and `truncate_min_interval` has elapsed since
8//! the last attempt (Plank 2); both run on the task's own dedicated
9//! standalone connection (`CheckpointConnection`), opened once at task
10//! startup and reused for every tick — `checkpoint_once` never checks out the
11//! pool's writer mutex at all, so a concurrent `pool.writer()` checkout can
12//! never queue behind a checkpoint tick's ADMISSION. That guarantee is
13//! admission-only: PASSIVE takes SQLite's CKPT lock, not the WRITE lock, so
14//! it never blocks writers at the SQLite level either — but TRUNCATE
15//! additionally acquires SQLite's writer lock and can still block a
16//! concurrent write transaction, on any connection, for up to
17//! `truncate_busy_timeout` while it waits on a pinning reader, exactly as
18//! before this connection split.
19//!
20//! If the dedicated connection is unavailable (never opened yet, or dropped
21//! after a prior tick's connection-level pragma failure), the tick reports
22//! `CheckpointTick::Skipped` and the next tick lazily reopens it. A busy or
23//! inconsistent PASSIVE result also skips pressure decisions without replacing
24//! the last valid WAL sample; a busy pool writer does not cause either skip.
25//!
26//! `warn_pages` / `high_water_pages` WARNs fire at most once per below→above
27//! crossing; a skipped tick leaves crossing state unchanged. An age-based
28//! background sweep (Plank 1) additionally checks the oldest span in
29//! `khive_storage::tx_registry` against `tx_warn_secs`/`tx_max_age_secs` on
30//! every tick (Skipped or Observed) and escalates to `warn!`/`error!` on each
31//! below→above crossing — visibility only, nothing here force-closes a stale
32//! span.
33//!
34//! See crates/khive-db/docs/api/checkpoint.md#module-overview-adr-091-planks-012
35//! for full ADR-091 Plank 0/1/2 design rationale (why TRUNCATE is excluded
36//! from ordinary ticks, the dedicated-connection invariant, and why Plank 1
37//! is a sweep rather than the ADR's originally-described per-statement guard).
38//!
39//! The same long-lived standalone connection also owns a separate, five-minute
40//! FTS5 maintenance cadence. One due call gives one index at most 500 pages of
41//! incremental merge work and uses a zero busy timeout, so this best-effort
42//! derived-index maintenance cannot queue behind application writes.
43
44use std::collections::{BTreeMap, HashMap};
45use std::path::{Path, PathBuf};
46use std::sync::atomic::{AtomicU64, Ordering};
47#[cfg(test)]
48use std::sync::Arc;
49use std::sync::{Mutex, OnceLock};
50use std::time::{Duration, Instant};
51
52use crate::pool::ConnectionPool;
53
54mod backfill_queries;
55mod checkpoint_core;
56mod lifecycle;
57mod task_loop;
58mod walpin_attribution;
59mod walpin_sidecar;
60
61use backfill_queries::{
62 crossing_warn, log_backfill_gap, observed_wal_pages, query_routine_checkpoint_observation,
63 query_truncate_observation, query_wal_pages, CheckpointUnavailableReason,
64 RawCheckpointObservation,
65};
66#[cfg(test)]
67use backfill_queries::{
68 query_backfill_gap, query_checkpoint_observation, test_arm_passive_row, test_take_passive_row,
69};
70use checkpoint_core::{
71 checkpoint_once_core, log_tx_registry_oldest_debug, log_tx_registry_oldest_warn,
72 log_tx_registry_snapshot_warn, log_wal_high_water_warn, CheckpointCoreOutcome,
73};
74#[cfg(test)]
75use checkpoint_core::{
76 log_truncate_no_progress_warn, maybe_truncate, note_truncate_outcome,
77 truncate_needs_attribution, truncate_report_test_sync,
78};
79
80pub use checkpoint_core::checkpoint_once;
81pub use lifecycle::{run_session_sweep_task, CheckpointLifecycleOwner, SweepBackend};
82pub use task_loop::run_checkpoint_task;
83
84use lifecycle::{CheckpointConnection, CheckpointLifecycleEmitter, CheckpointPressureEpisode};
85#[cfg(test)]
86use task_loop::{
87 checkpoint_outcome_should_emit, observe_checkpoint_pressure_tick,
88 run_fts_maintenance_off_worker,
89};
90use walpin_attribution::WalpinAttributionRequest;
91#[cfg(unix)]
92use walpin_attribution::{capture_walpin_attribution_request, complete_walpin_attribution};
93#[cfg(all(test, unix))]
94use walpin_attribution::{
95 log_walpin_sidecar_report, walpin_attribution_test_sync, WalpinAttributionFailure,
96 WalpinReportFreshness,
97};
98use walpin_sidecar::{log_tx_age_emission, ReaderCheckoutSweepState, WalpinSidecarState};
99#[cfg(unix)]
100use walpin_sidecar::{
101 now_epoch_secs, run_walpin_housekeeping_if_due, CachedWalpinAttribution, WalpinFullScanPlan,
102 DEFAULT_WALPIN_FULL_SCAN_INTERVAL,
103};
104
105mod off_worker;
106#[cfg(test)]
107mod off_worker_tests;
108
109// ── metrics read-surface (load/perf harness) ─────────────────────────────
110// Read-only process-wide gauges (never reset outside #[cfg(test)]). See
111// crates/khive-db/docs/api/checkpoint.md#metrics-read-surface-loadperf-harness
112
113/// Last-observed WAL page count (the routine PASSIVE row's `log` value, or a
114/// rare post-TRUNCATE observation from `maybe_truncate`).
115/// `u64::MAX` is the "never observed" sentinel — no checkpoint tick has run
116/// yet in this process — distinct from a genuine zero-page WAL.
117static LAST_WAL_PAGES: AtomicU64 = AtomicU64::new(u64::MAX);
118
119/// Count of TRUNCATE attempts (`maybe_truncate`'s pragma actually invoked,
120/// win or lose) across this process's lifetime.
121static TRUNCATE_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
122
123/// Current consecutive-failure count, mirrored from the caller-owned
124/// `TruncateState::consecutive_failures` field into a process-readable
125/// gauge every time `note_truncate_outcome` runs.
126static TRUNCATE_CONSECUTIVE_FAILURES: AtomicU64 = AtomicU64::new(0);
127
128/// Count of checkpoint ticks without a usable WAL frame observation because
129/// the dedicated connection was unavailable or SQLite returned a busy or
130/// inconsistent PASSIVE row.
131/// Never reset outside `#[cfg(test)]`.
132static CHECKPOINT_SKIPPED_TICKS: AtomicU64 = AtomicU64::new(0);
133
134/// Current run-length of consecutive skipped ticks. Reset to 0 the next time
135/// a tick has a valid WAL frame observation, so a
136/// sustained skip streak is visible even between two successful
137/// observations.
138static CHECKPOINT_CONSECUTIVE_SKIPS: AtomicU64 = AtomicU64::new(0);
139
140/// WAL page count as of the most recent *observed* tick, snapshotted at the
141/// moment a skip occurs. `u64::MAX` is the "no skip has recorded a snapshot
142/// yet" sentinel, mirroring `LAST_WAL_PAGES`.
143static CHECKPOINT_LAST_SKIP_WAL_PAGES: AtomicU64 = AtomicU64::new(u64::MAX);
144
145/// Elevated checkpoint observations aggregated in memory instead of written
146/// as one primary-store lifecycle row per tick (#1838).
147static CHECKPOINT_PRESSURE_ELEVATED_TICKS: AtomicU64 = AtomicU64::new(0);
148
149/// Below-to-above `warn_pages` transitions observed by checkpoint tasks.
150static CHECKPOINT_PRESSURE_EPISODES_STARTED: AtomicU64 = AtomicU64::new(0);
151
152/// Above-to-below `warn_pages` transitions observed by checkpoint tasks.
153static CHECKPOINT_PRESSURE_EPISODES_RECOVERED: AtomicU64 = AtomicU64::new(0);
154
155/// Primary-store append calls actually made by checkpoint lifecycle workers.
156static CHECKPOINT_LIFECYCLE_APPEND_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
157
158/// Checkpoint lifecycle append calls that returned a storage error.
159static CHECKPOINT_LIFECYCLE_APPEND_FAILURES: AtomicU64 = AtomicU64::new(0);
160
161/// Lifecycle transitions rejected before append because the bounded handoff
162/// was full, closed, or could not serialize the payload.
163static CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS: AtomicU64 = AtomicU64::new(0);
164
165/// Count of cached-reader explicit read transactions rolled back on reuse
166/// for exceeding `read_tx_max_age` (#1846), across this process's lifetime.
167/// Unlike the Plank 1 sweep above, this is reclamation, not just visibility:
168/// each count here is a WAL snapshot that was actually released rather than
169/// merely logged as stale. See `sql_bridge.rs::execute_standalone_read`.
170static READ_TX_MAX_AGE_EVICTIONS: AtomicU64 = AtomicU64::new(0);
171
172mod run_state;
173
174pub use run_state::{
175 checkpoint_consecutive_skips, checkpoint_last_skip_wal_pages,
176 checkpoint_lifecycle_append_attempts, checkpoint_lifecycle_append_failures,
177 checkpoint_lifecycle_enqueue_drops, checkpoint_pressure_elevated_ticks,
178 checkpoint_pressure_episodes_recovered, checkpoint_pressure_episodes_started,
179 checkpoint_skipped_ticks, checkpoint_timing, last_observed_wal_pages,
180 read_tx_max_age_evictions, routine_wal_observation, truncate_attempts,
181 truncate_consecutive_failures, CheckpointRun, CheckpointTick, CheckpointTiming,
182 RoutineWalObservation,
183};
184
185pub(crate) use run_state::{
186 checkpoint_run_snapshot, note_read_tx_max_age_eviction, record_checkpoint_run_result,
187 CheckpointRunStatus, CheckpointRunTaskGuard,
188};
189
190use run_state::{
191 note_checkpoint_observed, note_checkpoint_pressure_observation, note_checkpoint_skipped,
192 record_checkpoint_timing, record_routine_wal_observation,
193};
194
195#[cfg(test)]
196pub(crate) use run_state::{checkpoint_run_status, reset_checkpoint_metrics_for_tests};
197
198#[cfg(test)]
199use run_state::{
200 advance_checkpoint_run, advance_checkpoint_run_at, checkpoint_db_key,
201 checkpoint_db_key_from_path, checkpoint_runs, checkpoint_timings,
202};
203
204mod config;
205
206pub(crate) use config::tx_age_thresholds_from_env;
207pub use config::{CheckpointConfig, DEFAULT_WARN_SUSTAINED_CYCLES};
208
209mod severity_state;
210
211pub use severity_state::{
212 CheckpointSeverityEmission, CheckpointSeverityRung, CheckpointSeverityState, TruncateState,
213 TxAgeEmission, TxAgeRung, TxAgeSweepState,
214};
215
216/// ADR-091 Amendment 2 Plank A: config for the observe-only per-session
217/// sweep. Sessions never checkpoint — that stays daemon-owned so N session
218/// processes never compete for the writer mutex — this only watches
219/// `tx_registry` (and, Plank B, refreshes this process's walpin heartbeat).
220const DEFAULT_SESSION_SWEEP_INTERVAL: Duration = Duration::from_secs(5);
221
222#[derive(Clone, Debug)]
223pub struct SessionSweepConfig {
224 /// How often a session polls the registry. Coarser than the daemon's
225 /// tick: sessions do not need the daemon's 500ms checkpoint cadence.
226 ///
227 /// Overridable via `KHIVE_SESSION_SWEEP_INTERVAL_MS`. Default: 5000 ms.
228 pub interval: Duration,
229 /// Same semantics and default as [`CheckpointConfig::tx_warn_secs`].
230 pub tx_warn_secs: Duration,
231 /// Same semantics and default as [`CheckpointConfig::tx_max_age_secs`].
232 pub tx_max_age_secs: Duration,
233}
234
235impl Default for SessionSweepConfig {
236 fn default() -> Self {
237 Self {
238 interval: DEFAULT_SESSION_SWEEP_INTERVAL,
239 tx_warn_secs: Duration::from_secs(30),
240 tx_max_age_secs: Duration::from_secs(120),
241 }
242 }
243}
244
245impl SessionSweepConfig {
246 /// Build from the environment. Reuses `KHIVE_TX_WARN_SECS` /
247 /// `KHIVE_TX_MAX_AGE_SECS` (the same knobs the daemon's checkpoint task
248 /// reads) so a session and the daemon agree on the same thresholds.
249 pub fn from_env() -> Self {
250 let mut cfg = Self {
251 interval: session_sweep_interval_from_env(),
252 ..Self::default()
253 };
254 // Shares `tx_age_thresholds_from_env` with `CheckpointConfig::from_env`
255 // (minor, ADR-091 Amendment 2) so a session and the daemon
256 // parse and validate `KHIVE_TX_WARN_SECS`/`KHIVE_TX_MAX_AGE_SECS`
257 // identically from one source, not two hand-copied blocks.
258 (cfg.tx_warn_secs, cfg.tx_max_age_secs) =
259 tx_age_thresholds_from_env(cfg.tx_warn_secs, cfg.tx_max_age_secs);
260
261 cfg
262 }
263}
264
265fn session_sweep_interval_from_env() -> Duration {
266 let millis = crate::env::env_parse_or("KHIVE_SESSION_SWEEP_INTERVAL_MS", 0_u64);
267 if millis > 0 {
268 Duration::from_millis(millis)
269 } else {
270 DEFAULT_SESSION_SWEEP_INTERVAL
271 }
272}
273
274#[cfg(test)]
275#[path = "checkpoint_tests.rs"]
276mod tests;