khive_db/checkpoint/task_loop.rs
1//! Periodic checkpoint work and pressure-episode transitions.
2
3use std::sync::atomic::Ordering;
4use std::sync::Arc;
5use std::time::Instant;
6
7use crate::pool::ConnectionPool;
8
9#[cfg(unix)]
10use super::{
11 complete_walpin_attribution, run_walpin_housekeeping_if_due, WalpinSidecarState,
12 DEFAULT_SESSION_SWEEP_INTERVAL,
13};
14use super::{
15 crossing_warn, log_tx_age_emission, log_tx_registry_oldest_debug, log_tx_registry_oldest_warn,
16 log_tx_registry_snapshot_warn, log_wal_high_water_warn, note_checkpoint_pressure_observation,
17 note_checkpoint_skipped, off_worker, CheckpointConfig, CheckpointConnection,
18 CheckpointLifecycleEmitter, CheckpointLifecycleOwner, CheckpointPressureEpisode,
19 CheckpointRunTaskGuard, CheckpointSeverityRung, CheckpointSeverityState, CheckpointTick,
20 ReaderCheckoutSweepState, TruncateState, TxAgeSweepState, CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS,
21};
22
23/// Run one due FTS5 maintenance step off this task's Tokio worker thread.
24///
25/// A due step can issue up to `config.merge_pages` pages of synchronous
26/// SQLite incremental-merge I/O against a trigram index over a corpus of
27/// hundreds of thousands of rows — the same class of blocking work the
28/// WAL-pin beacon writes above already move off the worker via
29/// `tokio::task::spawn_blocking`. `conn` and `state` are moved into the
30/// blocking closure and handed back to the caller whenever the step returns,
31/// so the checkpoint task can restore its dedicated connection and scheduler
32/// state on every non-panicking path. A panic inside the step surfaces as the
33/// `JoinError`, like the beacon writes above: the connection and scheduler
34/// state moved into the task are gone with it, and the caller reopens both
35/// on the next tick instead of taking the checkpoint task down.
36pub(super) async fn run_fts_maintenance_off_worker(
37 conn: rusqlite::Connection,
38 config: crate::fts_maintenance::FtsMaintenanceConfig,
39 mut state: crate::fts_maintenance::FtsMaintenanceState,
40 now: Instant,
41) -> Result<
42 (
43 rusqlite::Connection,
44 crate::fts_maintenance::FtsMaintenanceState,
45 Result<Option<crate::fts_maintenance::FtsMaintenanceStep>, String>,
46 ),
47 tokio::task::JoinError,
48> {
49 tokio::task::spawn_blocking(move || {
50 let result = crate::fts_maintenance::run_if_due(&conn, &config, &mut state, now);
51 (conn, state, result)
52 })
53 .await
54}
55
56/// Run the WAL checkpoint background task.
57///
58/// Long-running async task — spawn with `tokio::spawn`. Loops until
59/// `shutdown_rx` observes a change (or its sender is dropped). Callers MUST
60/// hold the paired `tokio::sync::watch::Sender` for the daemon's run scope
61/// and send on it to shut down — do NOT rely on `pool`'s `Arc` refcount
62/// reaching zero; a sibling owner (e.g. `event_store`) holding its own clone
63/// makes that check unreachable (issue #774).
64///
65/// Issues `PRAGMA wal_checkpoint(PASSIVE)` every tick on the task's dedicated
66/// `CheckpointConnection` — never the pool's writer mutex, so a concurrent
67/// `pool.writer()` checkout can never queue behind a checkpoint tick. That
68/// guarantee is admission-only: an armed TRUNCATE still takes SQLite's writer
69/// lock and can block new write transactions, on any connection, for up to
70/// `truncate_busy_timeout` (see `CheckpointConnection`'s contract). The
71/// checkpoint call itself runs on `spawn_blocking`, so that wait never holds
72/// one of the runtime's worker threads. A tick is
73/// `Skipped` when that connection is unavailable or SQLite returns a busy
74/// PASSIVE row without a usable pressure observation. A
75/// WARNING fires once per below→above threshold crossing, not every tick.
76///
77/// `lifecycle_owner` (ADR-094): exactly one task in a multi-backend fan-out
78/// should receive `Some`. That task appends a best-effort
79/// `CheckpointOutcomeRecorded` event on the elevation transition and one
80/// recovery summary when pressure falls back below `warn_pages`. Sustained
81/// elevated ticks aggregate in memory and in `db_diagnostics`; they never
82/// write one primary-store row per checkpoint attempt. `None` explicitly
83/// marks a non-owner. See `crates/khive-db/docs/api/checkpoint.md` for the
84/// full shutdown-mechanism and event-emission design history.
85///
86/// `is_main` (ADR-091 Amendment 3): whether `pool` is the deployment's main
87/// backend. A daemon owning several file-backed backends spawns one task per
88/// backend, each with its own pool and shutdown-channel clone (the sender
89/// broadcasts to every receiver clone alike). Lifecycle ownership is selected
90/// independently through `lifecycle_owner`; `is_main` only controls registry
91/// filtering. See the `tx_filter` construction below.
92pub async fn run_checkpoint_task(
93 pool: Arc<ConnectionPool>,
94 config: CheckpointConfig,
95 lifecycle_owner: Option<CheckpointLifecycleOwner>,
96 mut shutdown_rx: tokio::sync::watch::Receiver<()>,
97 is_main: bool,
98) {
99 let _checkpoint_run_guard = CheckpointRunTaskGuard::start(&pool, config.interval);
100 // This task IS the dedicated checkpoint owner: claim the pool so writer
101 // connections drop the bounded autocheckpoint fallback and routine
102 // checkpoint I/O stays off application commit paths. Pools without a
103 // running checkpoint task never claim and keep SQLite's bounded WAL
104 // reclamation. A failed claim leaves connections on the bounded fallback
105 // — safe, just not the low-latency posture — so it warns and continues.
106 match pool.claim_checkpoint_ownership() {
107 Ok(()) => {
108 if let Err(e) = pool.propagate_checkpoint_claim_to_writer_task().await {
109 tracing::warn!(target: "khive_db::checkpoint",
110 error = %e,
111 "checkpoint task could not reach the writer task's connection; it keeps the \
112 bounded autocheckpoint fallback"
113 );
114 }
115 }
116 Err(e) => {
117 tracing::warn!(target: "khive_db::checkpoint",
118 error = %e,
119 "checkpoint task could not re-apply the ownership pragma on the pooled writer; \
120 writer connections keep the bounded autocheckpoint fallback unless ownership is \
121 claimed later"
122 );
123 }
124 }
125 let mut interval = tokio::time::interval(config.interval);
126 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
127 let mut severity_state = CheckpointSeverityState::default();
128 let mut tx_age_state = TxAgeSweepState::default();
129 let mut reader_checkout_state = ReaderCheckoutSweepState::default();
130 let mut was_above_high_water = false;
131 #[cfg(unix)]
132 let legacy_walpin_fallback_interval = DEFAULT_SESSION_SWEEP_INTERVAL;
133 #[cfg(unix)]
134 let mut truncate_state =
135 TruncateState::with_legacy_walpin_fallback(legacy_walpin_fallback_interval);
136 #[cfg(not(unix))]
137 let mut truncate_state = TruncateState::default();
138 let mut lifecycle_emitter = CheckpointLifecycleEmitter::new(lifecycle_owner);
139 // Independent of `severity_state` (which owns the WARN ladder): this
140 // tracks the lifecycle sink's accepted elevation state. A full queue
141 // leaves it unchanged, so an opening or recovery transition is retried
142 // without admitting more than one primary-store write for that edge.
143 let mut event_elevation_open = false;
144 let mut pressure_episode: Option<CheckpointPressureEpisode> = None;
145 // A recovery row whose `try_emit` lost the race against a full queue.
146 // Retried on later ticks (before that tick's own transition handling)
147 // instead of leaving `pressure_episode` open for a stale episode to
148 // absorb the next, genuinely separate, pressure incident (#1857).
149 let mut pending_recovery: Option<khive_storage::CheckpointOutcomeRecordedPayload> = None;
150 let mut was_observed_above_warn = false;
151 // ADR-091 Amendment 3: this task's own backend-scoped view of the
152 // registry. `is_main` selects which `TxOriginFilter` variant applies —
153 // the caller passes `true` for exactly the one checkpoint task covering
154 // the deployment's main backend, so only that task also observes legacy
155 // `Unscoped` spans from any call site not yet threaded to an origin, the
156 // designed never-silently-drop fallback. A secondary backend's task
157 // never falls back to `Unscoped`: those spans belong to the main view or
158 // to no view, never to a database they were never registered against.
159 // `None` only when this pool's own origin isn't `Database` (an in-memory
160 // checkpoint pool) — degrades to "no open span observed" for the tick
161 // rather than panicking a long-running daemon loop on an
162 // assumed-impossible state.
163 let tx_filter = match pool.origin() {
164 khive_storage::tx_registry::TxOrigin::Database(id) => Some(if is_main {
165 khive_storage::tx_registry::TxOriginFilter::Main(id)
166 } else {
167 khive_storage::tx_registry::TxOriginFilter::Secondary(id)
168 }),
169 khive_storage::tx_registry::TxOrigin::Memory
170 | khive_storage::tx_registry::TxOrigin::Unscoped => None,
171 };
172 // ADR-091 Amendment 2 Plank B: the checkpoint pool is only ever wired for
173 // file-backed backends (`checkpoint_pool_for`), so `is_file_backed: true`
174 // is always correct here. `canonical_path()` (not `pool.config().path`)
175 // so the sidecar directory is keyed off the same minted identity every
176 // alias of this backend's configured path converges to.
177 #[cfg(unix)]
178 let mut walpin_state =
179 WalpinSidecarState::new(pool.canonical_path(), true, "daemon", config.interval);
180 #[cfg(unix)]
181 if let Some(sidecar) = walpin_state.as_mut() {
182 sidecar.register_beacon().await;
183 }
184
185 // Opened once here, at task startup; `ensure_open` is a no-op in steady
186 // state and only reopens after a connection-level failure or (for an
187 // in-memory/read-only pool) retries the open on every subsequent tick.
188 let mut checkpoint_conn = CheckpointConnection::new();
189 checkpoint_conn.ensure_open(&pool);
190 // FTS5 segment maintenance shares this task's standalone connection, but
191 // not its 500 ms cadence. Each due call performs at most one bounded
192 // merge step on one table and refuses immediately when another writer
193 // owns SQLite's write lock.
194 let mut fts_maintenance_config = crate::fts_maintenance::FtsMaintenanceConfig::from_env();
195 // Secondary backends have independent schemas (for example the code-map
196 // database) and are not required to contain the substrate FTS tables.
197 // Exactly the main backend owns this derived-index maintenance.
198 fts_maintenance_config.enabled &= is_main;
199 let mut fts_maintenance_state =
200 crate::fts_maintenance::FtsMaintenanceState::new(Instant::now());
201
202 loop {
203 // A closed sender (the daemon returning without an explicit send)
204 // makes `changed()` resolve with `Err` immediately, which `select!`
205 // treats as ready — so shutdown is observed either way, not just on
206 // an explicit send.
207 tokio::select! {
208 _ = interval.tick() => {}
209 _ = shutdown_rx.changed() => break,
210 }
211
212 #[cfg(unix)]
213 truncate_state.begin_tick();
214
215 #[cfg(unix)]
216 let mut pending_sidecar_attribution = None;
217
218 let tick = if checkpoint_conn.ensure_open(&pool).is_none() {
219 note_checkpoint_skipped();
220 CheckpointTick::Skipped
221 } else {
222 // `ensure_open` above just confirmed a connection is open. Take
223 // ownership of it so the checkpoint cycle and the FTS maintenance
224 // step below can each move it onto a blocking thread; every path
225 // either restores it to `checkpoint_conn` or lets it drop, which is
226 // the moved-ownership equivalent of the former `drop_connection()`
227 // call.
228 let conn = checkpoint_conn
229 .conn
230 .take()
231 .expect("ensure_open just confirmed a connection is open");
232 match off_worker::run_checkpoint_core_off_worker(
233 Arc::clone(&pool),
234 conn,
235 config.clone(),
236 truncate_state,
237 )
238 .await
239 {
240 Ok((conn, state, Ok(outcome))) => {
241 truncate_state = state;
242 #[cfg(unix)]
243 {
244 pending_sidecar_attribution = outcome.sidecar_attribution;
245 }
246 #[cfg(not(unix))]
247 let _ = outcome.sidecar_attribution;
248
249 // Only a due tick moves the connection onto a blocking
250 // thread; an ordinary tick between maintenance intervals
251 // keeps it here and records a no-op.
252 let fts_result = if !fts_maintenance_state
253 .is_due(&fts_maintenance_config, Instant::now())
254 {
255 checkpoint_conn.conn = Some(conn);
256 Ok(None)
257 } else {
258 match run_fts_maintenance_off_worker(
259 conn,
260 fts_maintenance_config.clone(),
261 fts_maintenance_state,
262 Instant::now(),
263 )
264 .await
265 {
266 Ok((conn, state, fts_result)) => {
267 fts_maintenance_state = state;
268 checkpoint_conn.conn = Some(conn);
269 fts_result
270 }
271 Err(join_err) => {
272 // The step panicked on the blocking thread. The
273 // connection and scheduler state moved into it
274 // are gone; `ensure_open` reopens the connection
275 // next tick and the schedule restarts from now.
276 // The checkpoint pragma itself already succeeded.
277 fts_maintenance_state =
278 crate::fts_maintenance::FtsMaintenanceState::new(Instant::now());
279 Err(format!(
280 "bounded FTS5 segment maintenance task panicked: {join_err}"
281 ))
282 }
283 }
284 };
285
286 match fts_result {
287 Ok(Some(step)) => match step.outcome {
288 crate::fts_maintenance::FtsMaintenanceOutcome::Worked => {
289 tracing::info!(target: "khive_db::checkpoint",
290 table = step.table,
291 requested_pages = step.requested_pages,
292 segments_before = step.segments_before,
293 segments_after = step.segments_after,
294 "bounded FTS5 segment maintenance made progress"
295 );
296 }
297 crate::fts_maintenance::FtsMaintenanceOutcome::Busy => {
298 tracing::debug!(target: "khive_db::checkpoint",
299 table = step.table,
300 requested_pages = step.requested_pages,
301 segments = step.segments_before,
302 "bounded FTS5 segment maintenance skipped a busy writer"
303 );
304 }
305 crate::fts_maintenance::FtsMaintenanceOutcome::Noop
306 | crate::fts_maintenance::FtsMaintenanceOutcome::BelowThreshold => {
307 tracing::debug!(target: "khive_db::checkpoint",
308 table = step.table,
309 outcome = ?step.outcome,
310 segments = step.segments_before,
311 "bounded FTS5 segment maintenance had no work"
312 );
313 }
314 },
315 Ok(None) => {}
316 Err(error) => {
317 // The checkpoint pragma already succeeded. An FTS
318 // structure/read/merge error is an independent,
319 // best-effort maintenance failure and must not make
320 // the task discard an otherwise healthy connection.
321 tracing::warn!(target: "khive_db::checkpoint",
322 error = %error,
323 "bounded FTS5 segment maintenance failed"
324 );
325 }
326 }
327 match outcome.wal_pages {
328 Some(wal_pages) => CheckpointTick::Observed(wal_pages),
329 None => {
330 note_checkpoint_skipped();
331 CheckpointTick::Skipped
332 }
333 }
334 }
335 Ok((_conn, state, Err(e))) => {
336 truncate_state = state;
337 tracing::warn!(target: "khive_db::checkpoint",
338 error = %e,
339 "dedicated checkpoint connection failed a pragma; \
340 dropping it for a fresh reopen next tick"
341 );
342 note_checkpoint_skipped();
343 CheckpointTick::Skipped
344 }
345 Err(panicked) => {
346 // The cycle panicked: its connection is gone (reopened next tick) and the
347 // escalation state is the one it was handed, counted as a TRUNCATE attempt.
348 truncate_state = panicked.truncate_state;
349 tracing::warn!(target: "khive_db::checkpoint",
350 error = %panicked.join_error,
351 "WAL checkpoint cycle panicked on its blocking thread; \
352 dropping the connection for a fresh reopen next tick"
353 );
354 note_checkpoint_skipped();
355 CheckpointTick::Skipped
356 }
357 }
358 };
359
360 // A no-progress or unmeasured TRUNCATE returns a bounded attribution
361 // request alongside the core outcome. Consume it before any
362 // report-derived decision or ordinary housekeeping for this tick.
363 // The await is intentional: enumeration may perform up to 512
364 // filesystem reads/classifications, so none of that work is allowed
365 // to run on this Tokio worker, while one-pass-per-tick ordering still
366 // requires the result (or an honest worker/enumeration failure) before
367 // the fallback housekeeping arm is considered.
368 #[cfg(unix)]
369 if let Err(error) =
370 complete_walpin_attribution(pending_sidecar_attribution, &mut truncate_state).await
371 {
372 tracing::warn!(target: "khive_db::checkpoint",
373 error = %error,
374 failure_kind = error.kind(),
375 "ADR-091 Amendment 2 Plank B: no-progress sidecar attribution failed"
376 );
377 }
378
379 // ADR-091 Plank 1: age-based sweep over the registry's oldest entry
380 // MUST run on every tick, including a Skipped one — deliberately
381 // BEFORE the Skipped early-continue below. Since the dedicated
382 // checkpoint connection amendment, a `Skipped` tick means that
383 // connection was unavailable or its PASSIVE result was busy, not that
384 // some registered span held the pool's writer mutex (a checkpoint
385 // tick no longer touches it at all) — but the sweep must not go blind for the
386 // duration of that outage, and the two failure surfaces are
387 // independent: a registry span can go stale
388 // (KHIVE_TX_WARN_SECS / KHIVE_TX_MAX_AGE_SECS) while wal_pages sits
389 // well under warn_pages, or while the checkpoint connection itself is
390 // down. Edge-triggered per rung, same debounce idiom as the severity
391 // ladder below, so a sustained stale span logs once per rung rather
392 // than once per tick.
393 reader_checkout_state.sweep_at(&pool, Instant::now());
394 let oldest_tx = tx_filter
395 .as_ref()
396 .and_then(khive_storage::tx_registry::oldest_for);
397 for emission in tx_age_state.observe(
398 oldest_tx.as_ref().map(|s| (s.id, s.age, s.label.clone())),
399 config.tx_warn_secs,
400 config.tx_max_age_secs,
401 ) {
402 log_tx_age_emission(&emission);
403 }
404 // ADR-091 Amendment 2 Plank B: refresh (or clear) this daemon
405 // process's own walpin heartbeat on the same cadence, so its own
406 // pin — if any — is attributable the same way a session's is.
407 #[cfg(unix)]
408 if let Some(sidecar) = walpin_state.as_mut() {
409 sidecar
410 .observe(oldest_tx.clone(), config.tx_warn_secs)
411 .await;
412 let _ = run_walpin_housekeeping_if_due(
413 sidecar,
414 &mut truncate_state,
415 legacy_walpin_fallback_interval,
416 )
417 .await;
418 }
419
420 // Skipped ticks leave crossing state unchanged — a busy tick must not
421 // re-arm the rate limit while WAL pressure is still elevated.
422 let wal_pages = match tick {
423 CheckpointTick::Skipped => continue,
424 CheckpointTick::Observed(n) => n,
425 };
426
427 let above_warn = wal_pages >= config.warn_pages;
428 let above_high_water = wal_pages >= config.high_water_pages;
429 let above_truncate_high_water = wal_pages >= config.truncate_high_water_pages;
430 note_checkpoint_pressure_observation(above_warn, was_observed_above_warn);
431 was_observed_above_warn = above_warn;
432
433 // Per-tick debug for the oldest open entry always fires (cheap —
434 // reuses this tick's already-computed `oldest_tx`); the two
435 // `warn!`-level registry logs below are gated on the SAME crossing
436 // state as the WAL-threshold WARNs above, so sustained pressure
437 // logs once per crossing, not once per tick.
438 log_tx_registry_oldest_debug(wal_pages, oldest_tx.as_ref());
439
440 // ADR-091 severity ladder: INFO on the first below→above crossing,
441 // WARN once `warn_sustained_cycles` consecutive ticks stay elevated.
442 // The oldest-entry registry WARN rides the same INFO edge the old
443 // binary crossing_warn used to gate on.
444 for emission in severity_state.observe_wal_pages(wal_pages, &config) {
445 match emission.rung {
446 CheckpointSeverityRung::Info => {
447 log_tx_registry_oldest_warn(wal_pages, oldest_tx.as_ref());
448 tracing::info!(target: "khive_db::checkpoint",
449 wal_pages = emission.wal_pages,
450 warn_threshold = emission.threshold_pages,
451 "WAL page count crossed warn threshold"
452 );
453 }
454 CheckpointSeverityRung::Warn => {
455 tracing::warn!(target: "khive_db::checkpoint",
456 wal_pages = emission.wal_pages,
457 warn_threshold = emission.threshold_pages,
458 consecutive_cycles = emission.consecutive_cycles,
459 "WAL page count failed to drain below warn threshold"
460 );
461 }
462 CheckpointSeverityRung::Alarm => {
463 // Never produced by `observe_wal_pages`; see its doc.
464 }
465 }
466 }
467
468 let high_water_crossed = crossing_warn(above_high_water, &mut was_above_high_water);
469 if high_water_crossed {
470 log_tx_registry_snapshot_warn(wal_pages);
471 log_wal_high_water_warn(
472 wal_pages,
473 config.high_water_pages,
474 oldest_tx.as_ref(),
475 config.tx_warn_secs,
476 );
477 }
478
479 // ADR-094/#1838, #1857: one elevation row and one recovery summary
480 // per genuinely continuous episode. Sustained elevated ticks update
481 // only the bounded in-memory aggregate and process diagnostics
482 // above; a dropped recovery handoff must not fold the next,
483 // separate, pressure incident into this episode's aggregate.
484 observe_checkpoint_pressure_tick(
485 above_warn,
486 wal_pages,
487 above_high_water,
488 above_truncate_high_water,
489 &config,
490 &mut event_elevation_open,
491 &mut pressure_episode,
492 &mut pending_recovery,
493 |payload| {
494 lifecycle_emitter
495 .try_emit(khive_types::EventKind::CheckpointOutcomeRecorded, payload)
496 },
497 );
498 }
499
500 lifecycle_emitter.shutdown().await;
501
502 #[cfg(unix)]
503 if let Some(sidecar) = walpin_state.as_mut() {
504 sidecar.shutdown().await;
505 }
506}
507
508/// Whether a `CheckpointOutcomeRecorded` transition should be enqueued for
509/// this tick. Repeated observations in either state aggregate in memory;
510/// only elevation and recovery edges reach the primary store.
511pub(super) fn checkpoint_outcome_should_emit(above_warn: bool, was_elevated: bool) -> bool {
512 above_warn != was_elevated
513}
514
515/// Advance the pressure-episode/lifecycle-emission state machine for one
516/// observed tick. `try_emit` mirrors [`CheckpointLifecycleEmitter::try_emit`]
517/// — `true` means the row was handed off, `false` means the queue was full
518/// or closed.
519///
520/// #1857: on a dropped recovery handoff (`try_emit` returns `false` while
521/// `above_warn` is `false`), the closed episode's summary is stashed in
522/// `pending_recovery` for retry on later ticks — flushed here before this
523/// tick's own transition is evaluated — instead of leaving
524/// `event_elevation_open` and `pressure_episode` open for the next elevated
525/// tick to silently extend, which would report two separate pressure
526/// incidents as one merged episode.
527#[allow(clippy::too_many_arguments)]
528pub(super) fn observe_checkpoint_pressure_tick(
529 above_warn: bool,
530 wal_pages: u64,
531 above_high_water: bool,
532 above_truncate_high_water: bool,
533 config: &CheckpointConfig,
534 event_elevation_open: &mut bool,
535 pressure_episode: &mut Option<CheckpointPressureEpisode>,
536 pending_recovery: &mut Option<khive_storage::CheckpointOutcomeRecordedPayload>,
537 mut try_emit: impl FnMut(khive_storage::CheckpointOutcomeRecordedPayload) -> bool,
538) {
539 // An undelivered recovery summary is a BARRIER, not merely a retry:
540 // lifecycle consumers assert on the ordered event history (ADR-094), so
541 // a later episode's opening must never be appended ahead of an earlier
542 // episode's recovery. If the retry fails, the in-memory aggregate still
543 // advances below, but no other emission is attempted this tick — a
544 // deferred opening or recovery re-derives from state on a later tick,
545 // after the pending summary has been delivered in order.
546 let pending_blocks_emission = if let Some(payload) = pending_recovery.clone() {
547 if try_emit(payload) {
548 *pending_recovery = None;
549 false
550 } else {
551 true
552 }
553 } else {
554 false
555 };
556
557 if above_warn {
558 match pressure_episode.as_mut() {
559 Some(episode) => episode.observe(wal_pages),
560 None => *pressure_episode = Some(CheckpointPressureEpisode::start(wal_pages)),
561 }
562 } else if !*event_elevation_open {
563 // No elevation row reached the bounded handoff, so from any
564 // consumer's view this episode never opened; discarding it keeps
565 // the delivered history self-consistent. When the discard happens
566 // because the barrier suppressed the opening attempt entirely, the
567 // loss would otherwise be invisible even to the drop counters that
568 // record failed attempts, so it is counted and logged here.
569 if pending_blocks_emission && pressure_episode.is_some() {
570 CHECKPOINT_LIFECYCLE_ENQUEUE_DROPS.fetch_add(1, Ordering::Relaxed);
571 tracing::warn!(target: "khive_db::checkpoint",
572 wal_pages,
573 "checkpoint pressure episode elapsed unreported behind an undelivered recovery summary"
574 );
575 }
576 *pressure_episode = None;
577 }
578
579 if pending_blocks_emission || !checkpoint_outcome_should_emit(above_warn, *event_elevation_open)
580 {
581 return;
582 }
583 let Some(episode) = *pressure_episode else {
584 tracing::warn!(target: "khive_db::checkpoint",
585 above_warn,
586 event_elevation_open = *event_elevation_open,
587 "checkpoint pressure transition has no episode aggregate"
588 );
589 return;
590 };
591 let payload = khive_storage::CheckpointOutcomeRecordedPayload {
592 wal_pages,
593 warn_pages: config.warn_pages,
594 high_water_pages: config.high_water_pages,
595 truncate_high_water_pages: config.truncate_high_water_pages,
596 above_warn,
597 above_high_water,
598 above_truncate_high_water,
599 episode_elevated_ticks: Some(episode.elevated_ticks),
600 episode_peak_wal_pages: Some(episode.peak_wal_pages),
601 };
602 if try_emit(payload.clone()) {
603 *event_elevation_open = above_warn;
604 if !above_warn {
605 *pressure_episode = None;
606 }
607 } else if !above_warn {
608 // The recovery handoff was dropped. Close this episode locally
609 // anyway — `event_elevation_open` MUST NOT stay true, or the next
610 // elevated tick would extend this (already finished) episode's
611 // aggregate instead of starting a fresh one for what is genuinely a
612 // new pressure incident. The dropped summary itself isn't thrown
613 // away: it is stashed in `pending_recovery` and delivered on a
614 // later tick, ahead of (and as a barrier to) every subsequent
615 // emission, so lifecycle ordering survives the retry. The slot is
616 // structurally empty here: a tick that entered with an undelivered
617 // summary returned at the barrier above and never reached this arm.
618 debug_assert!(
619 pending_recovery.is_none(),
620 "recovery emission attempted while an earlier summary was still pending"
621 );
622 *event_elevation_open = false;
623 *pressure_episode = None;
624 *pending_recovery = Some(payload);
625 }
626}