Bound SQLite integrity checks for diagnostic attachments (#40688)

## Why

Diagnostic uploads skip the doctor attachment if report generation exceeds its
overall timeout. SQLite integrity scans can be delayed by large databases or
lock contention.

## What changed

- Give each database integrity scan a one-second deadline when collecting a
  diagnostic attachment.
- Interrupt scans and lock waits at the deadline, preserve any corruption rows
  already returned, and report an incomplete clean scan as a warning.
- Keep direct `codex doctor` integrity checks unbounded.

## Testing

- Cover interrupted scans, lock waits, retries without a deadline, and
  corruption results returned before interruption.

GitOrigin-RevId: 4817fb459ac53953669a9ba5f03397a6fc82c72e
This commit is contained in:
chess
2026-08-25 20:09:55 +00:00
committed by copyberry
parent 94967e03e5
commit 32ce703a80
7 changed files with 194 additions and 53 deletions
+49 -36
View File
@@ -162,7 +162,7 @@ const NARROW_TERMINAL_ROWS: u16 = 24;
/// Options for building a local Codex diagnostic report.
///
/// The command always runs the full bounded diagnostic set. Human output includes
/// The command always runs the full diagnostic set. Human output includes
/// detailed diagnostics by default; --summary keeps the terminal output compact.
#[derive(Debug, Parser)]
pub struct DoctorCommand {
@@ -170,6 +170,10 @@ pub struct DoctorCommand {
#[arg(long, default_value_t = false)]
json: bool,
/// Limit database integrity scans when collecting a feedback attachment.
#[arg(long, hide = true, default_value_t = false)]
feedback: bool,
/// Only show grouped check rows and the final count summary.
#[arg(long, default_value_t = false)]
summary: bool,
@@ -452,7 +456,7 @@ async fn build_report(
terminal_title_check(config)
})
},
run_async_check("state", progress.clone(), state_check(config)),
run_async_check("state", progress.clone(), state_check(config, command)),
run_async_check(
"thread inventory",
progress.clone(),
@@ -2179,35 +2183,36 @@ fn non_empty_trimmed(value: String) -> Option<String> {
if value.is_empty() { None } else { Some(value) }
}
async fn state_check(config: &Config) -> DoctorCheck {
async fn state_check(config: &Config, command: &DoctorCommand) -> DoctorCheck {
let mut details = Vec::new();
path_readiness(&mut details, "CODEX_HOME", &config.codex_home);
path_readiness(&mut details, "log dir", &config.log_dir);
path_readiness(&mut details, "sqlite home", config.sqlite_config().home());
let mut integrity_failures = Vec::new();
let mut status = CheckStatus::Ok;
for db in config.sqlite_config().runtime_db_paths() {
path_readiness(&mut details, db.label, &db.path);
sqlite_integrity_detail(
config.sqlite_config(),
&mut details,
&mut integrity_failures,
db.label,
&db.path,
)
.await;
// Feedback collection gives each database its own budget; direct runs scan fully.
let deadline = command
.feedback
.then(|| Instant::now() + Duration::from_secs(1));
status = status.max(
sqlite_integrity_detail(
config.sqlite_config(),
&mut details,
db.label,
&db.path,
deadline,
)
.await,
);
}
rollout_stats_details(&mut details, &config.codex_home);
standalone_release_cache_details(&mut details);
let status = if integrity_failures.is_empty() {
CheckStatus::Ok
} else {
CheckStatus::Fail
};
let summary = if status == CheckStatus::Ok {
"state paths and databases are inspectable"
} else {
"state database integrity check failed"
let summary = match status {
CheckStatus::Ok => "state paths and databases are inspectable",
CheckStatus::Warning => "some database integrity checks exceeded their time limit",
CheckStatus::Fail => "state database integrity check failed",
};
let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details);
if status == CheckStatus::Fail {
@@ -2221,29 +2226,37 @@ async fn state_check(config: &Config) -> DoctorCheck {
async fn sqlite_integrity_detail(
sqlite: &codex_state::SqliteConfig,
details: &mut Vec<String>,
integrity_failures: &mut Vec<String>,
label: &str,
path: &Path,
) {
deadline: Option<Instant>,
) -> CheckStatus {
if !path.is_file() {
details.push(format!("{label} integrity: skipped (missing)"));
return;
return CheckStatus::Ok;
}
match codex_state::sqlite_integrity_check(sqlite, path).await {
Ok(rows) if rows.iter().all(|row| row == "ok") => {
details.push(format!("{label} integrity: ok"));
}
Ok(rows) => {
let message = format!("{label} integrity: {}", rows.join("; "));
integrity_failures.push(message.clone());
details.push(message);
}
let (rows, timed_out) = match codex_state::sqlite_integrity_check(sqlite, path, deadline).await
{
Ok(codex_state::SqliteIntegrityCheck::Complete(rows)) => (rows, false),
Ok(codex_state::SqliteIntegrityCheck::TimedOut(rows)) => (rows, true),
Err(err) => {
let message = format!("{label} integrity: {err}");
integrity_failures.push(message.clone());
details.push(message);
details.push(format!("{label} integrity: {err}"));
return CheckStatus::Fail;
}
};
if timed_out {
details.push(format!(
"{label} integrity: incomplete (1 second time limit)"
));
}
if rows.iter().any(|row| row != "ok") {
details.push(format!("{label} integrity: {}", rows.join("; ")));
CheckStatus::Fail
} else if timed_out {
CheckStatus::Warning
} else {
details.push(format!("{label} integrity: ok"));
CheckStatus::Ok
}
}