add cache inspect/vacuum and a diagnose self-check

A corrupt database or an oversized cache previously left the user with no
answer but "delete the state directory and re-sync", which for a large
account means re-downloading everything to fix a problem that may have been
a few stale rows.

pdfs diagnose reports on paths, keyring, daemon, and mountpoint, and exits
non-zero if any check fails so it works in a health check. Every check is
written to run with no daemon, because that is the situation it gets run in:
a stopped daemon is reported as a finding rather than aborting the report.
Writability is tested by writing, since permission bits say nothing about a
full disk or a read-only remount.

pdfs cache inspect reports database size, reclaimable space, per-table row
counts, and cache usage; --deep adds SQLite's integrity check, opt-in because
it reads every page. The report distinguishes "checked and clean" from "not
checked" rather than letting an empty problem list imply a clean bill of
health it never gave.

pdfs cache vacuum checkpoints the WAL and compacts the database.

Deliberately no orphaned-cache-row pruning: ContentCache::reconcile already
rebuilds the index from what is on disk at every open, so a second pass here
would duplicate it against a worse view of the truth. The WAL is likewise
left to journal_size_limit, which already caps it -- a periodic checkpoint
would paper over a problem that no longer exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
This commit is contained in:
nvrlandClaude Opus 4.8 committed 2026-07-20 19:54:42 +02:00
1 parent 3ade754ea2
commit 471f57b8e1
6 files changed
+621

No files matched your search

+241
View File
@@ -235,6 +235,34 @@ enum Command {
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
/// Inspect and maintain the local metadata database and content cache.
Cache {
#[command(subcommand)]
action: CacheCmd,
},
/// Check this installation for problems and print a report.
///
/// Runs without a daemon on purpose: the state worth diagnosing is usually
/// the state where the daemon will not start.
Diagnose,
}
#[derive(Subcommand)]
enum CacheCmd {
/// Report database size, row counts, and cache usage.
Inspect {
/// Also run SQLite's integrity check. Reads every page, so it is slow
/// on a large database — worth it when you suspect corruption.
#[arg(long)]
deep: bool,
},
/// Checkpoint the write-ahead log and compact the database.
///
/// Takes a write lock for the duration and needs room for a second copy of
/// the database while it runs.
Vacuum,
/// Delete cached file content, keeping pinned files.
Clear,
}
/// Which collection a share entry lives in, for `share-role` / `unshare`.
@@ -426,6 +454,8 @@ fn main() -> Result<()> {
Command::Invitations { action } => cmd_invitations(action),
Command::Bookmarks { action } => cmd_bookmarks(action),
Command::Activity { limit } => cmd_activity(limit),
Command::Cache { action } => cmd_cache(action),
Command::Diagnose => cmd_diagnose(),
}
}
@@ -1310,6 +1340,217 @@ fn path_arg(path: &Path) -> Result<String> {
.ok_or_else(|| anyhow!("path is not valid UTF-8"))
}
/// Format a byte count for a report line.
fn human_bytes(bytes: u64) -> String {
const UNITS: [(&str, f64); 4] = [
("GiB", 1_073_741_824.0),
("MiB", 1_048_576.0),
("KiB", 1024.0),
("B", 1.0),
];
let b = bytes as f64;
for (unit, scale) in UNITS {
if b >= scale {
return format!("{:.1} {unit}", b / scale);
}
}
"0 B".to_string()
}
fn cmd_cache(action: CacheCmd) -> Result<()> {
match action {
CacheCmd::Inspect { deep } => cmd_cache_inspect(deep),
CacheCmd::Vacuum => match control_request(CtlRequest::CacheVacuum)? {
CtlResponse::Ok { message } => {
println!("{message}");
Ok(())
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
CacheCmd::Clear => match control_request(CtlRequest::PurgeCache)? {
CtlResponse::Ok { message } => {
println!("{message}");
Ok(())
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
}
}
fn cmd_cache_inspect(deep: bool) -> Result<()> {
match control_request(CtlRequest::CacheInspect { deep })? {
CtlResponse::CacheReport {
schema_version,
db_bytes,
db_reclaimable_bytes,
tables,
cache_used,
cache_budget,
integrity_problems,
integrity_checked,
} => {
println!("Database schema v{schema_version}, {}", human_bytes(db_bytes));
if db_reclaimable_bytes > 0 {
println!(
" {} reclaimable — run `pdfs cache vacuum`",
human_bytes(db_reclaimable_bytes)
);
}
let budget = if cache_budget == 0 {
"unlimited".to_string()
} else {
human_bytes(cache_budget)
};
println!("Content {} cached of {budget}", human_bytes(cache_used));
println!("\nRows");
for (name, count) in tables {
println!(" {name:<16} {count:>10}");
}
if integrity_checked {
if integrity_problems.is_empty() {
println!("\nIntegrity ok");
} else {
println!("\nIntegrity {} problem(s):", integrity_problems.len());
for problem in integrity_problems {
println!(" {problem}");
}
}
} else {
println!("\nIntegrity not checked (pass --deep)");
}
Ok(())
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
}
/// One line of the `diagnose` report.
fn check(label: &str, ok: bool, detail: impl AsRef<str>) -> bool {
let mark = if ok { "ok " } else { "FAIL" };
let detail = detail.as_ref();
if detail.is_empty() {
println!("[{mark}] {label}");
} else {
println!("[{mark}] {label}: {detail}");
}
ok
}
/// Self-check the installation, reporting each finding and exiting non-zero if
/// any check failed.
///
/// Every check is written to work with no daemon running, because that is the
/// situation a user runs this in. A missing daemon is reported as a finding,
/// not an error that aborts the rest of the report.
fn cmd_diagnose() -> Result<()> {
let mut all_ok = true;
let dirs = AppDirs::new().context("resolve application directories")?;
let config = dirs.load_config();
println!("Paths");
let state = dirs.state_dir();
all_ok &= check(
" state dir",
state.is_dir(),
format!("{}", state.display()),
);
let cache_dir = dirs.cache_dir();
all_ok &= check(
" cache dir",
cache_dir.is_dir(),
format!("{}", cache_dir.display()),
);
// Writability is checked by actually writing: permission bits do not
// account for a full disk, a read-only remount, or an immutable flag.
let probe = state.join(".pdfs-diagnose-probe");
let writable = std::fs::write(&probe, b"probe").is_ok();
let _ = std::fs::remove_file(&probe);
all_ok &= check(" state dir writable", writable, "");
let db_path = dirs.db_path();
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
all_ok &= check(
" database",
db_path.is_file(),
format!("{} ({})", db_path.display(), human_bytes(db_size)),
);
println!("\nAccount");
match pdfs_core::auth::load() {
Ok(session) => {
all_ok &= check(" keyring session", true, session.username);
}
Err(e) => {
// A locked keyring and an absent login look the same from here, so
// the detail carries the distinction rather than the verdict.
all_ok &= check(" keyring session", false, format!("{e}"));
}
}
println!("\nDaemon");
let socket = dirs.control_socket();
let socket_exists = socket.exists();
check(
" control socket",
socket_exists,
format!("{}", socket.display()),
);
// A stale socket file with no listener is the common failure, so the reply
// is what decides this rather than the file's existence.
match control_request(CtlRequest::Status) {
Ok(CtlResponse::Status {
mountpoint,
online,
pending_uploads,
pending_changes,
..
}) => {
check(" daemon responding", true, "");
check(" mounted at", true, mountpoint);
check(" network", online, if online { "" } else { "offline" });
let pending = pending_uploads + pending_changes;
check(
" queued writes",
true,
if pending == 0 {
"none".to_string()
} else {
format!("{pending} waiting to upload")
},
);
}
Ok(other) => {
all_ok &= check(" daemon responding", false, format!("unexpected: {other:?}"));
}
Err(e) => {
// Not a failure of the installation: a stopped daemon is a normal
// state, and the rest of the report is still worth printing.
check(" daemon responding", false, format!("{e:#}"));
println!(" (start it with `systemctl --user start pdfs.service`)");
}
}
let mountpoint = dirs.resolved_mountpoint(&config);
check(
" mountpoint exists",
mountpoint.is_dir(),
format!("{}", mountpoint.display()),
);
if all_ok {
println!("\nNo problems found.");
Ok(())
} else {
bail!("one or more checks failed; see the report above")
}
}
/// Send one request to the running mount daemon's control socket and read its
/// reply. Errors if no daemon is listening.
fn control_request(req: CtlRequest) -> Result<CtlResponse> {