Skip to main content

khive_db/checkpoint/
lifecycle.rs

1//! Session sweeps, lifecycle emission, and checkpoint connection ownership.
2
3use std::sync::atomic::Ordering;
4use std::sync::Arc;
5use std::time::Instant;
6
7use crate::pool::ConnectionPool;
8
9#[cfg(doc)]
10use super::run_checkpoint_task;
11use super::{
12    log_tx_age_emission, ReaderCheckoutSweepState, SessionSweepConfig, TxAgeSweepState,
13    WalpinSidecarState, CHECKPOINT_LIFECYCLE_APPEND_ATTEMPTS, CHECKPOINT_LIFECYCLE_APPEND_FAILURES,
14    CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS,
15};
16
17/// One file-backed backend the session sweep observes (ADR-091 Amendment 3
18/// fan-out). `is_main` selects which [`khive_storage::tx_registry::TxOriginFilter`]
19/// variant scopes this backend's view of the registry: the main backend's
20/// `Main` filter additionally observes `Unscoped` spans (the
21/// never-silently-drop fallback for call sites not yet threaded to an
22/// origin); a secondary backend's `Secondary` filter is scoped to exactly
23/// its own identity. A pool whose origin is `Memory` contributes no entry —
24/// in-memory backends have no sidecar and nothing to attribute
25/// cross-process.
26pub struct SweepBackend {
27    pub pool: Arc<ConnectionPool>,
28    pub is_main: bool,
29}
30
31/// Per-backend state the session sweep carries across ticks: this backend's
32/// registry view, its own edge-triggered age-sweep state machine (so a
33/// sustained stale span on one backend logs independently of the others),
34/// and its own walpin sidecar (`None` if the sidecar is disabled or this
35/// backend's origin is `Memory`).
36struct BackendSweep {
37    pool: Arc<ConnectionPool>,
38    filter: khive_storage::tx_registry::TxOriginFilter,
39    tx_age_state: TxAgeSweepState,
40    reader_checkout_state: ReaderCheckoutSweepState,
41    sidecar: Option<WalpinSidecarState>,
42}
43
44/// ADR-091 Amendment 2 Plank A (Amendment 3: per-backend fan-out): run the
45/// observe-only per-session sweep.
46///
47/// Every non-daemon `kkernel mcp` process runs this instead of the daemon's
48/// `run_checkpoint_task`: same `tx_registry` age check and Plank B heartbeat
49/// refresh, but no PASSIVE/TRUNCATE checkpointing — checkpointing stays
50/// daemon-owned. Stays ONE task for the whole process, but fans out
51/// internally: each file-backed backend in `backends` gets its own
52/// registry view, age-sweep state, and sidecar directory, so a long span on
53/// a secondary backend is attributed (and heartbeats) only in that
54/// backend's own sidecar — never the main backend's. Loops until
55/// `shutdown_rx` observes a change (or its sender is dropped), removing
56/// every written heartbeat on the way out.
57pub async fn run_session_sweep_task(
58    backends: Vec<SweepBackend>,
59    config: SessionSweepConfig,
60    mut shutdown_rx: tokio::sync::watch::Receiver<()>,
61) {
62    let mut interval = tokio::time::interval(config.interval);
63    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
64
65    let mut sweeps: Vec<BackendSweep> = Vec::with_capacity(backends.len());
66    for backend in backends {
67        let identity = match backend.pool.origin() {
68            khive_storage::tx_registry::TxOrigin::Database(id) => id,
69            // No on-disk file, so no sidecar and no cross-process
70            // attribution surface — nothing for this sweep to fan out to.
71            khive_storage::tx_registry::TxOrigin::Memory
72            | khive_storage::tx_registry::TxOrigin::Unscoped => continue,
73        };
74        let filter = if backend.is_main {
75            khive_storage::tx_registry::TxOriginFilter::Main(identity)
76        } else {
77            khive_storage::tx_registry::TxOriginFilter::Secondary(identity)
78        };
79        let sidecar = WalpinSidecarState::new(
80            backend.pool.canonical_path(),
81            true,
82            "session",
83            config.interval,
84        );
85        sweeps.push(BackendSweep {
86            pool: backend.pool,
87            filter,
88            tx_age_state: TxAgeSweepState::default(),
89            reader_checkout_state: ReaderCheckoutSweepState::default(),
90            sidecar,
91        });
92    }
93    for sweep in sweeps.iter_mut() {
94        if let Some(sidecar) = sweep.sidecar.as_mut() {
95            sidecar.register_beacon().await;
96        }
97    }
98
99    loop {
100        tokio::select! {
101            _ = interval.tick() => {}
102            _ = shutdown_rx.changed() => break,
103        }
104
105        for sweep in sweeps.iter_mut() {
106            sweep
107                .reader_checkout_state
108                .sweep_at(&sweep.pool, Instant::now());
109            let oldest = khive_storage::tx_registry::oldest_for(&sweep.filter);
110            for emission in sweep.tx_age_state.observe(
111                oldest.as_ref().map(|s| (s.id, s.age, s.label.clone())),
112                config.tx_warn_secs,
113                config.tx_max_age_secs,
114            ) {
115                log_tx_age_emission(&emission);
116            }
117            if let Some(sidecar) = sweep.sidecar.as_mut() {
118                sidecar.observe(oldest, config.tx_warn_secs).await;
119            }
120        }
121    }
122
123    for sweep in sweeps.iter_mut() {
124        if let Some(sidecar) = sweep.sidecar.as_mut() {
125            sidecar.shutdown().await;
126        }
127    }
128}
129
130/// The event sink and namespace owned by one checkpoint task in a fan-out.
131///
132/// Backend role and lifecycle ownership are separate: a secondary task may
133/// own lifecycle emission when the deployment's main backend is in-memory.
134#[derive(Clone)]
135pub struct CheckpointLifecycleOwner {
136    event_store: Arc<dyn khive_storage::EventStore>,
137    namespace: String,
138}
139
140impl CheckpointLifecycleOwner {
141    /// Designate `event_store` as the lifecycle sink for one checkpoint task.
142    pub fn new(
143        event_store: Arc<dyn khive_storage::EventStore>,
144        namespace: impl Into<String>,
145    ) -> Self {
146        Self {
147            event_store,
148            namespace: namespace.into(),
149        }
150    }
151}
152
153/// Maximum number of checkpoint lifecycle events waiting behind the append
154/// currently owned by the worker. One queued row preserves a recent outcome
155/// without allowing sustained writer contention to grow memory without bound.
156const CHECKPOINT_LIFECYCLE_QUEUE_CAPACITY: usize = 1;
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub(super) struct CheckpointPressureEpisode {
160    pub(super) elevated_ticks: u64,
161    pub(super) peak_wal_pages: u64,
162}
163
164impl CheckpointPressureEpisode {
165    pub(super) fn start(wal_pages: u64) -> Self {
166        Self {
167            elevated_ticks: 1,
168            peak_wal_pages: wal_pages,
169        }
170    }
171
172    pub(super) fn observe(&mut self, wal_pages: u64) {
173        self.elevated_ticks = self.elevated_ticks.saturating_add(1);
174        self.peak_wal_pages = self.peak_wal_pages.max(wal_pages);
175    }
176}
177
178/// Zero-wait handoff from the checkpoint scheduler to its lifecycle sink.
179///
180/// The worker serializes appends, preserving the order of every event that is
181/// accepted. The scheduler only calls [`tokio::sync::mpsc::Sender::try_send`]:
182/// if the worker and its single queue slot are both occupied, telemetry is
183/// dropped rather than delaying the next checkpoint cycle. The first drop in
184/// each uninterrupted full-queue episode warns; a successful enqueue re-arms
185/// that warning without producing per-tick log spam.
186pub(super) struct CheckpointLifecycleEmitter {
187    namespace: Option<String>,
188    sender: Option<tokio::sync::mpsc::Sender<khive_storage::Event>>,
189    worker: Option<tokio::task::JoinHandle<()>>,
190    busy_warning_emitted: bool,
191}
192
193impl CheckpointLifecycleEmitter {
194    pub(super) fn new(owner: Option<CheckpointLifecycleOwner>) -> Self {
195        let Some(owner) = owner else {
196            return Self {
197                namespace: None,
198                sender: None,
199                worker: None,
200                busy_warning_emitted: false,
201            };
202        };
203
204        let namespace = owner.namespace.clone();
205        let (sender, mut receiver) =
206            tokio::sync::mpsc::channel::<khive_storage::Event>(CHECKPOINT_LIFECYCLE_QUEUE_CAPACITY);
207        let worker = tokio::spawn(async move {
208            while let Some(event) = receiver.recv().await {
209                let kind = event.kind;
210                CHECKPOINT_LIFECYCLE_APPEND_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
211                if let Err(err) = owner.event_store.append_event(event).await {
212                    CHECKPOINT_LIFECYCLE_APPEND_FAILURES.fetch_add(1, Ordering::Relaxed);
213                    tracing::warn!(target: "khive_db::checkpoint",
214                        error = %err,
215                        event_kind = %kind.name(),
216                        "checkpoint lifecycle event append failed"
217                    );
218                }
219            }
220        });
221
222        Self {
223            namespace: Some(namespace),
224            sender: Some(sender),
225            worker: Some(worker),
226            busy_warning_emitted: false,
227        }
228    }
229
230    /// Serialize and enqueue one lifecycle event without awaiting sink I/O.
231    /// Returns whether the row was accepted for delivery (or no sink exists).
232    pub(super) fn try_emit<P: serde::Serialize>(
233        &mut self,
234        kind: khive_types::EventKind,
235        payload: P,
236    ) -> bool {
237        let (Some(namespace), Some(sender)) = (&self.namespace, &self.sender) else {
238            return true;
239        };
240        let payload_value = match serde_json::to_value(&payload) {
241            Ok(value) => value,
242            Err(err) => {
243                tracing::warn!(target: "khive_db::checkpoint",
244                    error = %err,
245                    event_kind = %kind.name(),
246                    "failed to serialize checkpoint lifecycle event payload"
247                );
248                CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS.fetch_add(1, Ordering::Relaxed);
249                return false;
250            }
251        };
252        let payload_schema_version = match kind {
253            khive_types::EventKind::CheckpointOutcomeRecorded => 2,
254            _ => 1,
255        };
256        let event = khive_storage::Event::new(
257            namespace,
258            "checkpoint.lifecycle",
259            kind,
260            khive_types::SubstrateKind::Event,
261            "daemon:checkpoint_task",
262        )
263        .with_payload(payload_value)
264        .with_payload_schema_version(payload_schema_version);
265
266        match sender.try_send(event) {
267            Ok(()) => {
268                self.busy_warning_emitted = false;
269                true
270            }
271            Err(tokio::sync::mpsc::error::TrySendError::Full(event)) => {
272                CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS.fetch_add(1, Ordering::Relaxed);
273                if !self.busy_warning_emitted {
274                    tracing::warn!(target: "khive_db::checkpoint",
275                        event_kind = %event.kind.name(),
276                        queue_capacity = CHECKPOINT_LIFECYCLE_QUEUE_CAPACITY,
277                        "checkpoint lifecycle event dropped because the append worker is busy"
278                    );
279                    self.busy_warning_emitted = true;
280                }
281                false
282            }
283            Err(tokio::sync::mpsc::error::TrySendError::Closed(event)) => {
284                CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS.fetch_add(1, Ordering::Relaxed);
285                tracing::warn!(target: "khive_db::checkpoint",
286                    event_kind = %event.kind.name(),
287                    "checkpoint lifecycle event dropped because the append worker stopped"
288                );
289                false
290            }
291        }
292    }
293
294    /// Stop the scheduler-owned async worker without making
295    /// [`run_checkpoint_task`] wait for its current append future.
296    ///
297    /// This bounds checkpoint-task shutdown only. If the event store already
298    /// admitted the append to `spawn_blocking` or a `WriterTask`, aborting this
299    /// worker cannot cancel that downstream operation; at most one such sink
300    /// operation may outlive the checkpoint task.
301    pub(super) async fn shutdown(mut self) {
302        drop(self.sender.take());
303        let Some(worker) = self.worker.take() else {
304            return;
305        };
306        worker.abort();
307        match worker.await {
308            Ok(()) => {}
309            Err(err) if err.is_cancelled() => {}
310            Err(err) => tracing::warn!(target: "khive_db::checkpoint",
311                error = %err,
312                "checkpoint lifecycle event append worker terminated unexpectedly"
313            ),
314        }
315    }
316}
317
318impl Drop for CheckpointLifecycleEmitter {
319    fn drop(&mut self) {
320        // The normal watch-signal path calls `shutdown` and takes the handle
321        // first. This fallback covers an externally-aborted or panicking
322        // checkpoint task so the scheduler-owned async worker itself is never
323        // detached. One already-admitted downstream sink operation may outlive
324        // it; see `shutdown`'s contract above.
325        if let Some(worker) = &self.worker {
326            worker.abort();
327        }
328    }
329}
330
331/// The checkpoint task's dedicated, long-lived standalone connection to the
332/// same database file — opened once at task startup and reused for every
333/// tick's PASSIVE (and, when armed, TRUNCATE) pragma. NEVER the pool's writer
334/// mutex, which is what removes the pool-mutex ADMISSION path: a concurrent
335/// `pool.writer()` checkout no longer queues behind a checkpoint tick.
336///
337/// That removal is scoped to admission, not to SQLite-level blocking in
338/// general. `PRAGMA wal_checkpoint(PASSIVE)` takes only SQLite's CKPT lock,
339/// not the WRITE lock, so a concurrent writer can commit while a PASSIVE pass
340/// runs on this connection — true of PASSIVE specifically, not of TRUNCATE.
341/// TRUNCATE inherits RESTART semantics and additionally acquires SQLite's
342/// writer lock, so it can still block a concurrent write transaction, on any
343/// connection, for up to `truncate_busy_timeout` while it waits on a pinning
344/// reader — the same bounded cost that existed pre-fix, now paid on this
345/// dedicated connection instead of the pool writer. Serializing checkpoint
346/// admission behind the pool's writer mutex (the pre-fix design) imposed
347/// contention SQLite itself does not require; TRUNCATE's own SQLite-level
348/// write-blocking window is unaffected by that removal.
349///
350/// `None` between ticks means the connection is unavailable (never opened
351/// yet, or dropped after a prior tick's connection-level pragma failure) —
352/// the caller must report that tick `Skipped` and retry the open on the next
353/// one. A busy or inconsistent PASSIVE result also skips a tick while keeping
354/// this connection open.
355///
356/// ADR-136 D1 gate 5 classification: **checkpoint writer**. Explicitly
357/// exempt from `WriterTask`/queue routing by design (see the admission-path
358/// note above), never `SqlAccess`-reachable, never counted as a
359/// `direct_route_violation` — see the classification table in
360/// `writer_task`'s module doc.
361pub(super) struct CheckpointConnection {
362    pub(super) conn: Option<rusqlite::Connection>,
363    /// Consecutive failed `open_standalone_writer` attempts since the last
364    /// successful open (or since task startup). Drives the WARN-once /
365    /// debug-thereafter log rate-limiting in `ensure_open`: a file-backed
366    /// pool that transiently loses its dedicated connection would otherwise
367    /// log a WARN on every tick (default 500ms) for as long as the outage
368    /// lasts, which for a read-only or in-memory pool — where the open can
369    /// never succeed — means permanent per-tick WARN spam.
370    consecutive_open_failures: u32,
371}
372
373impl CheckpointConnection {
374    pub(super) fn new() -> Self {
375        Self {
376            conn: None,
377            consecutive_open_failures: 0,
378        }
379    }
380
381    /// Ensure a usable connection is open, lazily (re)opening from `pool`
382    /// when the current one is absent. Reuses the crate's existing untracked
383    /// standalone-connection open path (`ConnectionPool::open_standalone_writer_untracked`),
384    /// which applies the same pragmas (including `busy_timeout` from the pool
385    /// config) as any other standalone connection, without counting this
386    /// infrastructure connection as write-operation traffic. Returns `None` if
387    /// opening fails — an in-memory pool (no on-disk file to open a second
388    /// connection against), a read-only pool, or a transient filesystem error.
389    ///
390    /// Logging is rate-limited across a failure streak: the FIRST failure of
391    /// a streak logs at `warn!`, every subsequent identical failure (while
392    /// still failing) logs at `debug!` instead, and a successful open that
393    /// ends a streak logs one `info!` recovery line. Without this, a
394    /// permanently-unopenable pool (read-only or in-memory, selected by
395    /// `checkpoint_pool_for`) would WARN on every tick forever.
396    pub(super) fn ensure_open(&mut self, pool: &ConnectionPool) -> Option<&rusqlite::Connection> {
397        if self.conn.is_none() {
398            match pool.open_standalone_writer_untracked() {
399                Ok(conn) => {
400                    // This is the dedicated owner's own connection: disable
401                    // autocheckpoint on it unconditionally, independent of
402                    // whether the pool-level ownership claim has landed yet
403                    // (the standalone open applies the claim-dependent
404                    // value; this connection must never run an implicit
405                    // checkpoint inside its own PASSIVE/TRUNCATE work).
406                    if let Err(e) = conn.pragma_update(None, "wal_autocheckpoint", 0) {
407                        tracing::warn!(target: "khive_db::checkpoint",
408                            error = %e,
409                            "could not disable autocheckpoint on the dedicated checkpoint \
410                             connection"
411                        );
412                    }
413                    if self.consecutive_open_failures > 0 {
414                        tracing::info!(target: "khive_db::checkpoint",
415                            prior_consecutive_failures = self.consecutive_open_failures,
416                            "dedicated checkpoint connection opened successfully, ending a \
417                             failure streak"
418                        );
419                    }
420                    self.consecutive_open_failures = 0;
421                    self.conn = Some(conn);
422                }
423                Err(e) => {
424                    if self.consecutive_open_failures == 0 {
425                        tracing::warn!(target: "khive_db::checkpoint",
426                            error = %e,
427                            "failed to open the dedicated checkpoint connection; \
428                             this tick is skipped and the open retried next tick"
429                        );
430                    } else {
431                        tracing::debug!(target: "khive_db::checkpoint",
432                            error = %e,
433                            consecutive_failures = self.consecutive_open_failures,
434                            "dedicated checkpoint connection still unavailable; \
435                             this tick is skipped and the open retried next tick"
436                        );
437                    }
438                    self.consecutive_open_failures =
439                        self.consecutive_open_failures.saturating_add(1);
440                    return None;
441                }
442            }
443        }
444        self.conn.as_ref()
445    }
446}