1use 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
22pub(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
44pub(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
61pub(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
110pub(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#[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
158pub 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
202pub(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
304pub(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 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 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 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 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 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
455pub(super) fn note_truncate_outcome(
460 config: &CheckpointConfig,
461 wal_pages_after: Option<u64>,
462 state: &mut TruncateState,
463) {
464 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}