Skip to main content

khive_db/checkpoint/
checkpoint_core.rs

1//! Synchronous checkpoint execution and TRUNCATE escalation.
2
3use std::sync::atomic::Ordering;
4use std::time::{Duration, Instant};
5
6use crate::pool::ConnectionPool;
7
8#[cfg(unix)]
9use super::capture_walpin_attribution_request;
10#[cfg(test)]
11use super::off_worker;
12#[cfg(doc)]
13use super::run_checkpoint_task;
14use super::{
15    log_backfill_gap, note_checkpoint_observed, observed_wal_pages,
16    query_routine_checkpoint_observation, query_truncate_observation, query_wal_pages,
17    record_checkpoint_run_result, record_checkpoint_timing, record_routine_wal_observation,
18    CheckpointConfig, CheckpointUnavailableReason, TruncateState, WalpinAttributionRequest,
19    LAST_WAL_PAGES, TRUNCATE_ATTEMPTS, TRUNCATE_CONSECUTIVE_FAILURES,
20};
21
22/// ADR-091 Plank 0 (Amendment 3: takes the tick's already-computed,
23/// backend-scoped oldest span instead of re-querying the process-wide
24/// aggregate): log the oldest open transaction registry entry alongside the
25/// WAL frame count at `debug!`, on EVERY tick regardless of threshold
26/// state. This is the low-volume per-tick trace; the WARN-level escalations
27/// live in [`log_tx_registry_oldest_warn`] and
28/// debug-level, unconditional per-tick trace. See
29/// crates/khive-db/docs/api/checkpoint.md#private-tx-registry-logging-helpers-plank-0
30pub(super) fn log_tx_registry_oldest_debug(
31    wal_pages: u64,
32    oldest: Option<&khive_storage::tx_registry::OldestSpan>,
33) {
34    if let Some(span) = oldest {
35        tracing::debug!(target: "khive_db::checkpoint",
36            wal_pages,
37            oldest_tx_age_secs = span.age.as_secs_f64(),
38            oldest_tx_label = span.label.as_deref().unwrap_or("<unlabeled>"),
39            "WAL checkpoint tick: oldest open transaction registry entry"
40        );
41    }
42}
43
44/// Escalates the oldest open registry entry to `warn!`. NOT internally
45/// rate-limited — caller MUST gate on a below→above `warn_pages` crossing
46/// (`crossing_warn`) or every tick reproduces the log-spam bug this fixes.
47pub(super) fn log_tx_registry_oldest_warn(
48    wal_pages: u64,
49    oldest: Option<&khive_storage::tx_registry::OldestSpan>,
50) {
51    if let Some(span) = oldest {
52        tracing::warn!(target: "khive_db::checkpoint",
53            wal_pages,
54            oldest_tx_age_secs = span.age.as_secs_f64(),
55            oldest_tx_label = span.label.as_deref().unwrap_or("<unlabeled>"),
56            "WAL checkpoint tick: oldest open transaction registry entry"
57        );
58    }
59}
60
61/// Enumerates every open registry entry at `warn!`. NOT internally
62/// rate-limited — caller MUST gate on a below→above `high_water_pages`
63/// crossing (`crossing_warn`) or every tick repeats the full enumeration.
64pub(super) fn log_tx_registry_snapshot_warn(wal_pages: u64) {
65    log_tx_registry_entries_warn(wal_pages, &khive_storage::tx_registry::snapshot());
66}
67
68fn log_tx_registry_entries_warn(wal_pages: u64, snapshot: &[(Duration, Option<String>)]) {
69    for (age, label) in snapshot {
70        tracing::warn!(target: "khive_db::checkpoint",
71            wal_pages,
72            tx_age_secs = age.as_secs_f64(),
73            tx_label = label.as_deref().unwrap_or("<unlabeled>"),
74            "WAL high-water: open transaction registry entry"
75        );
76    }
77}
78
79pub(super) fn log_truncate_no_progress_warn(
80    wal_pages_before: u64,
81    wal_pages_after: u64,
82    snapshot: &[(Duration, Option<String>)],
83) {
84    let open_tx_count = snapshot.len();
85    let oldest_tx_age_secs = snapshot
86        .iter()
87        .map(|(age, _)| *age)
88        .max()
89        .map(|age| age.as_secs_f64());
90    if snapshot.is_empty() {
91        tracing::warn!(target: "khive_db::checkpoint",
92            wal_pages_before,
93            wal_pages_after,
94            open_tx_count,
95            oldest_tx_age_secs = ?oldest_tx_age_secs,
96            "WAL TRUNCATE attempt made no progress; no open transaction in this process's registry"
97        );
98    } else {
99        tracing::warn!(target: "khive_db::checkpoint",
100            wal_pages_before,
101            wal_pages_after,
102            open_tx_count,
103            oldest_tx_age_secs = ?oldest_tx_age_secs,
104            "WAL TRUNCATE attempt made no progress; open transactions observed in this process's registry"
105        );
106    }
107    log_tx_registry_entries_warn(wal_pages_after, snapshot);
108}
109
110/// Emits the high-water WARN, deciding its text from the registry entry this
111/// tick already read instead of asserting a cause the evidence beside it can
112/// refute.
113///
114/// The registry only covers this process. An old registered transaction may
115/// hold a snapshot; a young or empty registry cannot rule out an external
116/// reader. `warn_after` is the same threshold as the transaction-age ladder.
117pub(super) fn log_wal_high_water_warn(
118    wal_pages: u64,
119    high_water: u64,
120    oldest: Option<&khive_storage::tx_registry::OldestSpan>,
121    warn_after: Duration,
122) {
123    match oldest.filter(|span| span.age >= warn_after) {
124        Some(span) => tracing::warn!(target: "khive_db::checkpoint",
125            wal_pages,
126            high_water,
127            oldest_tx_age_secs = span.age.as_secs_f64(),
128            oldest_tx_label = span.label.as_deref().unwrap_or("<unlabeled>"),
129            "WAL high-water mark exceeded; an in-process registered transaction is older \
130             than the age threshold and may hold a snapshot"
131        ),
132        None => tracing::warn!(target: "khive_db::checkpoint",
133            wal_pages,
134            high_water,
135            oldest_tx_age_secs = ?oldest.map(|span| span.age.as_secs_f64()),
136            oldest_tx_label = oldest
137                .and_then(|span| span.label.as_deref())
138                .unwrap_or("<none>"),
139            "WAL high-water mark exceeded; no in-process transaction older than the age \
140             threshold is visible; a reader in another process may hold the snapshot, \
141             or writes may outpace PASSIVE checkpoints"
142        ),
143    }
144}
145
146/// Internal result of the synchronous SQLite checkpoint core. Keeping the
147/// no-progress attribution request next to (but distinct from) `wal_pages`
148/// makes the async handoff explicit and gives deferred work one caller-owned
149/// lifetime instead of leaving it in mutable cross-tick state.
150#[derive(Debug)]
151#[must_use]
152pub(super) struct CheckpointCoreOutcome {
153    pub(super) wal_pages: Option<u64>,
154    pub(super) unavailable_reason: Option<CheckpointUnavailableReason>,
155    pub(super) sidecar_attribution: Option<WalpinAttributionRequest>,
156}
157
158/// Issue one checkpoint cycle against the task's dedicated checkpoint
159/// connection (`conn` — see `CheckpointConnection`; NEVER the pool's writer
160/// mutex).
161///
162/// Returns the observed WAL page count on success. A busy PASSIVE row has no
163/// usable observation and the compatibility wrapper returns `SQLITE_BUSY`;
164/// an inconsistent nonbusy frame pair instead returns `SQLITE_ERROR`.
165/// A connection-level pragma error is also returned; the task caller drops
166/// that connection and reopens next tick. TRUNCATE errors remain non-fatal.
167///
168/// The caller owns all threshold-crossing WARN logging so that warnings fire
169/// at most once per crossing, not every tick.
170///
171/// ADR-091 Plank 2: after the PASSIVE pass, this is also the single point
172/// that may escalate to TRUNCATE (`maybe_truncate`) — on the SAME dedicated
173/// connection, never a second connection or a pool checkout. A no-progress
174/// result produces a separate cross-process attribution request; the
175/// synchronous core never walks the sidecar directory. Production's
176/// [`run_checkpoint_task`] consumes that request through an awaited
177/// `spawn_blocking` before continuing the tick. This compatibility wrapper
178/// intentionally returns only the historical page-count surface; the daemon
179/// calls `checkpoint_once_core` so it cannot discard the request.
180pub fn checkpoint_once(
181    pool: &ConnectionPool,
182    conn: &rusqlite::Connection,
183    config: &CheckpointConfig,
184    truncate_state: &mut TruncateState,
185) -> Result<u64, rusqlite::Error> {
186    let outcome = checkpoint_once_core(pool, conn, config, truncate_state)?;
187    outcome.wal_pages.ok_or_else(|| match outcome
188        .unavailable_reason
189        .expect("unavailable core outcome has a reason")
190    {
191        CheckpointUnavailableReason::Busy => rusqlite::Error::SqliteFailure(
192            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY),
193            Some("PASSIVE checkpoint returned a busy row without a WAL frame observation".into()),
194        ),
195        CheckpointUnavailableReason::InconsistentFrames => rusqlite::Error::SqliteFailure(
196            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
197            Some("PASSIVE checkpoint returned an inconsistent frame pair without a WAL frame observation".into()),
198        ),
199    })
200}
201
202/// Synchronous PASSIVE/TRUNCATE core used by the async task. Unlike the
203/// compatibility wrapper [`checkpoint_once`], this preserves the explicit
204/// no-progress attribution request for the caller to complete off-runtime.
205pub(super) fn checkpoint_once_core(
206    pool: &ConnectionPool,
207    conn: &rusqlite::Connection,
208    config: &CheckpointConfig,
209    truncate_state: &mut TruncateState,
210) -> Result<CheckpointCoreOutcome, rusqlite::Error> {
211    #[cfg(unix)]
212    truncate_state.begin_tick();
213    let started = Instant::now();
214    let checkpoint_result = query_routine_checkpoint_observation(pool, conn);
215    let elapsed_us = started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64;
216    record_checkpoint_timing(
217        pool,
218        elapsed_us,
219        checkpoint_result
220            .as_ref()
221            .ok()
222            .map(|observation| observation.busy),
223    );
224    let raw_observation = match checkpoint_result {
225        Ok(observation) => observation,
226        Err(e) => {
227            record_checkpoint_run_result(pool, None);
228            tracing::warn!(
229                target: "khive_db::checkpoint",
230                error = %e,
231                elapsed_us,
232                "WAL checkpoint failed"
233            );
234            return Err(e);
235        }
236    };
237    record_checkpoint_run_result(
238        pool,
239        Some((
240            raw_observation.busy,
241            raw_observation.log_frames,
242            raw_observation.checkpointed_frames,
243        )),
244    );
245    let wal_pages = match observed_wal_pages(raw_observation) {
246        Ok(wal_pages) => wal_pages,
247        Err(reason) => {
248            match reason {
249                CheckpointUnavailableReason::Busy => {
250                    tracing::debug!(target: "khive_db::checkpoint",
251                        busy = raw_observation.busy,
252                        wal_log_frames = raw_observation.log_frames,
253                        wal_checkpointed_frames = raw_observation.checkpointed_frames,
254                        elapsed_us,
255                        "WAL PASSIVE checkpoint returned a busy row; frame observation unavailable"
256                    )
257                }
258                CheckpointUnavailableReason::InconsistentFrames => {
259                    tracing::warn!(target: "khive_db::checkpoint",
260                        busy = raw_observation.busy,
261                        wal_log_frames = raw_observation.log_frames,
262                        wal_checkpointed_frames = raw_observation.checkpointed_frames,
263                        elapsed_us,
264                        "WAL PASSIVE checkpoint returned an inconsistent frame pair; frame observation unavailable"
265                    )
266                }
267            }
268            return Ok(CheckpointCoreOutcome {
269                wal_pages: None,
270                unavailable_reason: Some(reason),
271                sidecar_attribution: None,
272            });
273        }
274    };
275    let observation = record_routine_wal_observation(pool, raw_observation);
276    LAST_WAL_PAGES.store(wal_pages, Ordering::Relaxed);
277    note_checkpoint_observed(wal_pages);
278    tracing::debug!(target: "khive_db::checkpoint",
279        wal_pages,
280        elapsed_us,
281        busy = raw_observation.busy,
282        wal_checkpointed_frames = observation.checkpointed_frames,
283        wal_pending_frames = observation.pending_frames,
284        wal_physical_bytes = ?observation.physical_wal_bytes,
285        "WAL checkpoint issued"
286    );
287
288    let sidecar_attribution = maybe_truncate(pool, conn, config, wal_pages, truncate_state);
289
290    Ok(CheckpointCoreOutcome {
291        wal_pages: Some(wal_pages),
292        unavailable_reason: None,
293        sidecar_attribution,
294    })
295}
296
297pub(super) fn truncate_needs_attribution(
298    wal_pages_before: u64,
299    wal_pages_after: Option<u64>,
300) -> bool {
301    wal_pages_after.is_none_or(|pages| pages >= wal_pages_before)
302}
303
304/// Evaluate and, if due, attempt a TRUNCATE escalation on the same dedicated
305/// checkpoint connection the caller already holds (never its own checkout —
306/// there is no pool writer involved on this path at all). `last_attempt`
307/// is stamped ONLY on an actual attempt, never on a skip. See
308/// crates/khive-db/docs/api/checkpoint.md#maybe_truncate--truncate-attempt-gating-plank-2
309pub(super) fn maybe_truncate(
310    pool: &ConnectionPool,
311    conn: &rusqlite::Connection,
312    config: &CheckpointConfig,
313    wal_pages_before: u64,
314    truncate_state: &mut TruncateState,
315) -> Option<WalpinAttributionRequest> {
316    if wal_pages_before < config.truncate_high_water_pages {
317        return None;
318    }
319
320    if let Some(last) = truncate_state.last_attempt {
321        if last.elapsed() < config.truncate_min_interval {
322            return None;
323        }
324    }
325
326    // Which caller (if any) is pinning the WAL — logged before the attempt so
327    // it is available even if the attempt itself succeeds.
328    log_tx_registry_snapshot_warn(wal_pages_before);
329
330    let original_busy_timeout = pool.config().busy_timeout;
331
332    if let Err(e) = conn.busy_timeout(config.truncate_busy_timeout) {
333        // Setup failed before the TRUNCATE pragma ever ran — this is a skip,
334        // not an attempt. `last_attempt` must NOT advance here (ADR-091
335        // §377-382): stamping now would suppress the next eligible attempt
336        // for the full `truncate_min_interval` on a path that never touched
337        // the WAL at all.
338        tracing::warn!(
339            target: "khive_db::checkpoint",
340            error = %e,
341            "failed to lower busy_timeout for TRUNCATE attempt; skipping"
342        );
343        return None;
344    }
345
346    #[cfg(unix)]
347    let mut holder_attribution = capture_walpin_attribution_request(pool, truncate_state);
348    #[cfg(unix)]
349    let mut sidecar_attribution = None;
350    #[cfg(not(unix))]
351    let sidecar_attribution = None;
352
353    // Only now is this a genuine attempt: the writer is held, the threshold
354    // and interval gates passed, and the busy_timeout override is in effect
355    // immediately before the TRUNCATE pragma itself.
356    truncate_state.last_attempt = Some(Instant::now());
357    #[cfg(test)]
358    off_worker::cycle_panic_seam::after_attempt_decided(pool.canonical_path());
359
360    let start = Instant::now();
361    let outcome = query_truncate_observation(conn);
362    record_checkpoint_run_result(
363        pool,
364        outcome.as_ref().ok().map(|observation| {
365            (
366                observation.busy,
367                observation.log_frames,
368                observation.checkpointed_frames,
369            )
370        }),
371    );
372    let elapsed = start.elapsed();
373
374    // Restore the pool's configured busy_timeout immediately after the
375    // attempt, win or lose, before any other logging or bookkeeping.
376    if let Err(e) = conn.busy_timeout(original_busy_timeout) {
377        tracing::warn!(
378            target: "khive_db::checkpoint",
379            error = %e,
380            "failed to restore busy_timeout after TRUNCATE attempt"
381        );
382    }
383
384    match outcome {
385        Ok(_) => {
386            let wal_pages_after = query_wal_pages(pool, conn);
387            if let Some(pages) = wal_pages_after {
388                tracing::info!(target: "khive_db::checkpoint",
389                    wal_pages_before,
390                    wal_pages_after = pages,
391                    elapsed_ms = elapsed.as_millis() as u64,
392                    "WAL TRUNCATE checkpoint attempted"
393                );
394            } else {
395                tracing::info!(target: "khive_db::checkpoint",
396                    wal_pages_before,
397                    wal_pages_after_unavailable = true,
398                    elapsed_ms = elapsed.as_millis() as u64,
399                    "WAL TRUNCATE checkpoint attempted"
400                );
401            }
402
403            if truncate_needs_attribution(wal_pages_before, wal_pages_after) {
404                let snapshot = khive_storage::tx_registry::snapshot();
405                if let Some(pages) = wal_pages_after {
406                    log_truncate_no_progress_warn(wal_pages_before, pages, &snapshot);
407                } else {
408                    tracing::warn!(target: "khive_db::checkpoint",
409                        wal_pages_before,
410                        "WAL TRUNCATE progress unmeasured; checking possible holders in this process and others"
411                    );
412                    log_tx_registry_entries_warn(wal_pages_before, &snapshot);
413                }
414                #[cfg(test)]
415                if let Some(path) = pool.canonical_path() {
416                    truncate_report_test_sync::after_no_progress_before_report(path);
417                }
418                #[cfg(unix)]
419                {
420                    // The census above had to be captured before TRUNCATE so
421                    // a transient holder remains attributable. The bounded
422                    // sidecar walk itself must not run here: it keeps its own
423                    // awaited pass in the async owner, which orders it against
424                    // the tick's housekeeping. Hand the immutable request back
425                    // to that owner for its `spawn_blocking` pass.
426                    sidecar_attribution = holder_attribution.take();
427                }
428                log_backfill_gap(pool, conn);
429            }
430
431            note_truncate_outcome(config, wal_pages_after, truncate_state);
432        }
433        Err(e) => {
434            tracing::warn!(
435                target: "khive_db::checkpoint",
436                error = %e,
437                wal_pages_before,
438                "WAL TRUNCATE attempt failed"
439            );
440            log_tx_registry_snapshot_warn(wal_pages_before);
441            note_truncate_outcome(config, Some(wal_pages_before), truncate_state);
442        }
443    }
444    #[cfg(unix)]
445    if let Some(WalpinAttributionRequest::Fresh {
446        previous_last_attempt,
447        ..
448    }) = holder_attribution.as_ref()
449    {
450        truncate_state.restore_walpin_full_scan_reservation(*previous_last_attempt);
451    }
452    sidecar_attribution
453}
454
455/// ADR-091 Plank 2: track measured TRUNCATE outcomes that fail to bring
456/// `wal_pages` below `warn_pages`, firing a one-shot escalated WARN at the
457/// third such failure. An unmeasured attempt leaves the streak unchanged;
458/// only a measured result below `warn_pages` resets it.
459pub(super) fn note_truncate_outcome(
460    config: &CheckpointConfig,
461    wal_pages_after: Option<u64>,
462    state: &mut TruncateState,
463) {
464    // Metrics read-surface (load/perf harness): this function runs exactly
465    // once per genuine TRUNCATE attempt (both the `Ok` and `Err` outcome
466    // arms in `maybe_truncate` call it once each), so incrementing here
467    // counts total attempts without a separate call site.
468    TRUNCATE_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
469
470    if let Some(wal_pages_after) = wal_pages_after {
471        if wal_pages_after >= config.warn_pages {
472            state.consecutive_failures = state.consecutive_failures.saturating_add(1);
473            if state.consecutive_failures == 3 {
474                tracing::warn!(target: "khive_db::checkpoint",
475                    wal_pages_after,
476                    warn_threshold = config.warn_pages,
477                    "WAL TRUNCATE has failed to clear WAL pressure for 3 consecutive attempts"
478                );
479            }
480        } else {
481            state.consecutive_failures = 0;
482        }
483    }
484
485    TRUNCATE_CONSECUTIVE_FAILURES.store(state.consecutive_failures as u64, Ordering::Relaxed);
486}
487
488#[cfg(test)]
489pub(super) mod truncate_report_test_sync {
490    use std::path::{Path, PathBuf};
491    use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
492    use std::sync::Mutex;
493
494    struct Hook {
495        db_path: PathBuf,
496        reached_tx: SyncSender<()>,
497        proceed_rx: Receiver<()>,
498    }
499
500    static HOOK: Mutex<Option<Hook>> = Mutex::new(None);
501
502    pub(crate) fn install(db_path: PathBuf) -> (Receiver<()>, SyncSender<()>) {
503        let (reached_tx, reached_rx) = sync_channel(0);
504        let (proceed_tx, proceed_rx) = sync_channel(0);
505        let replaced = HOOK
506            .lock()
507            .unwrap_or_else(|poisoned| poisoned.into_inner())
508            .replace(Hook {
509                db_path,
510                reached_tx,
511                proceed_rx,
512            });
513        assert!(replaced.is_none(), "truncate report hook already installed");
514        (reached_rx, proceed_tx)
515    }
516
517    pub(crate) fn uninstall() {
518        *HOOK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
519    }
520
521    pub(crate) fn after_no_progress_before_report(db_path: &Path) {
522        let hook = {
523            let mut guard = HOOK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
524            match guard.as_ref() {
525                Some(hook) if hook.db_path == db_path => guard.take(),
526                _ => None,
527            }
528        };
529        let Some(hook) = hook else {
530            return;
531        };
532        let _ = hook.reached_tx.send(());
533        let _ = hook.proceed_rx.recv();
534    }
535}