Skip to main content

khive_db/
diagnostics.rs

1//! Non-mutating-by-intent database, WAL, and checkpoint diagnostics (read-only
2//! operator surface).
3//!
4//! Answers "is a reader pinning the checkpoint / why is the WAL at 64MiB"
5//! without raw SQL against a production store.
6//!
7//! NOT read-only. [`checkpoint_probe`](crate::diagnostics::checkpoint_probe) issues a real
8//! `PRAGMA wal_checkpoint(PASSIVE)`, and a PASSIVE checkpoint that succeeds
9//! BACKFILLS WAL frames into the main database — ordinary database-page
10//! writes, on the happy path. That I/O is the point: the busy/log_frames/
11//! checkpointed_frames triple reports a one-row backfill gap; a sustained
12//! pinned-frame report additionally requires a matching checkpoint run.
13//! SQLite exposes no read-only API for those counters. The
14//! guarantee is that nothing here changes logical state or destroys
15//! evidence, not that nothing touches the disk.
16//!
17//! What IS guaranteed, and what the narrowings below buy:
18//!
19//! * never creates a missing database file,
20//! * never escalates to TRUNCATE,
21//! * never perturbs the counters it reports,
22//! * never increments write-traffic acquisition counters,
23//! * never deletes a walpin sidecar entry.
24//!
25//! Deliberate narrowings make those claims true rather than aspirational:
26//!
27//! 1. [`checkpoint_probe`](crate::diagnostics::checkpoint_probe) runs a
28//!    single `PRAGMA wal_checkpoint(PASSIVE)` —
29//!    which never blocks readers or writers — and does NOT route through
30//!    [`crate::checkpoint::checkpoint_once`]: that path mutates
31//!    `TruncateState`, may escalate to TRUNCATE, and double-counts the
32//!    ADR-091 process-global counters. A verb that reports state must not
33//!    perturb the state it reports.
34//! 2. The probe's connection comes from
35//!    `ConnectionPool::open_standalone_writer_untracked`, opened without
36//!    `SQLITE_OPEN_CREATE`. A missing database yields `checkpoint_probe:
37//!    null` plus a `checkpoint_probe_error`, never a freshly created file.
38//! 3. WAL-pin attribution combines the read-only OS holder census with
39//!    `walpin::inspect_live`, a separate bounded sidecar enumerator whose
40//!    purpose flag prohibits every unlink. It applies the same descriptor-
41//!    bound trust and liveness checks as checkpoint attribution, but reports
42//!    stale cleanup candidates rather than consuming them. A complete census
43//!    plus a complete, conclusive sidecar walk can therefore report complete;
44//!    truncation, unknown entries, or holders absent from the sidecar degrade
45//!    explicitly.
46//! 4. Graph-edge integrity uses three scalar SELECTs on the same guarded
47//!    standalone connection. It exposes the exact pre-V14 duplicate-ID group
48//!    count plus raw live-edge/list-ledger counts and never repairs or deletes
49//!    data.
50//! 5. FTS5 segment diagnostics decode each index's documented one-row
51//!    structure record (`%_data.id = 10`). They never run `COUNT(*)` over or
52//!    scan a `%_idx` table, so observing segment health is bounded even when
53//!    the corpus itself is large.
54//!
55//! Checkpoint counters are process-global, while reader and writer acquisition
56//! counters belong to the supplied pool and can reset when it is reconstructed.
57//! Every payload carries [`BuildIdentity`](crate::diagnostics::BuildIdentity) and
58//! [`ProcessIdentity`](crate::diagnostics::ProcessIdentity) for the
59//! process producing the reading. The PID, OS start time, and main-pool generation
60//! identify the main pool's counter window; a secondary pool's reader/writer
61//! counters have their own reconstruction window. Checkpoint counters remain global.
62
63#[path = "diagnostics/disk_guard.rs"]
64mod disk_guard;
65#[path = "diagnostics/writer_contention.rs"]
66mod writer_contention;
67
68use std::path::{Path, PathBuf};
69use std::sync::atomic::{AtomicBool, Ordering};
70use std::sync::Arc;
71use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
72
73use khive_storage::error::StorageError;
74use khive_storage::types::StorageResult;
75use khive_storage::StorageCapability;
76use rusqlite::Connection;
77use serde::Serialize;
78
79use crate::checkpoint;
80use crate::pool::{ConnectionPool, WalCeilingSource};
81
82pub use disk_guard::DiskGuardDiagnostics;
83
84/// Raw `PRAGMA wal_checkpoint(PASSIVE)` return row.
85///
86/// SQLite returns three columns: `busy` (1 when the checkpoint could not
87/// obtain the checkpoint lock or read the WAL-index header), `log` (frames
88/// currently in the WAL), and `checkpointed` (frames moved into the database
89/// by THIS call). The one-row backfill gap is `log - checkpointed`; that
90/// difference alone does not establish a reader pin.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92pub struct CheckpointProbe {
93    pub busy: i64,
94    pub log_frames: i64,
95    pub checkpointed_frames: i64,
96}
97
98impl CheckpointProbe {
99    /// Frames still awaiting backfill in this row. Negative frame counts
100    /// (an in-memory or non-WAL database reports `-1`) return 0. Even when
101    /// busy, populated frame counts describe only a gap, never a reader pin.
102    pub fn backfill_gap_frames(&self) -> i64 {
103        if self.log_frames < 0 || self.checkpointed_frames < 0 {
104            return 0;
105        }
106        self.log_frames
107            .saturating_sub(self.checkpointed_frames)
108            .max(0)
109    }
110}
111
112/// Issue one PASSIVE checkpoint on `conn` and return the raw triple.
113///
114/// PASSIVE never blocks writers or readers, so this is safe against a live
115/// daemon. Touches none of the ADR-091 counters — unlike the periodic
116/// checkpoint task's own observation path, this does not mirror into the
117/// process-global gauges.
118///
119/// This WRITES. A PASSIVE checkpoint that makes progress backfills WAL
120/// frames into the main database file; that is normal checkpoint I/O, and on
121/// the happy path it is what the reported `checkpointed_frames` counts. The
122/// call is non-mutating in the sense that matters for a diagnostic — no
123/// logical state changes, no escalation, no evidence destroyed — but it is
124/// not write-free, and callers must not describe it as such.
125pub fn checkpoint_probe(conn: &Connection) -> rusqlite::Result<CheckpointProbe> {
126    conn.query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| {
127        Ok(CheckpointProbe {
128            busy: row.get(0)?,
129            log_frames: row.get(1)?,
130            checkpointed_frames: row.get(2)?,
131        })
132    })
133}
134
135/// ADR-091 checkpoint counters, read as one snapshot.
136///
137/// The two `Option` fields carry the `u64::MAX` "never observed" sentinel as
138/// `None`, so a caller serializing this never sees `18446744073709551615`
139/// where it means "no observation yet".
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
141pub struct CheckpointCounters {
142    pub last_observed_wal_pages: Option<u64>,
143    pub truncate_attempts: u64,
144    pub truncate_consecutive_failures: u64,
145    pub checkpoint_skipped_ticks: u64,
146    pub checkpoint_consecutive_skips: u64,
147    pub checkpoint_last_skip_wal_pages: Option<u64>,
148    pub checkpoint_pressure_elevated_ticks: u64,
149    pub checkpoint_pressure_episodes_started: u64,
150    pub checkpoint_pressure_episodes_recovered: u64,
151    pub checkpoint_lifecycle_append_attempts: u64,
152    pub checkpoint_lifecycle_append_failures: u64,
153    pub checkpoint_lifecycle_enqueue_drops: u64,
154    /// Cached-reader read transactions rolled back on reuse for exceeding
155    /// `read_tx_max_age` (#1846) — the count of WAL snapshots actually
156    /// released by that bound, not merely logged as stale.
157    pub read_tx_max_age_evictions: u64,
158}
159
160/// Snapshot the process-global ADR-091 counters.
161pub fn checkpoint_counters() -> CheckpointCounters {
162    CheckpointCounters {
163        last_observed_wal_pages: checkpoint::last_observed_wal_pages(),
164        truncate_attempts: checkpoint::truncate_attempts(),
165        truncate_consecutive_failures: checkpoint::truncate_consecutive_failures(),
166        checkpoint_skipped_ticks: checkpoint::checkpoint_skipped_ticks(),
167        checkpoint_consecutive_skips: checkpoint::checkpoint_consecutive_skips(),
168        checkpoint_last_skip_wal_pages: checkpoint::checkpoint_last_skip_wal_pages(),
169        checkpoint_pressure_elevated_ticks: checkpoint::checkpoint_pressure_elevated_ticks(),
170        checkpoint_pressure_episodes_started: checkpoint::checkpoint_pressure_episodes_started(),
171        checkpoint_pressure_episodes_recovered: checkpoint::checkpoint_pressure_episodes_recovered(
172        ),
173        checkpoint_lifecycle_append_attempts: checkpoint::checkpoint_lifecycle_append_attempts(),
174        checkpoint_lifecycle_append_failures: checkpoint::checkpoint_lifecycle_append_failures(),
175        checkpoint_lifecycle_enqueue_drops: checkpoint::checkpoint_lifecycle_enqueue_drops(),
176        read_tx_max_age_evictions: checkpoint::read_tx_max_age_evictions(),
177    }
178}
179
180/// Which build produced this reading.
181///
182/// The counters above are process-global: a stale daemon reports a stale
183/// build's state. `build_hash` is `None` unless the binary was stamped with
184/// one — this crate does not introduce a build-metadata mechanism, it only
185/// reports whatever the caller already has.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct BuildIdentity {
188    pub version: String,
189    pub build_hash: Option<String>,
190}
191
192impl BuildIdentity {
193    /// Identity of the crate that compiled this call site.
194    pub fn from_env(version: &str, build_hash: Option<&str>) -> Self {
195        Self {
196            version: version.to_string(),
197            build_hash: build_hash.map(str::to_string),
198        }
199    }
200}
201
202/// The serving OS process and the main pool's reader/writer counter generation.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub struct ProcessIdentity {
205    pub pid: u32,
206    /// OS-reported process creation time in whole Unix epoch seconds (UTC).
207    /// This is neither pool creation time nor the time of the first request.
208    pub started_at: Option<i64>,
209    /// Present when the OS lookup is unsupported or unavailable. An unknown
210    /// start time is never replaced by zero or the current wall clock.
211    pub started_at_unavailable_reason: Option<String>,
212    pub pool_generation: u64,
213}
214
215impl ProcessIdentity {
216    pub fn current(pool: &ConnectionPool) -> Self {
217        let pid = std::process::id();
218        Self::from_start_time(
219            pid,
220            crate::walpin::process_start_time_secs(pid),
221            pool.main_pool_generation(),
222        )
223    }
224
225    fn from_start_time(pid: u32, started_at: Option<i64>, pool_generation: u64) -> Self {
226        Self {
227            pid,
228            started_at,
229            started_at_unavailable_reason: started_at.is_none().then(|| {
230                format!(
231                    "OS process start time is unsupported or unavailable on {}",
232                    std::env::consts::OS
233                )
234            }),
235            pool_generation,
236        }
237    }
238}
239
240/// Absolute-free WAL file state for one database path.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242pub struct WalFileState {
243    pub wal_path: String,
244    /// `None` when the `-wal` sidecar does not exist or could not be stat'd —
245    /// the reason is in `unavailable_reason`.
246    pub wal_size_bytes: Option<u64>,
247    pub unavailable_reason: Option<String>,
248}
249
250/// Stat `<db_path>-wal` and report its size in bytes.
251pub fn wal_file_state(db_path: &Path) -> WalFileState {
252    let wal_path = wal_sidecar_path(db_path);
253    match std::fs::metadata(&wal_path) {
254        Ok(md) => WalFileState {
255            wal_path: wal_path.display().to_string(),
256            wal_size_bytes: Some(md.len()),
257            unavailable_reason: None,
258        },
259        Err(e) => WalFileState {
260            wal_path: wal_path.display().to_string(),
261            wal_size_bytes: None,
262            unavailable_reason: Some(e.to_string()),
263        },
264    }
265}
266
267/// `<db_path>-wal`, built by suffixing the file name (not by replacing an
268/// extension — `khive.db` must map to `khive.db-wal`).
269fn wal_sidecar_path(db_path: &Path) -> PathBuf {
270    let mut s = db_path.as_os_str().to_os_string();
271    s.push("-wal");
272    PathBuf::from(s)
273}
274
275/// WAL-pin attribution: who currently holds the database open, and how
276/// complete that answer is.
277///
278/// `status`, `status_reasons`, and the tagged `census` field are the
279/// authoritative wire contract. The older sibling booleans and PID arrays
280/// remain available to Rust callers but are not serialized. Sidecar evidence
281/// is collected through the handle-checked, bounded, non-mutating diagnostics
282/// enumerator and reconciled with the OS holder census.
283#[derive(Debug, Clone, PartialEq, Serialize)]
284pub struct WalPinAttribution {
285    /// Authoritative quality of the complete attribution answer.
286    pub status: WalPinAttributionStatus,
287    /// Machine-adjacent reasons why the answer is degraded or unavailable.
288    pub status_reasons: Vec<String>,
289    /// Authoritative tagged result of the OS holder census.
290    pub census: WalPinCensus,
291    /// PID spelling used by the process census for the reporting process.
292    pub reporting_pid: u32,
293    /// Whether the reporting process appears among confirmed file holders.
294    pub reporting_process_is_holder: Option<bool>,
295    /// Why the census could not determine whether the reporter is a holder.
296    pub reporting_process_is_holder_unavailable_reason: Option<String>,
297    /// Raw start-time values for every confirmed holder PID.
298    pub census_process_start_times: Vec<WalPinCensusProcessStart>,
299    /// Resolution of the operating system's process start-time value.
300    pub start_time_resolution_secs: Option<u64>,
301    /// Why the platform does not provide process start-time values.
302    pub start_time_resolution_unavailable_reason: Option<String>,
303    /// `false` plus an `unavailable_reason` whenever the OS census failed,
304    /// or when only a partial (census-only) answer is available.
305    pub available: bool,
306    pub unavailable_reason: Option<String>,
307    /// OS-derived census of every PID holding the DB file open.
308    #[serde(skip_serializing)]
309    pub census_holder_pids: Vec<u32>,
310    #[serde(skip_serializing)]
311    pub census_uninspectable_pids: Vec<u32>,
312    #[serde(skip_serializing)]
313    pub census_truncated: bool,
314    #[serde(skip_serializing)]
315    pub census_is_complete: bool,
316    /// Live, identity-matched heartbeats found in the sidecar.
317    pub reporting: Vec<WalPinHolder>,
318    /// Live, identity-matched beacons with no over-threshold heartbeat.
319    pub registered_silent_pids: Vec<u32>,
320    /// Sidecar entries whose identity or freshness could not be established.
321    pub unknown_pids: Vec<u32>,
322    /// OS-confirmed holders absent from every sidecar classification.
323    pub census_pids_without_attribution: Vec<u32>,
324    /// Whether both evidence sources completed and every holder was present
325    /// in a conclusive sidecar classification.
326    pub fully_attributed: bool,
327    /// Machine-readable sidecar classifications, ordered by PID and status.
328    pub sidecar_entries: Vec<serde_json::Value>,
329    /// Present only when this request actually enumerated the sidecar.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub sidecar_listing_truncated: Option<bool>,
332    /// Number of stale producer temps a housekeeping pass would reap. The
333    /// diagnostic pass itself never performs that cleanup.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub sidecar_entries_cleanup_would_reap: Option<usize>,
336}
337
338/// OS-reported start time for one holder confirmed by the process census.
339#[derive(Debug, Clone, PartialEq, Serialize)]
340pub struct WalPinCensusProcessStart {
341    /// PID confirmed by the OS holder census.
342    pub pid: u32,
343    /// Process start time as epoch seconds, if the OS reported it.
344    pub process_start_time_secs: Option<i64>,
345    /// Why the process start time is unavailable.
346    pub process_start_time_unavailable_reason: Option<String>,
347}
348
349fn census_process_start_times(holder_pids: &[u32]) -> Vec<WalPinCensusProcessStart> {
350    holder_pids
351        .iter()
352        .map(|pid| {
353            let process_start_time_secs = crate::walpin::process_start_time_secs(*pid);
354            let process_start_time_unavailable_reason =
355                process_start_time_secs.is_none().then(|| {
356                    if crate::walpin::start_time_resolution_secs().is_none() {
357                        "process start time is unavailable on this platform".to_string()
358                    } else {
359                        "the operating system did not report a start time for this process"
360                            .to_string()
361                    }
362                });
363            WalPinCensusProcessStart {
364                pid: *pid,
365                process_start_time_secs,
366                process_start_time_unavailable_reason,
367            }
368        })
369        .collect()
370}
371
372/// Overall quality of the WAL-pin attribution answer.
373///
374/// A result is `complete` only when both the OS census and read-only sidecar
375/// enumeration completed and every holder has sidecar evidence.
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
377#[serde(rename_all = "snake_case")]
378pub enum WalPinAttributionStatus {
379    /// Holder census and sidecar reconciliation both completed.
380    Complete,
381    /// Some useful evidence is present, but full attribution is impossible.
382    Degraded,
383    /// No holder-census evidence could be collected.
384    Unavailable,
385}
386
387/// Tagged OS holder-census result.
388///
389/// An incomplete scan retains the partial holder evidence, but its wire shape
390/// cannot be mistaken for a complete census without ignoring the explicit
391/// `status` tag. This is the fail-loud direction required by ADR-091: a
392/// truncated walk or an uninspectable PID is inconclusive, never evidence
393/// that no additional holder exists.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
395#[serde(tag = "status", rename_all = "snake_case")]
396pub enum WalPinCensus {
397    /// Every visible process was inspected and the walk was not truncated.
398    Complete {
399        /// PIDs confirmed to hold the database file open.
400        holder_pids: Vec<u32>,
401    },
402    /// Partial evidence from an inconclusive process walk.
403    Incomplete {
404        /// PIDs confirmed to hold the database file open.
405        holder_pids: Vec<u32>,
406        /// PIDs for which inspection failed outright.
407        uninspectable_pids: Vec<u32>,
408        /// Whether process enumeration had positive evidence of truncation.
409        truncated: bool,
410        /// Why additional holders cannot be ruled out.
411        reason: String,
412    },
413    /// The platform or census operation supplied no holder evidence.
414    Unavailable {
415        /// Why the census could not run.
416        reason: String,
417    },
418}
419
420/// Name the reason a holder census stopped short.
421///
422/// A budget stop and an enumeration failure both set `truncated`, and an
423/// operator reading one sentence has to be able to tell them apart: the first
424/// is a configurable trade this process made on purpose and is expected on a
425/// busy box, the second is evidence that process enumeration itself
426/// misbehaved. Describing both as "truncated" would make the report cry wolf
427/// on every bounded call, which is how the sentence that matters stops being
428/// read.
429fn census_truncation_cause(budget_exhausted: bool) -> String {
430    if budget_exhausted {
431        "the OS process walk stopped at its wall-clock budget (see          collection_cost.wal_pin_census_budget_ms)"
432            .to_string()
433    } else {
434        "the OS process walk was truncated".to_string()
435    }
436}
437
438/// One PID's live heartbeat as reported to an operator.
439#[derive(Debug, Clone, PartialEq, Serialize)]
440pub struct WalPinHolder {
441    pub pid: u32,
442    pub process_role: String,
443    pub current_oldest_tx_age_secs: f64,
444    pub oldest_tx_label: Option<String>,
445    pub attribution_is_evidence_backed: bool,
446}
447
448impl WalPinAttribution {
449    fn unavailable(reason: impl Into<String>) -> Self {
450        let reason = reason.into();
451        let start_time_resolution_secs = crate::walpin::start_time_resolution_secs();
452        Self {
453            status: WalPinAttributionStatus::Unavailable,
454            status_reasons: vec![reason.clone()],
455            census: WalPinCensus::Unavailable {
456                reason: reason.clone(),
457            },
458            reporting_pid: crate::walpin::reporting_pid(),
459            reporting_process_is_holder: None,
460            reporting_process_is_holder_unavailable_reason: Some(
461                "the OS holder census supplied no holder evidence".to_string(),
462            ),
463            census_process_start_times: Vec::new(),
464            start_time_resolution_secs,
465            start_time_resolution_unavailable_reason: start_time_resolution_secs
466                .is_none()
467                .then(|| "process start time is unavailable on this platform".to_string()),
468            available: false,
469            unavailable_reason: Some(reason),
470            census_holder_pids: Vec::new(),
471            census_uninspectable_pids: Vec::new(),
472            census_truncated: false,
473            census_is_complete: false,
474            reporting: Vec::new(),
475            registered_silent_pids: Vec::new(),
476            unknown_pids: Vec::new(),
477            census_pids_without_attribution: Vec::new(),
478            fully_attributed: false,
479            sidecar_entries: Vec::new(),
480            sidecar_listing_truncated: None,
481            sidecar_entries_cleanup_would_reap: None,
482        }
483    }
484}
485
486#[cfg(all(unix, test))]
487fn wal_pin_attribution_from_census(census: crate::walpin::CensusResult) -> WalPinAttribution {
488    wal_pin_attribution_without_sidecar(
489        census,
490        "read-only sidecar enumeration did not run for this attribution snapshot".to_string(),
491    )
492}
493
494#[cfg(unix)]
495fn wal_pin_attribution_without_sidecar(
496    census: crate::walpin::CensusResult,
497    sidecar_reason: String,
498) -> WalPinAttribution {
499    let census_is_complete = census.is_complete();
500    let mut census_holder_pids: Vec<u32> = census.holders.iter().copied().collect();
501    census_holder_pids.sort_unstable();
502    let reporting_pid = crate::walpin::reporting_pid();
503    let reporting_process_is_holder = census_holder_pids
504        .contains(&reporting_pid)
505        .then_some(true)
506        .or_else(|| census_is_complete.then_some(false));
507    let reporting_process_is_holder_unavailable_reason =
508        reporting_process_is_holder.is_none().then(|| {
509            "the reporting process was absent from the incomplete OS holder census".to_string()
510        });
511    let census_process_start_times = census_process_start_times(&census_holder_pids);
512    let start_time_resolution_secs = crate::walpin::start_time_resolution_secs();
513    let mut census_uninspectable_pids = census.uninspectable_pids;
514    census_uninspectable_pids.sort_unstable();
515    census_uninspectable_pids.dedup();
516    let census_truncated = census.truncated;
517    let census_budget_exhausted = census.budget_exhausted;
518
519    let mut status_reasons = vec![sidecar_reason];
520    let census = if census_is_complete {
521        WalPinCensus::Complete {
522            holder_pids: census_holder_pids.clone(),
523        }
524    } else {
525        let mut causes = Vec::new();
526        if census_truncated {
527            causes.push(census_truncation_cause(census_budget_exhausted));
528        }
529        if !census_uninspectable_pids.is_empty() {
530            causes.push(format!(
531                "{} PID(s) could not be inspected",
532                census_uninspectable_pids.len()
533            ));
534        }
535        let reason = format!(
536            "OS holder census is incomplete: {}; additional database holders cannot be ruled out",
537            causes.join("; ")
538        );
539        status_reasons.push(reason.clone());
540        WalPinCensus::Incomplete {
541            holder_pids: census_holder_pids.clone(),
542            uninspectable_pids: census_uninspectable_pids.clone(),
543            truncated: census_truncated,
544            reason,
545        }
546    };
547
548    WalPinAttribution {
549        status: WalPinAttributionStatus::Degraded,
550        unavailable_reason: Some(status_reasons.join("; ")),
551        status_reasons,
552        census,
553        reporting_pid,
554        reporting_process_is_holder,
555        reporting_process_is_holder_unavailable_reason,
556        census_process_start_times,
557        start_time_resolution_secs,
558        start_time_resolution_unavailable_reason: start_time_resolution_secs
559            .is_none()
560            .then(|| "process start time is unavailable on this platform".to_string()),
561        available: false,
562        census_holder_pids,
563        census_uninspectable_pids,
564        census_truncated,
565        census_is_complete,
566        reporting: Vec::new(),
567        registered_silent_pids: Vec::new(),
568        unknown_pids: Vec::new(),
569        census_pids_without_attribution: Vec::new(),
570        fully_attributed: false,
571        sidecar_entries: Vec::new(),
572        sidecar_listing_truncated: None,
573        sidecar_entries_cleanup_would_reap: None,
574    }
575}
576
577#[cfg(unix)]
578fn wal_pin_attribution_from_evidence(
579    census: crate::walpin::CensusResult,
580    sidecar: crate::walpin::WalpinReport,
581) -> WalPinAttribution {
582    use std::collections::BTreeSet;
583
584    let census_is_complete = census.is_complete();
585    let mut census_holder_pids: Vec<u32> = census.holders.iter().copied().collect();
586    census_holder_pids.sort_unstable();
587    let reporting_pid = crate::walpin::reporting_pid();
588    let reporting_process_is_holder = census_holder_pids
589        .contains(&reporting_pid)
590        .then_some(true)
591        .or_else(|| census_is_complete.then_some(false));
592    let reporting_process_is_holder_unavailable_reason =
593        reporting_process_is_holder.is_none().then(|| {
594            "the reporting process was absent from the incomplete OS holder census".to_string()
595        });
596    let census_process_start_times = census_process_start_times(&census_holder_pids);
597    let start_time_resolution_secs = crate::walpin::start_time_resolution_secs();
598    let mut census_uninspectable_pids = census.uninspectable_pids;
599    census_uninspectable_pids.sort_unstable();
600    census_uninspectable_pids.dedup();
601    let census_truncated = census.truncated;
602    let census_budget_exhausted = census.budget_exhausted;
603    let census_carrier = if census_is_complete {
604        WalPinCensus::Complete {
605            holder_pids: census_holder_pids.clone(),
606        }
607    } else {
608        let mut causes = Vec::new();
609        if census_truncated {
610            causes.push(census_truncation_cause(census_budget_exhausted));
611        }
612        if !census_uninspectable_pids.is_empty() {
613            causes.push(format!(
614                "{} PID(s) could not be inspected",
615                census_uninspectable_pids.len()
616            ));
617        }
618        let reason = format!(
619            "OS holder census is incomplete: {}; additional database holders cannot be ruled out",
620            causes.join("; ")
621        );
622        WalPinCensus::Incomplete {
623            holder_pids: census_holder_pids.clone(),
624            uninspectable_pids: census_uninspectable_pids.clone(),
625            truncated: census_truncated,
626            reason,
627        }
628    };
629
630    let now_epoch_secs = SystemTime::now()
631        .duration_since(UNIX_EPOCH)
632        .map(|duration| duration.as_secs() as i64)
633        .unwrap_or(0);
634    let sidecar_listing_truncated = sidecar.sidecar_listing_truncated;
635    let sidecar_entries_cleanup_would_reap = sidecar.cleanup_would_reap;
636    let mut reporting = Vec::new();
637    let mut registered_silent_pids = Vec::new();
638    let mut unknown_pids = Vec::new();
639    let mut sidecar_entries = Vec::new();
640    let mut sidecar_known_pids = BTreeSet::new();
641
642    for entry in sidecar.entries {
643        match entry {
644            crate::walpin::WalpinPidHealth::Reporting(heartbeat) => {
645                let current_oldest_tx_age_secs =
646                    heartbeat.current_oldest_tx_age_secs(now_epoch_secs);
647                let attribution_is_evidence_backed = heartbeat.attribution_is_evidence_backed();
648                sidecar_known_pids.insert(heartbeat.pid);
649                reporting.push(WalPinHolder {
650                    pid: heartbeat.pid,
651                    process_role: heartbeat.process_role.clone(),
652                    current_oldest_tx_age_secs,
653                    oldest_tx_label: heartbeat.oldest_tx_label.clone(),
654                    attribution_is_evidence_backed,
655                });
656                sidecar_entries.push((
657                    heartbeat.pid,
658                    0u8,
659                    serde_json::json!({
660                        "pid": heartbeat.pid,
661                        "status": "reporting",
662                        "process_role": heartbeat.process_role,
663                        "current_oldest_tx_age_secs": current_oldest_tx_age_secs,
664                        "oldest_tx_label": heartbeat.oldest_tx_label,
665                        "attribution_is_evidence_backed": attribution_is_evidence_backed,
666                    }),
667                ));
668            }
669            crate::walpin::WalpinPidHealth::RegisteredSilent { pid } => {
670                sidecar_known_pids.insert(pid);
671                registered_silent_pids.push(pid);
672                sidecar_entries.push((
673                    pid,
674                    1u8,
675                    serde_json::json!({"pid": pid, "status": "registered_silent"}),
676                ));
677            }
678            crate::walpin::WalpinPidHealth::Unknown { pid, reason } => {
679                sidecar_known_pids.insert(pid);
680                unknown_pids.push(pid);
681                sidecar_entries.push((
682                    pid,
683                    2u8,
684                    serde_json::json!({"pid": pid, "status": "unknown", "reason": reason}),
685                ));
686            }
687        }
688    }
689
690    reporting.sort_by_key(|holder| holder.pid);
691    reporting.dedup_by_key(|holder| holder.pid);
692    registered_silent_pids.sort_unstable();
693    registered_silent_pids.dedup();
694    unknown_pids.sort_unstable();
695    unknown_pids.dedup();
696    sidecar_entries.sort_by_key(|(pid, status_rank, _)| (*pid, *status_rank));
697    let sidecar_entries = sidecar_entries
698        .into_iter()
699        .map(|(_, _, entry)| entry)
700        .collect();
701    let census_pids_without_attribution: Vec<u32> = census_holder_pids
702        .iter()
703        .copied()
704        .filter(|pid| !sidecar_known_pids.contains(pid))
705        .collect();
706
707    let mut status_reasons = Vec::new();
708    if let WalPinCensus::Incomplete { reason, .. } = &census_carrier {
709        status_reasons.push(reason.clone());
710    }
711    if sidecar_listing_truncated {
712        status_reasons.push(
713            "read-only sidecar enumeration reached its entry cap; additional entries may exist"
714                .to_string(),
715        );
716    }
717    if !unknown_pids.is_empty() {
718        status_reasons.push(format!(
719            "{} sidecar PID(s) could not be classified conclusively",
720            unknown_pids.len()
721        ));
722    }
723    if !census_pids_without_attribution.is_empty() {
724        status_reasons.push(format!(
725            "{} OS-confirmed holder(s) have no sidecar attribution",
726            census_pids_without_attribution.len()
727        ));
728    }
729
730    let fully_attributed = census_is_complete
731        && !sidecar_listing_truncated
732        && unknown_pids.is_empty()
733        && census_pids_without_attribution.is_empty();
734    let status = if fully_attributed {
735        WalPinAttributionStatus::Complete
736    } else {
737        WalPinAttributionStatus::Degraded
738    };
739    let unavailable_reason = (!fully_attributed).then(|| status_reasons.join("; "));
740
741    WalPinAttribution {
742        status,
743        status_reasons,
744        census: census_carrier,
745        reporting_pid,
746        reporting_process_is_holder,
747        reporting_process_is_holder_unavailable_reason,
748        census_process_start_times,
749        start_time_resolution_secs,
750        start_time_resolution_unavailable_reason: start_time_resolution_secs
751            .is_none()
752            .then(|| "process start time is unavailable on this platform".to_string()),
753        available: fully_attributed,
754        unavailable_reason,
755        census_holder_pids,
756        census_uninspectable_pids,
757        census_truncated,
758        census_is_complete,
759        reporting,
760        registered_silent_pids,
761        unknown_pids,
762        census_pids_without_attribution,
763        fully_attributed,
764        sidecar_entries,
765        sidecar_listing_truncated: Some(sidecar_listing_truncated),
766        sidecar_entries_cleanup_would_reap: Some(sidecar_entries_cleanup_would_reap),
767    }
768}
769
770/// Build the WAL-pin attribution for `db_path`.
771///
772/// Unix-only: the OS census requires it. Everywhere else this degrades to
773/// `available: false` with a reason rather than failing the whole report.
774/// An operator who has explicitly disabled the sidecar (`KHIVE_WALPIN_SIDECAR`)
775/// also disables this request's sidecar collection, per ADR-091 Amendment 6 —
776/// the census still runs, but there is no sidecar evidence to reconcile it
777/// against.
778#[cfg(unix)]
779pub fn wal_pin_attribution(db_path: &Path, sweep_interval: Duration) -> WalPinAttribution {
780    use crate::walpin;
781
782    let census = match walpin::census_holders(db_path) {
783        Ok(c) => c,
784        Err(e) => return WalPinAttribution::unavailable(format!("census_holders failed: {e}")),
785    };
786    if !walpin::sidecar_enabled(true) {
787        return wal_pin_attribution_without_sidecar(census, SIDECAR_DISABLED_REASON.to_string());
788    }
789    match walpin::inspect_live(&walpin::sidecar_dir_for(db_path), sweep_interval) {
790        Ok(sidecar) => wal_pin_attribution_from_evidence(census, sidecar),
791        Err(error) => wal_pin_attribution_without_sidecar(
792            census,
793            format!("read-only sidecar enumeration failed: {error}"),
794        ),
795    }
796}
797
798/// Shared with the async collection path in `inspect_file_state_interruptibly`.
799#[cfg(unix)]
800const SIDECAR_DISABLED_REASON: &str =
801    "walpin sidecar is explicitly disabled (KHIVE_WALPIN_SIDECAR); attribution has no sidecar \
802     evidence to reconcile against the OS holder census";
803
804#[cfg(not(unix))]
805pub fn wal_pin_attribution(_db_path: &Path, _sweep_interval: Duration) -> WalPinAttribution {
806    WalPinAttribution::unavailable("WAL-pin attribution requires a Unix platform")
807}
808
809/// One typed snapshot of reader route, saturation, and hold-lifecycle signals.
810///
811/// Every field is pool-scoped. Monotonic counters reset only when the
812/// [`ConnectionPool`] is reconstructed; the active value is point-in-time.
813/// Infrastructure standalone opens are kept separate from request traffic so
814/// a boot/schema probe cannot make a hot path look like it churned readers.
815#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
816pub struct ReaderContentionDiagnostics {
817    /// Reader connections requested in this pool's configuration. The
818    /// effective admission capacity below can be smaller in degraded mode.
819    pub configured_reader_cap: usize,
820    /// Configured wait for a reader slot, in milliseconds.
821    pub configured_checkout_timeout_ms: u64,
822    /// Configured SQLite busy-handler wait, in milliseconds. This is separate
823    /// from the reader-slot checkout deadline.
824    pub configured_busy_timeout_ms: u64,
825    /// Configured total reader admission budget shared by pooled readers and
826    /// explicit raw-SQL read transactions.
827    pub reader_admission_capacity: usize,
828    /// Point-in-time permits not held when this snapshot was captured.
829    pub available_reader_admission_slots: usize,
830    /// Successful request-path acquisitions (pooled plus enumerated
831    /// standalone request exceptions; infrastructure excluded).
832    pub reader_acquisitions: u64,
833    /// Successful bounded pooled-reader checkouts.
834    pub pooled_reader_checkouts: u64,
835    /// Successful request-path standalone reader opens. Under ADR-165 Slice
836    /// 2 this is limited to the explicit raw-SQL read-transaction exception.
837    pub standalone_reader_opens: u64,
838    /// Successful standalone opens owned by an enumerated boot/diagnostic
839    /// infrastructure exception.
840    pub infrastructure_standalone_reader_opens: u64,
841    /// Pool-wide reader-admission waits that exhausted `checkout_timeout`
842    /// before work began. Cooperative request cancellation is excluded.
843    pub reader_checkout_timeouts: u64,
844    /// Queries on a checked-out pooled reader that SQLite refused with
845    /// `SQLITE_BUSY` after `configured_busy_timeout_ms` elapsed. Counted after
846    /// checkout succeeded, so it is disjoint from `reader_checkout_timeouts`.
847    /// Writer refusals are not included; see `direct_writer_busy_refusals` and
848    /// `writer_task_begin_busy`.
849    pub reader_busy_timeouts: u64,
850    /// Pooled reader guards live when the snapshot was captured.
851    pub active_pooled_reader_checkouts: u64,
852    /// Highest observed concurrent pooled-reader guard count.
853    pub peak_active_pooled_reader_checkouts: u64,
854    /// Pooled guards that completed return/reset.
855    pub completed_pooled_reader_checkouts: u64,
856    /// Longest completed hold, including return/reset, in microseconds.
857    pub max_completed_reader_hold_micros: u64,
858    /// The typed-store operation that held the checkout this maximum came
859    /// from. `None` means that hold came through a route carrying no
860    /// operation name — the pool's own internal checkout — which is the
861    /// answer to "which read was it", not a gap in the reading (#2793).
862    pub max_completed_reader_hold_operation: Option<&'static str>,
863    /// Dedicated readers discarded on return for age, checkout count, failed
864    /// cleanup/health checks, or an explicitly non-reusable lease. Counts
865    /// replacement attempts, including failed opens; excludes pool shutdown.
866    pub reader_discards: u64,
867    /// A disqualified pooled-reader return whose replacement connection then
868    /// also failed to open, permanently shrinking the physical pool by one
869    /// slot below `max_readers`. Non-zero here means the pool has fewer
870    /// physical reader connections than configured.
871    pub reader_replacement_open_failures: u64,
872}
873
874impl ReaderContentionDiagnostics {
875    fn snapshot(pool: &ConnectionPool) -> Self {
876        let reader = pool.reader_acquisition_snapshot();
877        Self {
878            configured_reader_cap: pool.config().max_readers,
879            configured_checkout_timeout_ms: u64::try_from(
880                pool.config().checkout_timeout.as_millis(),
881            )
882            .unwrap_or(u64::MAX),
883            configured_busy_timeout_ms: u64::try_from(pool.config().busy_timeout.as_millis())
884                .unwrap_or(u64::MAX),
885            reader_admission_capacity: reader.reader_admission_capacity,
886            available_reader_admission_slots: reader.available_reader_admission_slots,
887            reader_acquisitions: reader.acquisitions,
888            pooled_reader_checkouts: reader.pooled_checkouts,
889            standalone_reader_opens: reader.standalone_opens,
890            infrastructure_standalone_reader_opens: reader.infrastructure_standalone_opens,
891            reader_checkout_timeouts: reader.checkout_timeouts,
892            reader_busy_timeouts: reader.busy_timeouts,
893            active_pooled_reader_checkouts: reader.active_pooled_checkouts,
894            peak_active_pooled_reader_checkouts: reader.peak_active_pooled_checkouts,
895            completed_pooled_reader_checkouts: reader.completed_pooled_checkouts,
896            max_completed_reader_hold_micros: reader.max_completed_hold_micros,
897            max_completed_reader_hold_operation: reader.max_completed_hold_operation,
898            reader_discards: reader.reader_discards,
899            reader_replacement_open_failures: reader.reader_replacement_open_failures,
900        }
901    }
902}
903
904/// One typed snapshot of writer-contention signals.
905///
906/// `writer_acquisitions` is the aggregate of the three explicit connection
907/// classes below. `writer_acquisition_timeouts` remains specific to the
908/// finite-wait pool-mutex stage; standalone SQLite failures and writer-task
909/// `BEGIN` failures have different ADR-135 F6 stages and are not mislabeled as
910/// pool checkout timeouts. Those stages now carry their OWN failure counters
911/// (`writer_task_begin_busy`, `writer_task_begin_busy_absorbed`,
912/// `writer_task_begin_errors`) rather than being absent: refusing to mislabel
913/// a failure is not a reason to omit it, and an omitted failure counter fails
914/// toward looking healthy, which is the reading an operator believes.
915/// `audit_append_failures` is supplied by the runtime
916/// because the audit store lives above `khive-db`; direct `khive-db` callers
917/// receive `None` plus an explicit reason instead of a fabricated zero.
918#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
919pub struct WriterContentionDiagnostics {
920    /// Successful acquisitions across pooled, standalone, and writer-task
921    /// connection classes.
922    pub writer_acquisitions: u64,
923    /// Successful finite-wait main-pool mutex checkouts.
924    pub pooled_writer_acquisitions: u64,
925    /// Successful per-operation file-backed standalone writer opens.
926    pub standalone_writer_acquisitions: u64,
927    /// Successful writer-task ownership acquisitions.
928    pub writer_task_acquisitions: u64,
929    /// Main-pool writer checkouts that exhausted their finite deadline.
930    pub writer_acquisition_timeouts: u64,
931    /// Writer acquisitions refused because another writer held the volume
932    /// lease past the guard deadline (`CapacityUnavailable`, phase `lock`).
933    /// Every writer class takes the lease before its connection, so ordinary
934    /// same-volume writer contention is counted here, not in
935    /// `writer_acquisition_timeouts`.
936    pub writer_lease_timeouts: u64,
937    /// The guard deadline a writer waits for the volume lease, in ms; `None`
938    /// when the pool takes no lease (in-memory or read-only).
939    pub configured_guard_deadline_ms: Option<u64>,
940    /// `checkout_timeout`, which bounds only the pool-mutex wait that comes
941    /// after the lease.
942    pub configured_checkout_timeout_ms: u64,
943    /// The effective pooled-writer wait bound under contention: the guard
944    /// deadline for the lease, then `checkout_timeout` for the pool mutex. A
945    /// pool configured with a 50 ms `checkout_timeout` and the default 2000 ms
946    /// guard deadline can wait about 2050 ms before refusing.
947    pub effective_writer_wait_bound_ms: u64,
948    /// Final instrumented direct execution refusals retaining primary SQLITE_BUSY,
949    /// once per operation; excludes SQLITE_LOCKED and task/reader/open/admission errors.
950    pub direct_writer_busy_refusals: u64,
951    /// Every writer-task `BEGIN IMMEDIATE` attempt refused busy or locked,
952    /// whether or not a subsequent bounded retry absorbed it. A refusal not
953    /// absorbed by a retry also surfaces to the caller as the retryable
954    /// `writer_task_begin_busy` stage.
955    pub writer_task_begin_busy: u64,
956    /// Subset of `writer_task_begin_busy` absorbed by the bounded retry
957    /// before the request closure ran, so the caller never observed that
958    /// particular refusal. `writer_task_begin_busy - writer_task_begin_busy_absorbed`
959    /// is the count of refusals a caller actually observed.
960    pub writer_task_begin_busy_absorbed: u64,
961    /// Writer-task `BEGIN IMMEDIATE` attempts that failed for a reason other
962    /// than busy or locked.
963    pub writer_task_begin_errors: u64,
964    /// Dequeued writer-task requests that reached the writer seam and
965    /// returned error, counted once per request. Sourced directly from the
966    /// pool's own acquisition-site counters, so — unlike the runtime-supplied
967    /// fields below — it is populated identically for every caller.
968    pub writer_task_request_failures: u64,
969    /// Subset of `writer_task_request_failures` whose terminal state was
970    /// `WriterTaskRequestState::SideEffectsUnknown`.
971    pub writer_task_side_effects_unknown: u64,
972    /// Process-wide audit appends whose errors were logged and swallowed —
973    /// pure-observability rows only (config-lock rows, best-effort recall
974    /// telemetry). An obligation-bearing row's commit failure (a gate
975    /// denial's own audit row, a dispatch outcome, an unknown-verb row, or a
976    /// `git.digest` receipt) is never counted here: those either fail the
977    /// dispatch that produced them directly (visible to the caller as an
978    /// error, not as this counter moving) or, for a denial whose dispatch
979    /// already fails independent of the row, are tracked by the runtime's
980    /// own separate obligation-failure counter instead. Summing this field
981    /// with `audit_batch_flush_failures` therefore does not double-count an
982    /// obligation-bearing generation failure against this one.
983    pub audit_append_failures: Option<u64>,
984    /// Why `audit_append_failures` is unavailable to this caller.
985    pub audit_append_failures_unavailable_reason: Option<String>,
986    /// Process-wide obligation-bearing audit commit failures, including a
987    /// denial's audit failure even though the denial already refuses the call.
988    /// This counts failed producer submissions, not failed batch generations;
989    /// do not add it to `audit_batch_flush_failures` as a disjoint total.
990    pub audit_obligation_append_failures: Option<u64>,
991    /// Why the runtime-owned obligation counter is unavailable to this caller.
992    pub audit_obligation_append_failures_unavailable_reason: Option<String>,
993    /// Accepted audit-batch generations that reached a terminal non-commit
994    /// outcome after retry, including driver death; excludes preflight and
995    /// admission rejection. `None` for a direct `khive-db` caller, or when no
996    /// runtime audit-batch control has been wired into diagnostics.
997    pub audit_batch_flush_failures: Option<u64>,
998    /// Why `audit_batch_flush_failures` is unavailable to this caller.
999    pub audit_batch_flush_failures_unavailable_reason: Option<String>,
1000    /// Pure-observability audit rows released without a commit. `None` under
1001    /// the same conditions as `audit_batch_flush_failures`.
1002    pub audit_degraded_rows: Option<u64>,
1003    /// Why `audit_degraded_rows` is unavailable to this caller.
1004    pub audit_degraded_rows_unavailable_reason: Option<String>,
1005    /// Monotonic process-lifetime flag set once any accepted row has been
1006    /// released without a commit: a pure-observability row released degraded,
1007    /// or a generation that failed to flush, whose rows are absent from the
1008    /// audit trail whether or not their callers were told. `None` under the
1009    /// same conditions as `audit_batch_flush_failures`.
1010    pub audit_degraded: Option<bool>,
1011    /// Why `audit_degraded` is unavailable to this caller.
1012    pub audit_degraded_unavailable_reason: Option<String>,
1013    /// Per-dispatch audit rows for an explicitly allowlisted, domain-write-free
1014    /// read verb (`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`) that were
1015    /// **refused before they could be enqueued** on the audit lane
1016    /// (`AuditTerminalReason::QueueAdmissionExhausted`) while the dispatch
1017    /// still reported its own successful result (ADR-103 Amendment 3, ADR-133
1018    /// Amendment 1). This is a confirmed, terminal accounting loss: the row
1019    /// never shared a generation with anyone and will never commit, so it
1020    /// undercounts `brain.event_counts`'s cost totals for exactly the rows
1021    /// counted here. Disjoint from `audit_degraded_rows` (a different reason:
1022    /// persistent commit failure of a pure-observability row, not admission
1023    /// pressure) and from `audit_admission_unresolved_obligations` (a row that
1024    /// was enqueued and may still commit). `None` under the same conditions as
1025    /// `audit_batch_flush_failures`.
1026    ///
1027    /// CUMULATIVE since process start. Nothing decrements it, so a value that
1028    /// holds steady under traffic means no refusal occurred in that window
1029    /// rather than a stalled subsystem (#2791); read
1030    /// `audit_admission_refused_obligations_last_at_ms` beside it to tell the
1031    /// two apart.
1032    pub audit_admission_refused_obligations: Option<u64>,
1033    /// Wall-clock milliseconds at which `audit_admission_refused_obligations`
1034    /// last moved in the serving process, or `None` if it has never moved.
1035    /// `None` alongside a count of zero is the ordinary quiet case; `None`
1036    /// alongside a non-zero count cannot occur and would indicate the two are
1037    /// being produced from different processes.
1038    pub audit_admission_refused_obligations_last_at_ms: Option<u64>,
1039    /// Why `audit_admission_refused_obligations` is unavailable to this caller.
1040    pub audit_admission_refused_obligations_unavailable_reason: Option<String>,
1041    /// Successful admission-degrade-safe reads whose audit generation exhausted
1042    /// bounded transient retries. Cumulative since process start; these rows
1043    /// received no commit acknowledgement (ADR-154 Amendment 2).
1044    pub audit_retry_exhausted_obligations: Option<u64>,
1045    /// Last increment in wall-clock milliseconds, or `None` before the first.
1046    pub audit_retry_exhausted_obligations_last_at_ms: Option<u64>,
1047    /// Why the retry-exhausted obligation count is unavailable to this caller.
1048    pub audit_retry_exhausted_obligations_unavailable_reason: Option<String>,
1049    /// Per-dispatch audit rows for an explicitly allowlisted, domain-write-free
1050    /// read verb (`VerbRegistry::ADMISSION_DEGRADE_SAFE_VERBS`) that were
1051    /// **already enqueued but had not resolved when the caller's admission
1052    /// wait deadline elapsed** (`AuditTerminalReason::AdmissionDeadlineExpired`)
1053    /// while the dispatch still reported its own successful result (ADR-103
1054    /// Amendment 3, ADR-133 Amendment 1). Unlike
1055    /// `audit_admission_refused_obligations`, a row counted here is not a
1056    /// confirmed loss — it may still be committed by the generation driver
1057    /// independently of the caller's timeout — so this field is an upper
1058    /// bound on the eventual undercount, not the undercount itself. `None`
1059    /// under the same conditions as `audit_batch_flush_failures`.
1060    ///
1061    /// CUMULATIVE since process start, despite the set-shaped name. There is no
1062    /// live set of unresolved obligations and nothing resolves this counter:
1063    /// each increment records one past deadline expiry, and the row it counted
1064    /// most likely committed afterwards. A steady value under traffic means no
1065    /// deadline expired in that window, which is the healthy reading (#2791).
1066    /// Read `audit_admission_unresolved_obligations_last_at_ms` beside it: a
1067    /// non-zero count whose mark is old is history, the same count with a
1068    /// recent mark is an active condition.
1069    pub audit_admission_unresolved_obligations: Option<u64>,
1070    /// Wall-clock milliseconds at which
1071    /// `audit_admission_unresolved_obligations` last moved in the serving
1072    /// process, or `None` if it has never moved.
1073    pub audit_admission_unresolved_obligations_last_at_ms: Option<u64>,
1074    /// Why `audit_admission_unresolved_obligations` is unavailable to this
1075    /// caller.
1076    pub audit_admission_unresolved_obligations_unavailable_reason: Option<String>,
1077}
1078
1079/// Process-wide audit-batch health counters, supplied by the runtime layer
1080/// that owns the audit-batch control. `khive-db` never produces these itself
1081/// — a direct `khive-db` caller always sees the corresponding
1082/// `WriterContentionDiagnostics` fields as `None` plus an explicit reason.
1083#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1084pub struct RuntimeAuditBatchMetrics {
1085    /// Accepted generations reaching terminal non-commit after retry,
1086    /// including driver death; excludes preflight/admission rejection.
1087    pub flush_failures: u64,
1088    /// Pure-observability rows released without commit.
1089    pub degraded_rows: u64,
1090    /// Monotonic process-lifetime degradation flag.
1091    pub degraded: bool,
1092    /// Admission-degrade-safe read verbs' audit rows refused before enqueue
1093    /// under transient audit-lane admission pressure (ADR-103 Amendment 3,
1094    /// ADR-133 Amendment 1) — a confirmed, terminal accounting loss. Disjoint
1095    /// from `degraded_rows` and from `admission_unresolved_obligations`.
1096    pub admission_refused_obligations: u64,
1097    /// Wall-clock ms at which `admission_refused_obligations` last moved;
1098    /// `None` until it moves. A total cannot say when it was last earned.
1099    pub admission_refused_obligations_last_at_ms: Option<u64>,
1100    /// Safe-read audit rows whose generation exhausted bounded transient retries.
1101    pub retry_exhausted_obligations: u64,
1102    /// Last increment in wall-clock milliseconds, or `None` before the first.
1103    pub retry_exhausted_obligations_last_at_ms: Option<u64>,
1104    /// Admission-degrade-safe read verbs' audit rows that were already
1105    /// enqueued but had not resolved when the caller's admission wait
1106    /// deadline elapsed (ADR-103 Amendment 3, ADR-133 Amendment 1). Not a
1107    /// confirmed loss — the row may still commit — so this is an upper bound
1108    /// on the eventual undercount. Disjoint from `degraded_rows` and from
1109    /// `admission_refused_obligations`.
1110    pub admission_unresolved_obligations: u64,
1111    /// Wall-clock ms at which `admission_unresolved_obligations` last moved;
1112    /// `None` until it moves.
1113    pub admission_unresolved_obligations_last_at_ms: Option<u64>,
1114}
1115
1116/// Live graph-edge rows compared with the durable list-cursor ledger.
1117///
1118/// `duplicate_edge_id_groups > 0` is the exact pre-V14 state in which two
1119/// namespaces share an edge UUID and a multi-namespace cursor walk can drop
1120/// one row during ID-based deduplication. The two raw row counts are reported
1121/// separately because sequence rows intentionally survive hard deletion;
1122/// count inequality by itself is therefore not proof of corruption.
1123///
1124/// `live_entities_carrying_merged_into` counts entity rows that are live
1125/// (`deleted_at IS NULL`) while still carrying merge provenance. A merge
1126/// tombstones its source; a restore that ran before restore refused merge
1127/// tombstones cleared the tombstone and left the provenance, so such rows
1128/// read as merged by `get` and as live by `list` and `search`. Restore now
1129/// names them as `live_merged_entity`; this count is where an operator finds
1130/// them across all namespaces.
1131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1132pub struct GraphEdgeIntegrity {
1133    pub duplicate_edge_id_groups: i64,
1134    pub graph_edges_rows: i64,
1135    pub graph_edges_seq_rows: i64,
1136    pub pre_v14_duplicate_edge_state_detected: bool,
1137    pub live_entities_carrying_merged_into: i64,
1138}
1139
1140fn graph_edge_integrity(conn: &Connection) -> rusqlite::Result<GraphEdgeIntegrity> {
1141    conn.query_row(
1142        "SELECT
1143             (SELECT COUNT(*) FROM (
1144                 SELECT id FROM graph_edges GROUP BY id HAVING COUNT(*) > 1
1145             )),
1146             (SELECT COUNT(*) FROM graph_edges),
1147             (SELECT COUNT(*) FROM graph_edges_seq),
1148             (SELECT COUNT(*) FROM entities
1149              WHERE deleted_at IS NULL AND merged_into IS NOT NULL)",
1150        [],
1151        |row| {
1152            let duplicate_edge_id_groups = row.get(0)?;
1153            Ok(GraphEdgeIntegrity {
1154                duplicate_edge_id_groups,
1155                graph_edges_rows: row.get(1)?,
1156                graph_edges_seq_rows: row.get(2)?,
1157                pre_v14_duplicate_edge_state_detected: duplicate_edge_id_groups > 0,
1158                live_entities_carrying_merged_into: row.get(3)?,
1159            })
1160        },
1161    )
1162}
1163
1164const MAX_DATABASE_SIZE_OBJECTS: usize = 4_096;
1165
1166/// SQLite b-tree role reported by the size-composition diagnostic.
1167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1168#[serde(rename_all = "snake_case")]
1169pub enum DatabaseObjectKind {
1170    Table,
1171    Index,
1172    Internal,
1173}
1174
1175/// Operational storage grouping. `mixed_row_and_embedding` is deliberately
1176/// separate: SQLite cannot attribute bytes within a table page to one column,
1177/// so counting all of `knowledge_sections` as pure vector bytes would be a
1178/// false precision claim.
1179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1180#[serde(rename_all = "snake_case")]
1181pub enum DatabaseStorageClass {
1182    RowTable,
1183    Index,
1184    FullText,
1185    Vector,
1186    MixedRowAndEmbedding,
1187    Internal,
1188}
1189
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1191pub struct DatabaseObjectSize {
1192    pub name: String,
1193    pub owner_table: Option<String>,
1194    pub object_kind: DatabaseObjectKind,
1195    pub storage_class: DatabaseStorageClass,
1196    pub pages: u64,
1197    pub bytes: u64,
1198}
1199
1200/// Page-accounted file-size composition from SQLite's read-only `dbstat`
1201/// virtual table in aggregate mode.
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1203pub struct DatabaseSizeComposition {
1204    pub page_size_bytes: u64,
1205    pub page_count: u64,
1206    pub freelist_pages: u64,
1207    pub database_bytes: u64,
1208    pub freelist_bytes: u64,
1209    pub accounted_bytes: u64,
1210    pub unaccounted_bytes: u64,
1211    pub row_table_bytes: u64,
1212    pub index_bytes: u64,
1213    pub full_text_bytes: u64,
1214    pub vector_bytes: u64,
1215    pub mixed_embedding_bytes: u64,
1216    pub internal_bytes: u64,
1217    pub objects: Vec<DatabaseObjectSize>,
1218    pub objects_truncated: bool,
1219    pub objects_omitted: usize,
1220}
1221
1222fn nonnegative_sqlite_integer(column: usize, value: i64) -> rusqlite::Result<u64> {
1223    u64::try_from(value).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(column, value))
1224}
1225
1226/// The file's page size, page count and free-list length: one pragma each, so
1227/// the cost does not grow with the file. Reported for every file-backed
1228/// database whether or not the `dbstat` size composition was collected.
1229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1230pub struct DatabasePageAccounting {
1231    pub page_size_bytes: u64,
1232    pub page_count: u64,
1233    /// Pages later writes can reuse that stay in the file until a `VACUUM`.
1234    pub freelist_pages: u64,
1235}
1236
1237fn database_page_accounting(conn: &Connection) -> rusqlite::Result<DatabasePageAccounting> {
1238    let read = |pragma: &str| -> rusqlite::Result<u64> {
1239        nonnegative_sqlite_integer(0, conn.query_row(pragma, [], |row| row.get::<_, i64>(0))?)
1240    };
1241    Ok(DatabasePageAccounting {
1242        page_size_bytes: read("PRAGMA page_size")?,
1243        page_count: read("PRAGMA page_count")?,
1244        freelist_pages: read("PRAGMA freelist_count")?,
1245    })
1246}
1247
1248fn declares_embedding_blob(sql: Option<&str>) -> bool {
1249    let Some(sql) = sql else {
1250        return false;
1251    };
1252    let tokens: Vec<_> = sql
1253        .split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
1254        .filter(|token| !token.is_empty())
1255        .collect();
1256    tokens.windows(2).any(|pair| {
1257        pair[0].eq_ignore_ascii_case("embedding") && pair[1].eq_ignore_ascii_case("blob")
1258    })
1259}
1260
1261fn classify_database_object(
1262    name: &str,
1263    sqlite_type: &str,
1264    sql: Option<&str>,
1265) -> (DatabaseObjectKind, DatabaseStorageClass) {
1266    let object_kind = match sqlite_type {
1267        "table" => DatabaseObjectKind::Table,
1268        "index" => DatabaseObjectKind::Index,
1269        _ => DatabaseObjectKind::Internal,
1270    };
1271    let lower_name = name.to_ascii_lowercase();
1272    let lower_sql = sql.unwrap_or_default().to_ascii_lowercase();
1273    let storage_class = if lower_name.starts_with("fts_") || lower_sql.contains("using fts5") {
1274        DatabaseStorageClass::FullText
1275    } else if lower_name.starts_with("vec_")
1276        || lower_name == "_embedding_models"
1277        || lower_sql.contains("using vec0")
1278    {
1279        DatabaseStorageClass::Vector
1280    } else if declares_embedding_blob(sql) {
1281        DatabaseStorageClass::MixedRowAndEmbedding
1282    } else if object_kind == DatabaseObjectKind::Index {
1283        DatabaseStorageClass::Index
1284    } else if name.starts_with("sqlite_") || object_kind == DatabaseObjectKind::Internal {
1285        DatabaseStorageClass::Internal
1286    } else {
1287        DatabaseStorageClass::RowTable
1288    };
1289    (object_kind, storage_class)
1290}
1291
1292fn database_size_composition(conn: &Connection) -> rusqlite::Result<DatabaseSizeComposition> {
1293    let DatabasePageAccounting {
1294        page_size_bytes: page_size,
1295        page_count,
1296        freelist_pages,
1297    } = database_page_accounting(conn)?;
1298
1299    let mut statement = conn.prepare(
1300        "SELECT d.name, COALESCE(s.type, 'internal'), s.tbl_name, s.sql, d.pageno, d.pgsize
1301         FROM dbstat AS d
1302         LEFT JOIN sqlite_schema AS s ON s.name = d.name
1303         WHERE d.aggregate = TRUE
1304         ORDER BY d.name",
1305    )?;
1306    let mut rows = statement.query([])?;
1307    let mut objects = Vec::new();
1308    let mut objects_omitted = 0usize;
1309    let mut accounted_bytes = 0u64;
1310    let mut row_table_bytes = 0u64;
1311    let mut index_bytes = 0u64;
1312    let mut full_text_bytes = 0u64;
1313    let mut vector_bytes = 0u64;
1314    let mut mixed_embedding_bytes = 0u64;
1315    let mut internal_bytes = 0u64;
1316
1317    while let Some(row) = rows.next()? {
1318        let name: String = row.get(0)?;
1319        let sqlite_type: String = row.get(1)?;
1320        let owner_table: Option<String> = row.get(2)?;
1321        let sql: Option<String> = row.get(3)?;
1322        let pages = nonnegative_sqlite_integer(4, row.get(4)?)?;
1323        let bytes = nonnegative_sqlite_integer(5, row.get(5)?)?;
1324        let (object_kind, storage_class) =
1325            classify_database_object(&name, &sqlite_type, sql.as_deref());
1326        accounted_bytes = accounted_bytes.saturating_add(bytes);
1327        let class_total = match storage_class {
1328            DatabaseStorageClass::RowTable => &mut row_table_bytes,
1329            DatabaseStorageClass::Index => &mut index_bytes,
1330            DatabaseStorageClass::FullText => &mut full_text_bytes,
1331            DatabaseStorageClass::Vector => &mut vector_bytes,
1332            DatabaseStorageClass::MixedRowAndEmbedding => &mut mixed_embedding_bytes,
1333            DatabaseStorageClass::Internal => &mut internal_bytes,
1334        };
1335        *class_total = class_total.saturating_add(bytes);
1336
1337        if objects.len() < MAX_DATABASE_SIZE_OBJECTS {
1338            objects.push(DatabaseObjectSize {
1339                name,
1340                owner_table,
1341                object_kind,
1342                storage_class,
1343                pages,
1344                bytes,
1345            });
1346        } else {
1347            objects_omitted = objects_omitted.saturating_add(1);
1348        }
1349    }
1350
1351    let database_bytes = page_count.saturating_mul(page_size);
1352    let freelist_bytes = freelist_pages.saturating_mul(page_size);
1353    let unaccounted_bytes = database_bytes
1354        .saturating_sub(freelist_bytes)
1355        .saturating_sub(accounted_bytes);
1356    Ok(DatabaseSizeComposition {
1357        page_size_bytes: page_size,
1358        page_count,
1359        freelist_pages,
1360        database_bytes,
1361        freelist_bytes,
1362        accounted_bytes,
1363        unaccounted_bytes,
1364        row_table_bytes,
1365        index_bytes,
1366        full_text_bytes,
1367        vector_bytes,
1368        mixed_embedding_bytes,
1369        internal_bytes,
1370        objects,
1371        objects_truncated: objects_omitted > 0,
1372        objects_omitted,
1373    })
1374}
1375
1376/// What this report cost to assemble, per section, in milliseconds.
1377///
1378/// `db_diagnostics` is the verb an operator reaches for when the system feels
1379/// slow, and its own cost was measured varying more than five-fold between
1380/// consecutive calls against one unchanged store on one box. A surface that
1381/// varies that much has to say which part varied; otherwise the reader is left
1382/// to guess, and the cheapest guess is "the store is stalled", which is the
1383/// conclusion this verb exists to test rather than to suggest.
1384///
1385/// Every field is wall-clock and measured in this process, so it includes time
1386/// spent waiting on a contended machine. That is deliberate: the contention is
1387/// the thing being reported.
1388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1389pub struct CollectionCost {
1390    /// Whole-report assembly, from the first section to the last.
1391    pub total_ms: u64,
1392    /// SQLite-side inspection: the PASSIVE checkpoint probe, page composition,
1393    /// the graph-edge ledger counts and the FTS5 segment records.
1394    pub sqlite_ms: u64,
1395    /// `stat` of the WAL file. The cheap sibling of the census; reported so the
1396    /// comparison beside it is legible rather than asserted.
1397    pub wal_file_stat_ms: u64,
1398    /// The OS holder census: a walk of every process on the host.
1399    pub wal_pin_census_ms: u64,
1400    /// The sidecar directory enumeration the census is reconciled against.
1401    pub wal_pin_sidecar_ms: u64,
1402    /// The wall-clock budget the census was given, or `None` when it ran
1403    /// unbounded. An `Option` rather than a sentinel `0` because a producer
1404    /// that failed to record a budget would write `0`, which is exactly the
1405    /// value "unbounded" would have claimed.
1406    pub wal_pin_census_budget_ms: Option<u64>,
1407    /// Whether the census stopped because that budget was spent. When true the
1408    /// holder list is partial by design and `wal_pin.census.status` is
1409    /// `incomplete` for that reason and no other.
1410    pub wal_pin_census_budget_exhausted: bool,
1411}
1412
1413impl CollectionCost {
1414    /// A report whose file-backed sections never ran (an in-memory backend):
1415    /// the SQLite side still costs what it costs, and the census did not run
1416    /// at all rather than running fast.
1417    fn in_memory(total_ms: u64) -> Self {
1418        Self {
1419            total_ms,
1420            sqlite_ms: 0,
1421            wal_file_stat_ms: 0,
1422            wal_pin_census_ms: 0,
1423            wal_pin_sidecar_ms: 0,
1424            wal_pin_census_budget_ms: None,
1425            wal_pin_census_budget_exhausted: false,
1426        }
1427    }
1428}
1429
1430/// The default wall-clock budget for the OS holder census on the request path.
1431///
1432/// Sized from the measurements in the report that asked for the bound: five
1433/// consecutive samples on a 971-process box cost 3.4s to 18.6s, while every
1434/// contention counter the same report carried read zero. Two seconds keeps the
1435/// verb interactive on a busy machine and completes untruncated on an idle one.
1436const DEFAULT_CENSUS_BUDGET: Duration = Duration::from_millis(2000);
1437
1438/// Environment override for [`DEFAULT_CENSUS_BUDGET`], in milliseconds.
1439///
1440/// `0` disables the bound and restores the unbounded full-machine walk, for an
1441/// operator who would rather wait than read a partial holder list. An
1442/// unparseable value is ignored in favour of the default rather than failing
1443/// the request: a diagnostic verb that refuses to answer because its own
1444/// tuning knob is malformed is worse than one that answers on the default and
1445/// says which budget it used — which the report does, in
1446/// `collection_cost.wal_pin_census_budget_ms`.
1447const CENSUS_BUDGET_ENV: &str = "KHIVE_WALPIN_CENSUS_BUDGET_MS";
1448
1449fn request_census_budget() -> Option<Duration> {
1450    match std::env::var(CENSUS_BUDGET_ENV) {
1451        Ok(raw) => match raw.trim().parse::<u64>() {
1452            Ok(0) => None,
1453            Ok(ms) => Some(Duration::from_millis(ms)),
1454            Err(_) => Some(DEFAULT_CENSUS_BUDGET),
1455        },
1456        Err(_) => Some(DEFAULT_CENSUS_BUDGET),
1457    }
1458}
1459
1460/// The full database-integrity, reader/writer-contention, and WAL/checkpoint
1461/// payload.
1462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1463pub struct WalCeilingDiagnostics {
1464    /// Resolved configuration value, retained even on a read-only backend.
1465    pub configured_bytes: u64,
1466    /// Active writer-policy limit; zero on read-only backends and when disabled.
1467    pub effective_bytes: u64,
1468    pub source: WalCeilingSource,
1469    pub enabled: bool,
1470    /// Why the configured policy is active or inactive for this backend.
1471    pub status: &'static str,
1472}
1473
1474impl WalCeilingDiagnostics {
1475    fn from_pool(pool: &ConnectionPool) -> Self {
1476        let policy = pool.config().wal_ceiling;
1477        let read_only = pool.config().read_only;
1478        let effective_bytes = policy.effective_bytes(read_only);
1479        Self {
1480            configured_bytes: policy.bytes,
1481            effective_bytes,
1482            source: policy.source,
1483            enabled: effective_bytes > 0,
1484            status: if effective_bytes > 0 {
1485                "enforced"
1486            } else if read_only && policy.bytes > 0 {
1487                "read_only_not_enforced"
1488            } else {
1489                "disabled"
1490            },
1491        }
1492    }
1493}
1494
1495#[derive(Debug, Clone, PartialEq, Serialize)]
1496pub struct DbDiagnostics {
1497    pub build: BuildIdentity,
1498    pub process: ProcessIdentity,
1499    /// Process-lifetime note-search vector route counts, independent of this
1500    /// report's database file and reset only with the serving process.
1501    pub note_search_ann_route_total: u64,
1502    pub note_search_fallback_route_total: u64,
1503    /// Pool-scoped, monotonic coordinator dispatch and note-candidate
1504    /// hydration counts used by ADR-166 G4/G5. Reset on pool reconstruction.
1505    pub search_mechanism: crate::pool::SearchMechanismSnapshot,
1506    /// `None` for an in-memory backend — the file-backed sections then carry
1507    /// their own unavailability reasons.
1508    pub db_path: Option<String>,
1509    /// Explicit WAL ceiling policy for this already-open database.
1510    pub wal_ceiling: WalCeilingDiagnostics,
1511    pub disk_guard: DiskGuardDiagnostics,
1512    pub wal_file: Option<WalFileState>,
1513    pub checkpoint_counters: CheckpointCounters,
1514    pub checkpoint_probe: Option<CheckpointProbe>,
1515    pub checkpoint_probe_error: Option<String>,
1516    #[serde(flatten)]
1517    /// Frame pin fields serialized beside `checkpoint_probe` at the report root.
1518    pub checkpoint_pin: CheckpointPinDiagnostics,
1519    /// Reader route, checkout saturation, and hold-lifecycle signals.
1520    pub reader_contention: ReaderContentionDiagnostics,
1521    /// Writer-pool and best-effort audit persistence signals.
1522    pub writer_contention: WriterContentionDiagnostics,
1523    pub size_composition: Option<DatabaseSizeComposition>,
1524    pub size_composition_error: Option<String>,
1525    /// Page size, page count and free-list length from three pragmas, present
1526    /// whether or not `size_composition` was collected.
1527    pub page_accounting: Option<DatabasePageAccounting>,
1528    pub page_accounting_error: Option<String>,
1529    pub graph_edge_integrity: Option<GraphEdgeIntegrity>,
1530    pub graph_edge_integrity_error: Option<String>,
1531    /// Current FTS5 segment structure, decoded from each index's documented
1532    /// one-row structure record (shadow-table row id 10). This does not scan
1533    /// either corpus or `%_idx` table.
1534    pub fts_segments: Option<crate::FtsSegmentDiagnostics>,
1535    pub fts_segments_error: Option<String>,
1536    /// Process-lifetime outcomes from the checkpoint task's bounded FTS5
1537    /// maintenance steps.
1538    pub fts_maintenance: crate::FtsMaintenanceCounters,
1539    pub wal_pin: WalPinAttribution,
1540    /// Per-section assembly cost for this report. See [`CollectionCost`].
1541    pub collection_cost: CollectionCost,
1542}
1543
1544/// Checkpoint-row evidence used to report a sustained backfill ceiling.
1545#[derive(Debug, Clone, PartialEq, Serialize)]
1546pub struct CheckpointPinDiagnostics {
1547    /// The probe row's checkpointed frame when it shows unbackfilled frames.
1548    pub backfill_ceiling: Option<i64>,
1549    /// Why the probe row did not establish a backfill ceiling.
1550    pub backfill_ceiling_unavailable_reason: Option<String>,
1551    /// The ceiling after a matching checkpoint run has lasted at least one second.
1552    pub oldest_pinned_frame: Option<i64>,
1553    /// Why the current probe did not establish an oldest pinned frame.
1554    pub oldest_pinned_frame_unavailable_reason: Option<String>,
1555    /// The first observation for the reported frame.
1556    pub oldest_pinned_frame_run: Option<crate::checkpoint::CheckpointRun>,
1557    /// Why the matching checkpoint run is unavailable.
1558    pub oldest_pinned_frame_run_unavailable_reason: Option<String>,
1559    /// Frames from the sustained pin to the current probe's log end.
1560    pub pin_depth: Option<i64>,
1561    /// Why this report cannot claim a pin depth.
1562    pub pin_depth_unavailable_reason: Option<String>,
1563}
1564
1565fn checkpoint_pin_diagnostics(
1566    pool: &ConnectionPool,
1567    probe: Option<&CheckpointProbe>,
1568    probe_error: Option<&str>,
1569) -> CheckpointPinDiagnostics {
1570    let (run_status, run_age) = checkpoint::checkpoint_run_snapshot(pool);
1571    checkpoint_pin_diagnostics_for_run(probe, probe_error, run_status, run_age)
1572}
1573
1574fn checkpoint_pin_diagnostics_for_run(
1575    probe: Option<&CheckpointProbe>,
1576    probe_error: Option<&str>,
1577    run_status: checkpoint::CheckpointRunStatus,
1578    run_age: Option<Duration>,
1579) -> CheckpointPinDiagnostics {
1580    let (backfill_ceiling, backfill_reason) = match probe {
1581        None => (
1582            None,
1583            Some(
1584                probe_error
1585                    .unwrap_or("checkpoint probe did not return a row")
1586                    .to_string(),
1587            ),
1588        ),
1589        Some(probe) if probe.busy != 0 => (
1590            None,
1591            Some(format!("PASSIVE checkpoint returned busy={}", probe.busy)),
1592        ),
1593        Some(probe) if probe.log_frames < 0 || probe.checkpointed_frames < 0 => (
1594            None,
1595            Some("PASSIVE checkpoint returned a negative frame count".to_string()),
1596        ),
1597        Some(probe) if probe.log_frames <= probe.checkpointed_frames => (
1598            None,
1599            Some("PASSIVE checkpoint found no frames beyond the backfill ceiling".to_string()),
1600        ),
1601        Some(probe) => (Some(probe.checkpointed_frames), None),
1602    };
1603
1604    let (oldest_pinned_frame, oldest_reason, oldest_pinned_frame_run, run_reason) =
1605        match backfill_ceiling {
1606            None => {
1607                let reason = backfill_reason
1608                    .clone()
1609                    .unwrap_or_else(|| "backfill ceiling is unavailable".to_string());
1610                (None, Some(reason.clone()), None, Some(reason))
1611            }
1612            Some(ceiling) => match run_status {
1613                checkpoint::CheckpointRunStatus::NoTask => {
1614                    let reason = "no checkpoint task in this process".to_string();
1615                    (None, Some(reason.clone()), None, Some(reason))
1616                }
1617                checkpoint::CheckpointRunStatus::NoObservation => {
1618                    let reason = "no checkpoint run has been observed for this backend".to_string();
1619                    (None, Some(reason.clone()), None, Some(reason))
1620                }
1621                checkpoint::CheckpointRunStatus::Observed(run) if run.frame != ceiling => {
1622                    let reason = "checkpoint run frame does not match the current backfill ceiling";
1623                    (
1624                        None,
1625                        Some(reason.to_string()),
1626                        None,
1627                        Some(reason.to_string()),
1628                    )
1629                }
1630                checkpoint::CheckpointRunStatus::Observed(_)
1631                    if run_age.is_none_or(|age| age < Duration::from_secs(1)) =>
1632                {
1633                    let reason = "checkpoint run has been observed for less than one second";
1634                    (
1635                        None,
1636                        Some(reason.to_string()),
1637                        None,
1638                        Some(reason.to_string()),
1639                    )
1640                }
1641                checkpoint::CheckpointRunStatus::Observed(run) => {
1642                    (Some(ceiling), None, Some(run), None)
1643                }
1644            },
1645        };
1646
1647    let pin_depth = oldest_pinned_frame.map(|frame| {
1648        probe
1649            .expect("a reported pin always has a probe row")
1650            .log_frames
1651            .saturating_sub(frame)
1652            .max(0)
1653    });
1654    let pin_depth_reason = if pin_depth.is_none() {
1655        oldest_reason.clone()
1656    } else {
1657        None
1658    };
1659
1660    CheckpointPinDiagnostics {
1661        backfill_ceiling,
1662        backfill_ceiling_unavailable_reason: backfill_reason,
1663        oldest_pinned_frame,
1664        oldest_pinned_frame_unavailable_reason: oldest_reason,
1665        oldest_pinned_frame_run,
1666        oldest_pinned_frame_run_unavailable_reason: run_reason,
1667        pin_depth,
1668        pin_depth_unavailable_reason: pin_depth_reason,
1669    }
1670}
1671
1672/// Assemble the report for `pool`'s database.
1673///
1674/// `db_path` in the returned report is the pool's own configured path, so
1675/// the report can never claim to describe a file the pool is not bound to.
1676/// Every operational probe — the WAL file, the sidecar, the OS holder census
1677/// — instead targets `pool.canonical_path()`, the same value `ConnectionPool`
1678/// and the checkpoint sidecar writers key off of; a symlinked or otherwise
1679/// aliased configured path would otherwise send those probes looking beside
1680/// the alias while the evidence sits beside the canonical file. An in-memory
1681/// pool has no path: its process- and pool-scoped counters are still real,
1682/// but every file-backed section degrades to an explicit "unavailable" with
1683/// a reason rather than being silently omitted.
1684///
1685/// The PASSIVE probe goes through the infrastructure-only untracked
1686/// standalone open, WITHOUT `SQLITE_OPEN_CREATE`, so a diagnostic request
1687/// against a missing file returns `checkpoint_probe: null` with a
1688/// `checkpoint_probe_error` instead of creating a database or incrementing
1689/// the write-traffic acquisition total. Running on a standalone connection
1690/// also keeps checkpoint I/O off the pooled writer mutex.
1691pub fn collect(
1692    pool: &ConnectionPool,
1693    build: BuildIdentity,
1694    sweep_interval: Duration,
1695) -> DbDiagnostics {
1696    collect_inner(pool, build, sweep_interval, None, None)
1697}
1698
1699/// Assemble the report with the runtime's process-wide count of swallowed
1700/// best-effort audit append failures.
1701pub fn collect_with_audit_append_failures(
1702    pool: &ConnectionPool,
1703    build: BuildIdentity,
1704    sweep_interval: Duration,
1705    audit_append_failures: u64,
1706) -> DbDiagnostics {
1707    collect_inner(
1708        pool,
1709        build,
1710        sweep_interval,
1711        Some(audit_append_failures),
1712        None,
1713    )
1714}
1715
1716/// Assemble diagnostics without allowing request abandonment to leave the
1717/// graph SELECT or OS holder census running detached.
1718///
1719/// The PASSIVE checkpoint is intentionally outside SQLite interruption: once
1720/// admitted it may backfill database pages and must reach its physical
1721/// completion. The following graph integrity SELECT registers the common
1722/// request progress callback on that exact connection. The OS census runs as
1723/// a second cooperative phase and polls a shared cancellation flag between
1724/// process and fd entries.
1725pub async fn collect_with_audit_append_failures_interruptibly(
1726    pool: Arc<ConnectionPool>,
1727    build: BuildIdentity,
1728    sweep_interval: Duration,
1729    audit_append_failures: u64,
1730) -> StorageResult<DbDiagnostics> {
1731    collect_with_runtime_audit_metrics_interruptibly(
1732        pool,
1733        build,
1734        sweep_interval,
1735        audit_append_failures,
1736        None,
1737    )
1738    .await
1739}
1740
1741/// Like [`collect_with_audit_append_failures_interruptibly`], additionally
1742/// threading through the runtime's audit-batch health counters. `None` when
1743/// no audit-batch control is registered with the calling runtime instance —
1744/// the corresponding `writer_contention` fields then report unavailable with
1745/// a reason, exactly like `audit_append_failures` does for a direct
1746/// `khive-db` caller.
1747pub async fn collect_with_runtime_audit_metrics_interruptibly(
1748    pool: Arc<ConnectionPool>,
1749    build: BuildIdentity,
1750    sweep_interval: Duration,
1751    audit_append_failures: u64,
1752    runtime_audit_batch_metrics: Option<RuntimeAuditBatchMetrics>,
1753) -> StorageResult<DbDiagnostics> {
1754    let process = ProcessIdentity::current(&pool);
1755    collect_with_runtime_audit_metrics_for_process_interruptibly(
1756        pool,
1757        build,
1758        process,
1759        sweep_interval,
1760        audit_append_failures,
1761        runtime_audit_batch_metrics,
1762        true,
1763    )
1764    .await
1765}
1766
1767/// Collect one already-open pool while retaining the main pool's process and
1768/// generation identity. A secondary pool must not claim or advance the main
1769/// pool generation when its pool-scoped counters are inspected.
1770///
1771/// `collect_size_composition = false` skips the `dbstat` walk, whose cost
1772/// grows with the file because it reads every page; the report then carries
1773/// [`SIZE_COMPOSITION_NOT_REQUESTED`] in `size_composition_error`, and
1774/// `page_accounting` still reports the page counts.
1775pub async fn collect_with_runtime_audit_metrics_for_process_interruptibly(
1776    pool: Arc<ConnectionPool>,
1777    build: BuildIdentity,
1778    process: ProcessIdentity,
1779    sweep_interval: Duration,
1780    audit_append_failures: u64,
1781    runtime_audit_batch_metrics: Option<RuntimeAuditBatchMetrics>,
1782    collect_size_composition: bool,
1783) -> StorageResult<DbDiagnostics> {
1784    crate::ensure_request_read_active("db_diagnostics")?;
1785    let started = Instant::now();
1786    let counters = checkpoint_counters();
1787    let reader_contention = ReaderContentionDiagnostics::snapshot(&pool);
1788    let search_mechanism = pool.search_mechanism_snapshot();
1789    let writer_contention = WriterContentionDiagnostics::snapshot(
1790        &pool,
1791        Some(audit_append_failures),
1792        runtime_audit_batch_metrics,
1793    );
1794
1795    let Some(path) = pool.config().path.clone() else {
1796        crate::ensure_request_read_active("db_diagnostics")?;
1797        return Ok(DbDiagnostics {
1798            build,
1799            process,
1800            note_search_ann_route_total: 0,
1801            note_search_fallback_route_total: 0,
1802            search_mechanism,
1803            db_path: None,
1804            wal_ceiling: WalCeilingDiagnostics::from_pool(&pool),
1805            disk_guard: DiskGuardDiagnostics::snapshot(&pool),
1806            wal_file: None,
1807            checkpoint_counters: counters,
1808            checkpoint_probe: None,
1809            checkpoint_probe_error: Some(
1810                "in-memory database: no WAL file and no checkpoint to probe".to_string(),
1811            ),
1812            checkpoint_pin: checkpoint_pin_diagnostics(
1813                &pool,
1814                None,
1815                Some("in-memory database: no WAL file and no checkpoint to probe"),
1816            ),
1817            reader_contention,
1818            writer_contention,
1819            size_composition: None,
1820            size_composition_error: Some(
1821                "in-memory database: no file-backed page composition to inspect".to_string(),
1822            ),
1823            page_accounting: None,
1824            page_accounting_error: Some(
1825                "in-memory database: no file-backed pages to count".to_string(),
1826            ),
1827            graph_edge_integrity: None,
1828            graph_edge_integrity_error: Some(
1829                "in-memory database: no durable graph-edge ledger to inspect".to_string(),
1830            ),
1831            fts_segments: None,
1832            fts_segments_error: Some(
1833                "in-memory database: no durable FTS5 indexes to inspect".to_string(),
1834            ),
1835            fts_maintenance: crate::fts_maintenance_counters(),
1836            wal_pin: WalPinAttribution::unavailable(
1837                "in-memory database: no file for the OS holder census",
1838            ),
1839            collection_cost: CollectionCost::in_memory(elapsed_ms(started)),
1840        });
1841    };
1842
1843    let inspection_pool = Arc::clone(&pool);
1844    let sqlite_started = Instant::now();
1845    let inspection = crate::read_cancellation::run_interruptible_read(
1846        StorageCapability::Sql,
1847        "db_diagnostics.sqlite",
1848        move |scope| inspect_pool_interruptibly(&inspection_pool, scope, collect_size_composition),
1849    )
1850    .await?;
1851    let sqlite_ms = elapsed_ms(sqlite_started);
1852    crate::ensure_request_read_active("db_diagnostics")?;
1853    let canonical = operational_db_path(&pool, &path);
1854    let budget = request_census_budget();
1855    let (wal_file, wal_pin, file_state_cost) =
1856        inspect_file_state_interruptibly(canonical, sweep_interval, budget).await?;
1857    crate::ensure_request_read_active("db_diagnostics")?;
1858
1859    Ok(DbDiagnostics {
1860        build,
1861        process,
1862        note_search_ann_route_total: 0,
1863        note_search_fallback_route_total: 0,
1864        search_mechanism,
1865        db_path: Some(path.display().to_string()),
1866        wal_ceiling: WalCeilingDiagnostics::from_pool(&pool),
1867        disk_guard: DiskGuardDiagnostics::snapshot(&pool),
1868        wal_file: Some(wal_file),
1869        checkpoint_counters: counters,
1870        checkpoint_probe: inspection.checkpoint_probe,
1871        checkpoint_probe_error: inspection.checkpoint_probe_error,
1872        checkpoint_pin: inspection.checkpoint_pin,
1873        reader_contention,
1874        writer_contention,
1875        size_composition: inspection.size_composition,
1876        size_composition_error: inspection.size_composition_error,
1877        page_accounting: inspection.page_accounting,
1878        page_accounting_error: inspection.page_accounting_error,
1879        graph_edge_integrity: inspection.graph_edge_integrity,
1880        graph_edge_integrity_error: inspection.graph_edge_integrity_error,
1881        fts_segments: inspection.fts_segments,
1882        fts_segments_error: inspection.fts_segments_error,
1883        fts_maintenance: crate::fts_maintenance_counters(),
1884        wal_pin,
1885        collection_cost: CollectionCost {
1886            total_ms: elapsed_ms(started),
1887            sqlite_ms,
1888            wal_file_stat_ms: file_state_cost.wal_file_stat_ms,
1889            wal_pin_census_ms: file_state_cost.census_ms,
1890            wal_pin_sidecar_ms: file_state_cost.sidecar_ms,
1891            wal_pin_census_budget_ms: budget.map(|b| b.as_millis() as u64),
1892            wal_pin_census_budget_exhausted: file_state_cost.census_budget_exhausted,
1893        },
1894    })
1895}
1896
1897/// The path every operational probe (WAL file, sidecar directory, OS holder
1898/// census) resolves against for `pool`'s database. The checkpoint sidecar
1899/// writers and `ConnectionPool` itself key the WAL and sidecar directory off
1900/// `canonical_path()`, not the raw `configured` path — a symlinked or
1901/// otherwise aliased configured path would otherwise send every probe below
1902/// looking beside the alias while the evidence sits beside the canonical
1903/// file. `configured` remains the presentation value the sync and async
1904/// collectors put in `db_path`, since that is what the caller configured.
1905/// Both `collect_inner` and `collect_with_runtime_audit_metrics_interruptibly`
1906/// resolve through this one function so their aliasing behavior cannot drift
1907/// apart.
1908fn operational_db_path(pool: &ConnectionPool, configured: &Path) -> PathBuf {
1909    pool.canonical_path()
1910        .map(Path::to_path_buf)
1911        .unwrap_or_else(|| configured.to_path_buf())
1912}
1913
1914fn collect_inner(
1915    pool: &ConnectionPool,
1916    build: BuildIdentity,
1917    sweep_interval: Duration,
1918    audit_append_failures: Option<u64>,
1919    runtime_audit_batch_metrics: Option<RuntimeAuditBatchMetrics>,
1920) -> DbDiagnostics {
1921    let started = Instant::now();
1922    let process = ProcessIdentity::current(pool);
1923    let counters = checkpoint_counters();
1924    let reader_contention = ReaderContentionDiagnostics::snapshot(pool);
1925    let search_mechanism = pool.search_mechanism_snapshot();
1926    let writer_contention = WriterContentionDiagnostics::snapshot(
1927        pool,
1928        audit_append_failures,
1929        runtime_audit_batch_metrics,
1930    );
1931
1932    let Some(path) = pool.config().path.clone() else {
1933        return DbDiagnostics {
1934            build,
1935            process,
1936            note_search_ann_route_total: 0,
1937            note_search_fallback_route_total: 0,
1938            search_mechanism,
1939            db_path: None,
1940            wal_ceiling: WalCeilingDiagnostics::from_pool(pool),
1941            disk_guard: DiskGuardDiagnostics::snapshot(pool),
1942            wal_file: None,
1943            checkpoint_counters: counters,
1944            checkpoint_probe: None,
1945            checkpoint_probe_error: Some(
1946                "in-memory database: no WAL file and no checkpoint to probe".to_string(),
1947            ),
1948            checkpoint_pin: checkpoint_pin_diagnostics(
1949                pool,
1950                None,
1951                Some("in-memory database: no WAL file and no checkpoint to probe"),
1952            ),
1953            reader_contention,
1954            writer_contention,
1955            size_composition: None,
1956            size_composition_error: Some(
1957                "in-memory database: no file-backed page composition to inspect".to_string(),
1958            ),
1959            page_accounting: None,
1960            page_accounting_error: Some(
1961                "in-memory database: no file-backed pages to count".to_string(),
1962            ),
1963            graph_edge_integrity: None,
1964            graph_edge_integrity_error: Some(
1965                "in-memory database: no durable graph-edge ledger to inspect".to_string(),
1966            ),
1967            fts_segments: None,
1968            fts_segments_error: Some(
1969                "in-memory database: no durable FTS5 indexes to inspect".to_string(),
1970            ),
1971            fts_maintenance: crate::fts_maintenance_counters(),
1972            wal_pin: WalPinAttribution::unavailable(
1973                "in-memory database: no file for the OS holder census",
1974            ),
1975            collection_cost: CollectionCost::in_memory(elapsed_ms(started)),
1976        };
1977    };
1978
1979    let sqlite_started = Instant::now();
1980    let inspection = inspect_pool(pool);
1981    let sqlite_ms = elapsed_ms(sqlite_started);
1982    let canonical = operational_db_path(pool, &path);
1983    let wal_file_started = Instant::now();
1984    let wal_file = wal_file_state(&canonical);
1985    let wal_file_stat_ms = elapsed_ms(wal_file_started);
1986    let census_started = Instant::now();
1987    let wal_pin = wal_pin_attribution(&canonical, sweep_interval);
1988    let wal_pin_ms = elapsed_ms(census_started);
1989
1990    DbDiagnostics {
1991        build,
1992        process,
1993        note_search_ann_route_total: 0,
1994        note_search_fallback_route_total: 0,
1995        search_mechanism,
1996        db_path: Some(path.display().to_string()),
1997        wal_ceiling: WalCeilingDiagnostics::from_pool(pool),
1998        disk_guard: DiskGuardDiagnostics::snapshot(pool),
1999        wal_file: Some(wal_file),
2000        checkpoint_counters: counters,
2001        checkpoint_probe: inspection.checkpoint_probe,
2002        checkpoint_probe_error: inspection.checkpoint_probe_error,
2003        checkpoint_pin: inspection.checkpoint_pin,
2004        reader_contention,
2005        writer_contention,
2006        size_composition: inspection.size_composition,
2007        size_composition_error: inspection.size_composition_error,
2008        page_accounting: inspection.page_accounting,
2009        page_accounting_error: inspection.page_accounting_error,
2010        graph_edge_integrity: inspection.graph_edge_integrity,
2011        graph_edge_integrity_error: inspection.graph_edge_integrity_error,
2012        fts_segments: inspection.fts_segments,
2013        fts_segments_error: inspection.fts_segments_error,
2014        fts_maintenance: crate::fts_maintenance_counters(),
2015        wal_pin,
2016        collection_cost: CollectionCost {
2017            total_ms: elapsed_ms(started),
2018            sqlite_ms,
2019            wal_file_stat_ms,
2020            // This path does not separate the census from the sidecar read it
2021            // is reconciled against: it calls the combined `wal_pin_attribution`,
2022            // so splitting them here would mean inventing a number. The
2023            // request path, which is the one the operator waits on, does split
2024            // them.
2025            wal_pin_census_ms: wal_pin_ms,
2026            wal_pin_sidecar_ms: 0,
2027            wal_pin_census_budget_ms: None,
2028            wal_pin_census_budget_exhausted: false,
2029        },
2030    }
2031}
2032
2033struct PoolInspection {
2034    checkpoint_probe: Option<CheckpointProbe>,
2035    checkpoint_probe_error: Option<String>,
2036    checkpoint_pin: CheckpointPinDiagnostics,
2037    size_composition: Option<DatabaseSizeComposition>,
2038    size_composition_error: Option<String>,
2039    page_accounting: Option<DatabasePageAccounting>,
2040    page_accounting_error: Option<String>,
2041    graph_edge_integrity: Option<GraphEdgeIntegrity>,
2042    graph_edge_integrity_error: Option<String>,
2043    fts_segments: Option<crate::FtsSegmentDiagnostics>,
2044    fts_segments_error: Option<String>,
2045}
2046
2047/// Split an FTS segment inspection result into the `(value, error)` pair
2048/// shape every section of [`PoolInspection`]/[`DbDiagnostics`] uses, shared
2049/// by the interruptible and plain inspection paths below.
2050fn split_fts_segments_result(
2051    result: Result<crate::FtsSegmentDiagnostics, String>,
2052) -> (Option<crate::FtsSegmentDiagnostics>, Option<String>) {
2053    match result {
2054        Ok(segments) => (Some(segments), None),
2055        Err(error) => (None, Some(error)),
2056    }
2057}
2058
2059fn split_page_accounting_result(
2060    result: rusqlite::Result<DatabasePageAccounting>,
2061) -> (Option<DatabasePageAccounting>, Option<String>) {
2062    match result {
2063        Ok(pages) => (Some(pages), None),
2064        Err(error) => (None, Some(format!("page accounting query failed: {error}"))),
2065    }
2066}
2067
2068/// The `size_composition_error` of a report whose caller skipped the `dbstat`
2069/// walk. The walk reads every page of the file; `page_accounting` still
2070/// carries the page counts.
2071pub const SIZE_COMPOSITION_NOT_REQUESTED: &str =
2072    "not collected: size_composition=false skips the dbstat walk, which reads every page";
2073
2074fn record_diagnostic_checkpoint_probe(
2075    pool: &ConnectionPool,
2076    result: &rusqlite::Result<CheckpointProbe>,
2077) -> (checkpoint::CheckpointRunStatus, Option<Duration>) {
2078    checkpoint::record_checkpoint_run_result(
2079        pool,
2080        result
2081            .as_ref()
2082            .ok()
2083            .map(|probe| (probe.busy, probe.log_frames, probe.checkpointed_frames)),
2084    );
2085    checkpoint::checkpoint_run_snapshot(pool)
2086}
2087
2088fn inspect_pool_interruptibly(
2089    pool: &ConnectionPool,
2090    scope: &crate::read_cancellation::InterruptibleReadScope,
2091    collect_size_composition: bool,
2092) -> StorageResult<PoolInspection> {
2093    scope.ensure_active()?;
2094    let conn = match pool.open_standalone_writer_untracked() {
2095        Ok(conn) => conn,
2096        Err(e) => {
2097            scope.ensure_active()?;
2098            let reason = format!("guarded standalone open refused: {e}");
2099            return Ok(PoolInspection {
2100                checkpoint_probe: None,
2101                checkpoint_probe_error: Some(reason.clone()),
2102                checkpoint_pin: checkpoint_pin_diagnostics(pool, None, Some(&reason)),
2103                size_composition: None,
2104                size_composition_error: Some(reason.clone()),
2105                page_accounting: None,
2106                page_accounting_error: Some(reason.clone()),
2107                graph_edge_integrity: None,
2108                graph_edge_integrity_error: Some(reason.clone()),
2109                fts_segments: None,
2110                fts_segments_error: Some(reason),
2111            });
2112        }
2113    };
2114    // Opening is read-only filesystem work but can block. Cancellation that
2115    // arrived while it was in flight must prevent admission of the following
2116    // PASSIVE checkpoint, whose backfill I/O is intentionally noninterruptible
2117    // once started.
2118    scope.ensure_active()?;
2119
2120    // PASSIVE can perform write I/O. Never install sqlite3_interrupt for it.
2121    let probe_result = checkpoint_probe(&conn);
2122    let (run_status, run_age) = record_diagnostic_checkpoint_probe(pool, &probe_result);
2123    let (checkpoint_probe, checkpoint_probe_error) = match probe_result {
2124        Ok(probe) => (Some(probe), None),
2125        Err(e) => (
2126            None,
2127            Some(format!("PRAGMA wal_checkpoint(PASSIVE) failed: {e}")),
2128        ),
2129    };
2130    let checkpoint_pin = checkpoint_pin_diagnostics_for_run(
2131        checkpoint_probe.as_ref(),
2132        checkpoint_probe_error.as_deref(),
2133        run_status,
2134        run_age,
2135    );
2136    #[cfg(test)]
2137    if TEST_PAUSE_AFTER_PASSIVE.load(Ordering::SeqCst) {
2138        TEST_REACHED_AFTER_PASSIVE.store(true, Ordering::SeqCst);
2139        while TEST_PAUSE_AFTER_PASSIVE.load(Ordering::SeqCst) && !scope.should_stop() {
2140            std::thread::yield_now();
2141        }
2142    }
2143    scope.ensure_active()?;
2144
2145    // Install one progress/interrupt guard for all logical reads on this
2146    // connection. The scope deliberately refuses double registration, so
2147    // keep each query's ordinary SQLite result nested inside one guarded
2148    // execution while allowing the outer cancellation cause to remain typed.
2149    let (integrity, page_accounting, size_composition, fts_segments) = scope.run(&conn, || {
2150        Ok((
2151            graph_edge_integrity(&conn),
2152            database_page_accounting(&conn),
2153            collect_size_composition.then(|| database_size_composition(&conn)),
2154            crate::fts_maintenance::inspect_fts_segments(&conn),
2155        ))
2156    })?;
2157    let (graph_edge_integrity, graph_edge_integrity_error) = match integrity {
2158        Ok(integrity) => (Some(integrity), None),
2159        Err(e) => (
2160            None,
2161            Some(format!("graph-edge integrity query failed: {e}")),
2162        ),
2163    };
2164    let (fts_segments, fts_segments_error) = split_fts_segments_result(fts_segments);
2165    let (page_accounting, page_accounting_error) = split_page_accounting_result(page_accounting);
2166
2167    let (size_composition, size_composition_error) = match size_composition {
2168        Some(Ok(composition)) => (Some(composition), None),
2169        Some(Err(error)) => (
2170            None,
2171            Some(format!("database size composition query failed: {error}")),
2172        ),
2173        None => (None, Some(SIZE_COMPOSITION_NOT_REQUESTED.to_string())),
2174    };
2175
2176    Ok(PoolInspection {
2177        checkpoint_probe,
2178        checkpoint_probe_error,
2179        checkpoint_pin,
2180        size_composition,
2181        size_composition_error,
2182        page_accounting,
2183        page_accounting_error,
2184        graph_edge_integrity,
2185        graph_edge_integrity_error,
2186        fts_segments,
2187        fts_segments_error,
2188    })
2189}
2190
2191#[cfg(test)]
2192static TEST_PAUSE_AFTER_PASSIVE: AtomicBool = AtomicBool::new(false);
2193#[cfg(test)]
2194static TEST_REACHED_AFTER_PASSIVE: AtomicBool = AtomicBool::new(false);
2195
2196struct StopCensusOnDrop {
2197    stopped: Arc<AtomicBool>,
2198    armed: bool,
2199}
2200
2201impl Drop for StopCensusOnDrop {
2202    fn drop(&mut self) {
2203        if self.armed {
2204            self.stopped.store(true, Ordering::SeqCst);
2205        }
2206    }
2207}
2208
2209/// Wall-clock cost of the file-backed half of a report, split so the census
2210/// can be told apart from the two cheap reads it sits between.
2211#[derive(Debug, Clone, Copy, Default)]
2212struct FileStateCost {
2213    wal_file_stat_ms: u64,
2214    census_ms: u64,
2215    sidecar_ms: u64,
2216    census_budget_exhausted: bool,
2217}
2218
2219fn elapsed_ms(since: Instant) -> u64 {
2220    since.elapsed().as_millis() as u64
2221}
2222
2223async fn inspect_file_state_interruptibly(
2224    path: PathBuf,
2225    sweep_interval: Duration,
2226    census_budget: Option<Duration>,
2227) -> StorageResult<(WalFileState, WalPinAttribution, FileStateCost)> {
2228    const OPERATION: &str = "db_diagnostics.wal_holder_census";
2229    crate::ensure_request_read_active(OPERATION)?;
2230    let stopped = Arc::new(AtomicBool::new(false));
2231    let worker_stopped = Arc::clone(&stopped);
2232    let mut stop_on_drop = StopCensusOnDrop {
2233        stopped: Arc::clone(&stopped),
2234        armed: true,
2235    };
2236    let mut worker = tokio::task::spawn_blocking(move || {
2237        let mut cost = FileStateCost::default();
2238        let wal_file_started = Instant::now();
2239        let wal_file = wal_file_state(&path);
2240        cost.wal_file_stat_ms = elapsed_ms(wal_file_started);
2241        if worker_stopped.load(Ordering::SeqCst) {
2242            return Err(std::io::Error::new(
2243                std::io::ErrorKind::Interrupted,
2244                "WAL holder census cancelled",
2245            ));
2246        }
2247        #[cfg(unix)]
2248        let census_started = Instant::now();
2249        #[cfg(unix)]
2250        let census_result = match census_budget {
2251            Some(budget) => crate::walpin::census_holders_until_within(
2252                &path,
2253                || worker_stopped.load(Ordering::SeqCst),
2254                budget,
2255            ),
2256            None => {
2257                crate::walpin::census_holders_until(&path, || worker_stopped.load(Ordering::SeqCst))
2258            }
2259        };
2260        #[cfg(unix)]
2261        {
2262            cost.census_ms = elapsed_ms(census_started);
2263        }
2264        #[cfg(unix)]
2265        let attribution = match census_result {
2266            Ok(census) => {
2267                cost.census_budget_exhausted = census.budget_exhausted;
2268                if worker_stopped.load(Ordering::SeqCst) {
2269                    return Err(std::io::Error::new(
2270                        std::io::ErrorKind::Interrupted,
2271                        "WAL sidecar inspection cancelled",
2272                    ));
2273                }
2274                if !crate::walpin::sidecar_enabled(true) {
2275                    wal_pin_attribution_without_sidecar(census, SIDECAR_DISABLED_REASON.to_string())
2276                } else {
2277                    let sidecar_started = Instant::now();
2278                    let sidecar = crate::walpin::inspect_live(
2279                        &crate::walpin::sidecar_dir_for(&path),
2280                        sweep_interval,
2281                    );
2282                    cost.sidecar_ms = elapsed_ms(sidecar_started);
2283                    if worker_stopped.load(Ordering::SeqCst) {
2284                        return Err(std::io::Error::new(
2285                            std::io::ErrorKind::Interrupted,
2286                            "WAL sidecar inspection cancelled",
2287                        ));
2288                    }
2289                    match sidecar {
2290                        Ok(sidecar) => wal_pin_attribution_from_evidence(census, sidecar),
2291                        Err(error) => wal_pin_attribution_without_sidecar(
2292                            census,
2293                            format!("read-only sidecar enumeration failed: {error}"),
2294                        ),
2295                    }
2296                }
2297            }
2298            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => return Err(error),
2299            Err(error) => WalPinAttribution::unavailable(format!("census_holders failed: {error}")),
2300        };
2301        #[cfg(not(unix))]
2302        let attribution = {
2303            let _ = census_budget;
2304            let census_started = Instant::now();
2305            let attribution = wal_pin_attribution(&path, sweep_interval);
2306            cost.census_ms = elapsed_ms(census_started);
2307            attribution
2308        };
2309        Ok((wal_file, attribution, cost))
2310    });
2311
2312    tokio::select! {
2313        joined = &mut worker => {
2314            stop_on_drop.armed = false;
2315            let result = joined
2316                .map_err(|error| StorageError::driver(StorageCapability::Sql, OPERATION, error))?
2317                .map_err(|error| StorageError::driver(StorageCapability::Sql, OPERATION, error))?;
2318            crate::ensure_request_read_active(OPERATION)?;
2319            Ok(result)
2320        }
2321        _ = crate::wait_for_request_read_cancellation() => {
2322            stopped.store(true, Ordering::SeqCst);
2323            if tokio::time::timeout(crate::sqlite_interrupt_grace_from_env(), &mut worker)
2324                .await
2325                .is_err()
2326            {
2327                worker.abort();
2328            }
2329            stop_on_drop.armed = false;
2330            Err(StorageError::Timeout { operation: OPERATION.into() })
2331        }
2332    }
2333}
2334
2335/// Run the PASSIVE probe and graph-ledger reads on one guarded standalone
2336/// connection.
2337///
2338/// Every failure — missing file, read-only pool, in-memory pool, or the
2339/// pragma/query itself — comes back in its section's error field. Nothing
2340/// here can create a file.
2341fn inspect_pool(pool: &ConnectionPool) -> PoolInspection {
2342    let conn = match pool.open_standalone_writer_untracked() {
2343        Ok(conn) => conn,
2344        Err(e) => {
2345            let reason = format!("guarded standalone open refused: {e}");
2346            return PoolInspection {
2347                checkpoint_probe: None,
2348                checkpoint_probe_error: Some(reason.clone()),
2349                checkpoint_pin: checkpoint_pin_diagnostics(pool, None, Some(&reason)),
2350                size_composition: None,
2351                size_composition_error: Some(reason.clone()),
2352                page_accounting: None,
2353                page_accounting_error: Some(reason.clone()),
2354                graph_edge_integrity: None,
2355                graph_edge_integrity_error: Some(reason.clone()),
2356                fts_segments: None,
2357                fts_segments_error: Some(reason),
2358            };
2359        }
2360    };
2361
2362    let probe_result = checkpoint_probe(&conn);
2363    let (run_status, run_age) = record_diagnostic_checkpoint_probe(pool, &probe_result);
2364    let (checkpoint_probe, checkpoint_probe_error) = match probe_result {
2365        Ok(probe) => (Some(probe), None),
2366        Err(e) => (
2367            None,
2368            Some(format!("PRAGMA wal_checkpoint(PASSIVE) failed: {e}")),
2369        ),
2370    };
2371    let checkpoint_pin = checkpoint_pin_diagnostics_for_run(
2372        checkpoint_probe.as_ref(),
2373        checkpoint_probe_error.as_deref(),
2374        run_status,
2375        run_age,
2376    );
2377    let (graph_edge_integrity, graph_edge_integrity_error) = match graph_edge_integrity(&conn) {
2378        Ok(integrity) => (Some(integrity), None),
2379        Err(e) => (
2380            None,
2381            Some(format!("graph-edge integrity query failed: {e}")),
2382        ),
2383    };
2384    let (page_accounting, page_accounting_error) =
2385        split_page_accounting_result(database_page_accounting(&conn));
2386    let (size_composition, size_composition_error) = match database_size_composition(&conn) {
2387        Ok(composition) => (Some(composition), None),
2388        Err(error) => (
2389            None,
2390            Some(format!("database size composition query failed: {error}")),
2391        ),
2392    };
2393    let (fts_segments, fts_segments_error) =
2394        split_fts_segments_result(crate::fts_maintenance::inspect_fts_segments(&conn));
2395
2396    PoolInspection {
2397        checkpoint_probe,
2398        checkpoint_probe_error,
2399        checkpoint_pin,
2400        size_composition,
2401        size_composition_error,
2402        page_accounting,
2403        page_accounting_error,
2404        graph_edge_integrity,
2405        graph_edge_integrity_error,
2406        fts_segments,
2407        fts_segments_error,
2408    }
2409}
2410
2411#[cfg(test)]
2412#[path = "diagnostics_tests.rs"]
2413mod tests;