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}