1use std::path::{Path, PathBuf};
55use std::time::Duration;
56
57use rusqlite::Connection;
58use serde::Serialize;
59
60use crate::checkpoint;
61use crate::pool::ConnectionPool;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
70pub struct CheckpointProbe {
71 pub busy: i64,
72 pub log_frames: i64,
73 pub checkpointed_frames: i64,
74}
75
76impl CheckpointProbe {
77 pub fn pin_depth(&self) -> i64 {
80 (self.log_frames - self.checkpointed_frames).max(0)
81 }
82}
83
84pub fn checkpoint_probe(conn: &Connection) -> rusqlite::Result<CheckpointProbe> {
98 conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| {
99 Ok(CheckpointProbe {
100 busy: row.get(0)?,
101 log_frames: row.get(1)?,
102 checkpointed_frames: row.get(2)?,
103 })
104 })
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
113pub struct CheckpointCounters {
114 pub last_observed_wal_pages: Option<u64>,
115 pub truncate_attempts: u64,
116 pub truncate_consecutive_failures: u64,
117 pub checkpoint_skipped_ticks: u64,
118 pub checkpoint_consecutive_skips: u64,
119 pub checkpoint_last_skip_wal_pages: Option<u64>,
120}
121
122pub fn checkpoint_counters() -> CheckpointCounters {
124 CheckpointCounters {
125 last_observed_wal_pages: checkpoint::last_observed_wal_pages(),
126 truncate_attempts: checkpoint::truncate_attempts(),
127 truncate_consecutive_failures: checkpoint::truncate_consecutive_failures(),
128 checkpoint_skipped_ticks: checkpoint::checkpoint_skipped_ticks(),
129 checkpoint_consecutive_skips: checkpoint::checkpoint_consecutive_skips(),
130 checkpoint_last_skip_wal_pages: checkpoint::checkpoint_last_skip_wal_pages(),
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141pub struct BuildIdentity {
142 pub version: String,
143 pub build_hash: Option<String>,
144}
145
146impl BuildIdentity {
147 pub fn from_env(version: &str, build_hash: Option<&str>) -> Self {
149 Self {
150 version: version.to_string(),
151 build_hash: build_hash.map(str::to_string),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct WalFileState {
159 pub wal_path: String,
160 pub wal_size_bytes: Option<u64>,
163 pub unavailable_reason: Option<String>,
164}
165
166pub fn wal_file_state(db_path: &Path) -> WalFileState {
168 let wal_path = wal_sidecar_path(db_path);
169 match std::fs::metadata(&wal_path) {
170 Ok(md) => WalFileState {
171 wal_path: wal_path.display().to_string(),
172 wal_size_bytes: Some(md.len()),
173 unavailable_reason: None,
174 },
175 Err(e) => WalFileState {
176 wal_path: wal_path.display().to_string(),
177 wal_size_bytes: None,
178 unavailable_reason: Some(e.to_string()),
179 },
180 }
181}
182
183fn wal_sidecar_path(db_path: &Path) -> PathBuf {
186 let mut s = db_path.as_os_str().to_os_string();
187 s.push("-wal");
188 PathBuf::from(s)
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize)]
201pub struct WalPinAttribution {
202 pub available: bool,
205 pub unavailable_reason: Option<String>,
206 pub census_holder_pids: Vec<u32>,
208 pub census_uninspectable_pids: Vec<u32>,
209 pub census_truncated: bool,
210 pub census_is_complete: bool,
211 pub reporting: Vec<WalPinHolder>,
213 pub registered_silent_pids: Vec<u32>,
215 pub unknown_pids: Vec<u32>,
217 pub census_pids_without_attribution: Vec<u32>,
219 pub fully_attributed: bool,
222 pub sidecar_entries: Vec<serde_json::Value>,
224 pub sidecar_listing_truncated: bool,
225 pub sidecar_entries_cleanup_would_reap: usize,
226}
227
228#[derive(Debug, Clone, PartialEq, Serialize)]
232pub struct WalPinHolder {
233 pub pid: u32,
234 pub process_role: String,
235 pub current_oldest_tx_age_secs: f64,
236 pub oldest_tx_label: Option<String>,
237 pub attribution_is_evidence_backed: bool,
238}
239
240impl WalPinAttribution {
241 fn unavailable(reason: impl Into<String>) -> Self {
242 Self {
243 available: false,
244 unavailable_reason: Some(reason.into()),
245 census_holder_pids: Vec::new(),
246 census_uninspectable_pids: Vec::new(),
247 census_truncated: false,
248 census_is_complete: false,
249 reporting: Vec::new(),
250 registered_silent_pids: Vec::new(),
251 unknown_pids: Vec::new(),
252 census_pids_without_attribution: Vec::new(),
253 fully_attributed: false,
254 sidecar_entries: Vec::new(),
255 sidecar_listing_truncated: false,
256 sidecar_entries_cleanup_would_reap: 0,
257 }
258 }
259}
260
261#[cfg(unix)]
266pub fn wal_pin_attribution(db_path: &Path, _sweep_interval: Duration) -> WalPinAttribution {
267 use crate::walpin;
268
269 let census = match walpin::census_holders(db_path) {
270 Ok(c) => c,
271 Err(e) => return WalPinAttribution::unavailable(format!("census_holders failed: {e}")),
272 };
273
274 let mut census_holder_pids: Vec<u32> = census.holders.iter().copied().collect();
275 census_holder_pids.sort_unstable();
276
277 WalPinAttribution {
278 available: false,
279 unavailable_reason: Some(
280 "sidecar-to-holder reconciliation not available: this tree's khive-db exposes \
281 sidecar enumeration only via walpin::enumerate_live, which deletes stale/malformed \
282 entries as part of its cleanup pass; a diagnostics probe must not delete forensic \
283 sidecar evidence, so only the OS holder census below was collected"
284 .to_string(),
285 ),
286 census_holder_pids,
287 census_uninspectable_pids: census.uninspectable_pids.clone(),
288 census_truncated: census.truncated,
289 census_is_complete: census.is_complete(),
290 reporting: Vec::new(),
291 registered_silent_pids: Vec::new(),
292 unknown_pids: Vec::new(),
293 census_pids_without_attribution: Vec::new(),
294 fully_attributed: false,
295 sidecar_entries: Vec::new(),
296 sidecar_listing_truncated: false,
297 sidecar_entries_cleanup_would_reap: 0,
298 }
299}
300
301#[cfg(not(unix))]
302pub fn wal_pin_attribution(_db_path: &Path, _sweep_interval: Duration) -> WalPinAttribution {
303 WalPinAttribution::unavailable("WAL-pin attribution requires a Unix platform")
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize)]
308pub struct DbDiagnostics {
309 pub build: BuildIdentity,
310 pub db_path: Option<String>,
313 pub wal_file: Option<WalFileState>,
314 pub checkpoint_counters: CheckpointCounters,
315 pub checkpoint_probe: Option<CheckpointProbe>,
316 pub checkpoint_probe_error: Option<String>,
317 pub wal_pin: WalPinAttribution,
318}
319
320pub fn collect(
335 pool: &ConnectionPool,
336 build: BuildIdentity,
337 sweep_interval: Duration,
338) -> DbDiagnostics {
339 let counters = checkpoint_counters();
340
341 let Some(path) = pool.config().path.clone() else {
342 return DbDiagnostics {
343 build,
344 db_path: None,
345 wal_file: None,
346 checkpoint_counters: counters,
347 checkpoint_probe: None,
348 checkpoint_probe_error: Some(
349 "in-memory database: no WAL file and no checkpoint to probe".to_string(),
350 ),
351 wal_pin: WalPinAttribution::unavailable(
352 "in-memory database: no file for the OS holder census",
353 ),
354 };
355 };
356
357 let (probe, probe_error) = match probe_pool(pool) {
358 Ok(p) => (Some(p), None),
359 Err(e) => (None, Some(e)),
360 };
361
362 DbDiagnostics {
363 build,
364 db_path: Some(path.display().to_string()),
365 wal_file: Some(wal_file_state(&path)),
366 checkpoint_counters: counters,
367 checkpoint_probe: probe,
368 checkpoint_probe_error: probe_error,
369 wal_pin: wal_pin_attribution(&path, sweep_interval),
370 }
371}
372
373fn probe_pool(pool: &ConnectionPool) -> Result<CheckpointProbe, String> {
379 let conn = pool
380 .open_standalone_writer()
381 .map_err(|e| format!("guarded standalone open refused: {e}"))?;
382 checkpoint_probe(&conn).map_err(|e| format!("PRAGMA wal_checkpoint(PASSIVE) failed: {e}"))
383}
384
385#[cfg(test)]
386mod tests {
387 use serial_test::serial;
388
389 use super::*;
390 use crate::pool::{ConnectionPool, PoolConfig};
391
392 fn seeded_pool(dir: &tempfile::TempDir) -> (ConnectionPool, PathBuf) {
393 let path = dir.path().join("diag.db");
394 let pool = ConnectionPool::new(PoolConfig {
395 path: Some(path.clone()),
396 ..PoolConfig::default()
397 })
398 .expect("pool open");
399 {
400 let writer = pool.try_writer().expect("writer");
401 writer
402 .conn()
403 .execute_batch(
404 "CREATE TABLE t (x INTEGER); \
405 INSERT INTO t VALUES (1), (2), (3);",
406 )
407 .expect("seed writes");
408 }
409 (pool, path)
410 }
411
412 #[test]
413 fn checkpoint_probe_returns_a_well_formed_triple_on_a_file_backed_db() {
414 let dir = tempfile::tempdir().expect("tempdir");
415 let (pool, _path) = seeded_pool(&dir);
416 let conn = pool.open_standalone_writer().expect("standalone");
417
418 let probe = checkpoint_probe(&conn).expect("probe must succeed on a WAL database");
419
420 assert!(
421 probe.busy == 0 || probe.busy == 1,
422 "busy is a 0/1 flag, got {}",
423 probe.busy
424 );
425 assert!(
426 probe.log_frames >= 0,
427 "a WAL database must report a non-negative frame count, got {}",
428 probe.log_frames
429 );
430 assert!(
431 probe.checkpointed_frames >= 0,
432 "checkpointed frames must be non-negative, got {}",
433 probe.checkpointed_frames
434 );
435 assert!(
436 probe.checkpointed_frames <= probe.log_frames,
437 "a PASSIVE pass cannot checkpoint more frames than the WAL holds: {probe:?}"
438 );
439 assert!(probe.pin_depth() >= 0, "pin depth clamps at 0: {probe:?}");
440 }
441
442 #[test]
445 #[serial(checkpoint_skip_metrics)]
446 fn checkpoint_probe_does_not_perturb_the_adr091_counters() {
447 crate::checkpoint::reset_checkpoint_metrics_for_tests();
448 let dir = tempfile::tempdir().expect("tempdir");
449 let (pool, _path) = seeded_pool(&dir);
450 let conn = pool.open_standalone_writer().expect("standalone");
451
452 let before = checkpoint_counters();
453 for _ in 0..3 {
454 checkpoint_probe(&conn).expect("probe must succeed");
455 }
456 let after = checkpoint_counters();
457
458 assert_eq!(
459 before, after,
460 "checkpoint_probe must leave every ADR-091 counter untouched"
461 );
462 }
463
464 #[test]
465 fn wal_file_state_reports_the_sidecar_size_for_a_live_db() {
466 let dir = tempfile::tempdir().expect("tempdir");
467 let (_pool, path) = seeded_pool(&dir);
468
469 let state = wal_file_state(&path);
470 assert!(
471 state.wal_path.ends_with("diag.db-wal"),
472 "WAL path is the db path plus a -wal suffix, got {}",
473 state.wal_path
474 );
475 assert!(
476 state.wal_size_bytes.is_some(),
477 "a seeded WAL database must have a stat-able -wal file: {state:?}"
478 );
479 assert!(state.unavailable_reason.is_none(), "{state:?}");
480 }
481
482 #[test]
483 fn wal_file_state_degrades_with_a_reason_when_the_sidecar_is_absent() {
484 let dir = tempfile::tempdir().expect("tempdir");
485 let state = wal_file_state(&dir.path().join("never-created.db"));
486 assert!(state.wal_size_bytes.is_none());
487 assert!(
488 state.unavailable_reason.is_some(),
489 "an absent WAL file must carry a reason, not a silent zero: {state:?}"
490 );
491 }
492
493 #[test]
494 fn collect_on_a_file_backed_db_carries_build_identity_and_every_counter() {
495 let dir = tempfile::tempdir().expect("tempdir");
496 let (pool, _path) = seeded_pool(&dir);
497
498 let report = collect(
499 &pool,
500 BuildIdentity::from_env("9.9.9", Some("deadbeef")),
501 Duration::from_secs(30),
502 );
503
504 assert_eq!(report.build.version, "9.9.9");
505 assert_eq!(report.build.build_hash.as_deref(), Some("deadbeef"));
506 assert!(report.db_path.is_some());
507 assert!(
508 report.checkpoint_probe.is_some(),
509 "file-backed collect must land a probe; error was {:?}",
510 report.checkpoint_probe_error
511 );
512 assert!(
513 report.wal_file.as_ref().and_then(|w| w.wal_size_bytes) >= Some(0),
514 "wal_size_bytes must be a non-negative byte count when present"
515 );
516
517 let json = serde_json::to_value(&report).expect("report serializes");
518 let counters = json
519 .get("checkpoint_counters")
520 .expect("counters section present");
521 for key in [
522 "last_observed_wal_pages",
523 "truncate_attempts",
524 "truncate_consecutive_failures",
525 "checkpoint_skipped_ticks",
526 "checkpoint_consecutive_skips",
527 "checkpoint_last_skip_wal_pages",
528 ] {
529 assert!(counters.get(key).is_some(), "counter {key} must be present");
530 }
531 }
532
533 #[test]
536 fn never_observed_sentinels_serialize_as_null() {
537 let counters = CheckpointCounters {
538 last_observed_wal_pages: None,
539 truncate_attempts: 0,
540 truncate_consecutive_failures: 0,
541 checkpoint_skipped_ticks: 0,
542 checkpoint_consecutive_skips: 0,
543 checkpoint_last_skip_wal_pages: None,
544 };
545 let json = serde_json::to_value(counters).expect("serializes");
546 assert!(json["last_observed_wal_pages"].is_null());
547 assert!(json["checkpoint_last_skip_wal_pages"].is_null());
548 }
549
550 #[test]
554 fn probe_refuses_a_missing_configured_path_without_creating_it() {
555 let dir = tempfile::tempdir().expect("tempdir");
556 let (pool, path) = seeded_pool(&dir);
557
558 for suffix in ["", "-wal", "-shm"] {
559 let mut p = path.as_os_str().to_os_string();
560 p.push(suffix);
561 let _ = std::fs::remove_file(PathBuf::from(p));
562 }
563 assert!(!path.exists(), "precondition: the database file is gone");
564
565 let report = collect(
566 &pool,
567 BuildIdentity::from_env("0.0.0", None),
568 Duration::from_secs(30),
569 );
570
571 assert!(
572 report.checkpoint_probe.is_none(),
573 "a missing database must not yield a probe result: {report:?}"
574 );
575 assert!(
576 report.checkpoint_probe_error.is_some(),
577 "a missing database must say why there is no probe: {report:?}"
578 );
579 assert!(
580 !path.exists(),
581 "a diagnostics request must never create the database it was asked about"
582 );
583 }
584
585 #[test]
588 fn collect_degrades_gracefully_for_an_in_memory_backend() {
589 let pool = ConnectionPool::new(PoolConfig::default()).expect("in-memory pool");
590 let report = collect(
591 &pool,
592 BuildIdentity::from_env("0.0.0", None),
593 Duration::from_secs(30),
594 );
595
596 assert!(report.db_path.is_none());
597 assert!(report.wal_file.is_none());
598 assert!(report.checkpoint_probe.is_none());
599 assert!(
600 report.checkpoint_probe_error.is_some(),
601 "an in-memory report must say WHY there is no probe"
602 );
603 assert!(!report.wal_pin.available);
604 assert!(report.wal_pin.unavailable_reason.is_some());
605 }
606
607 #[cfg(unix)]
610 #[test]
611 fn wal_pin_attribution_reports_census_but_never_claims_full_attribution() {
612 let dir = tempfile::tempdir().expect("tempdir");
613 let (pool, path) = seeded_pool(&dir);
614 let _ = &pool;
615
616 let pin = wal_pin_attribution(&path, Duration::from_secs(30));
617
618 assert!(
619 !pin.fully_attributed,
620 "sidecar reconciliation is not ported: this must never claim completeness"
621 );
622 assert!(
623 pin.unavailable_reason.is_some(),
624 "the gap must be explained, not silent: {pin:?}"
625 );
626 assert!(pin.sidecar_entries.is_empty());
627 assert!(pin.reporting.is_empty());
628 }
629}