use std::{
collections::BTreeSet,
fmt, fs,
path::{Path, PathBuf},
time::SystemTime,
};
use anyhow::Result;
use clap::Args;
use log::debug;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
use crate::cli::{
StoreFlags, bytes, confirm,
db::{PimdirDangling, PimdirRefcountDrift},
};
const SHOWN: usize = 20;
#[derive(Debug, Args)]
pub struct CheckCommand {
#[arg(long)]
pub fix: bool,
#[arg(long, value_name = "DURATION", default_value = "1h")]
pub grace: humantime::Duration,
#[arg(long, short = 'y')]
pub yes: bool,
}
impl CheckCommand {
pub fn execute(self, printer: &mut impl Printer, store: &StoreFlags) -> Result<()> {
let db = store.db()?;
let indexed = db.hashes()?;
let on_disk = blob_files(&store.dir().join("objects"))?;
let mut orphans = Vec::new();
let mut orphan_bytes = 0;
for blob in &on_disk {
if !indexed.contains(&blob.hash) {
orphan_bytes += blob.size;
orphans.push(blob.clone());
}
}
let names: BTreeSet<&String> = on_disk.iter().map(|blob| &blob.hash).collect();
let missing: Vec<String> = indexed
.iter()
.filter(|hash| !names.contains(hash))
.cloned()
.collect();
let drift = db.refcount_drift()?;
let dangling = db.dangling()?;
let mut removed = 0;
let mut reclaimed = 0;
if self.fix && !orphans.is_empty() {
let cutoff = SystemTime::now()
.checked_sub(*self.grace)
.unwrap_or(SystemTime::UNIX_EPOCH);
let stale: Vec<&BlobFile> = orphans
.iter()
.filter(|blob| blob.modified.is_none_or(|modified| modified < cutoff))
.collect();
if stale.is_empty() {
debug!("every orphan blob is younger than the grace period, nothing to delete");
} else {
let total: u64 = stale.iter().map(|blob| blob.size).sum();
confirm(
printer,
self.yes,
&format!(
"Delete {} orphan blob file(s), reclaiming {}?",
stale.len(),
bytes(total)
),
)?;
for blob in stale {
fs::remove_file(&blob.path)?;
removed += 1;
reclaimed += blob.size;
}
}
}
printer.out(CheckOutput {
orphans: orphans
.into_iter()
.map(|blob| OrphanBlob {
hash: blob.hash,
size: blob.size,
path: blob.path,
})
.collect(),
orphan_bytes,
missing,
drift,
dangling,
removed,
reclaimed,
})
}
}
#[derive(Clone, Debug)]
struct BlobFile {
hash: String,
path: PathBuf,
size: u64,
modified: Option<SystemTime>,
}
fn blob_files(root: &Path) -> Result<Vec<BlobFile>> {
let mut files = Vec::new();
if !root.is_dir() {
return Ok(files);
}
walk(root, &mut files)?;
Ok(files)
}
fn walk(dir: &Path, files: &mut Vec<BlobFile>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let metadata = entry.metadata()?;
if metadata.is_dir() {
walk(&entry.path(), files)?;
} else if metadata.is_file() {
files.push(BlobFile {
hash: name,
path: entry.path(),
size: metadata.len(),
modified: metadata.modified().ok(),
});
}
}
Ok(())
}
#[derive(Debug, Serialize)]
pub struct OrphanBlob {
pub hash: String,
pub size: u64,
pub path: PathBuf,
}
#[derive(Debug, Serialize)]
pub struct CheckOutput {
pub orphans: Vec<OrphanBlob>,
pub orphan_bytes: u64,
pub missing: Vec<String>,
pub drift: Vec<PimdirRefcountDrift>,
pub dangling: Vec<PimdirDangling>,
pub removed: usize,
pub reclaimed: u64,
}
impl CheckOutput {
fn problems(&self) -> usize {
self.orphans.len() + self.missing.len() + self.drift.len() + self.dangling.len()
}
}
impl fmt::Display for CheckOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.problems() == 0 {
return writeln!(
f,
"This store is consistent: no orphan blob, no refcount drift, no dangling row"
);
}
if !self.orphans.is_empty() {
writeln!(
f,
"{} orphan blob file(s), {} not referenced by any object row:",
self.orphans.len(),
bytes(self.orphan_bytes)
)?;
for orphan in self.orphans.iter().take(SHOWN) {
writeln!(f, " - {} ({})", orphan.hash, bytes(orphan.size))?;
}
more(f, self.orphans.len())?;
if self.removed == 0 {
writeln!(f, " Reclaim them with `pimdir check --fix`")?;
}
}
if !self.missing.is_empty() {
writeln!(
f,
"{} object row(s) whose body is missing from the blob store:",
self.missing.len()
)?;
for hash in self.missing.iter().take(SHOWN) {
writeln!(f, " - {hash}")?;
}
more(f, self.missing.len())?;
}
if !self.drift.is_empty() {
writeln!(f, "{} object(s) with a drifted refcount:", self.drift.len())?;
for drift in self.drift.iter().take(SHOWN) {
writeln!(
f,
" - {}: stored {}, references {}",
drift.hash, drift.stored, drift.expected
)?;
}
more(f, self.drift.len())?;
}
if !self.dangling.is_empty() {
writeln!(f, "{} dangling row(s):", self.dangling.len())?;
for dangling in self.dangling.iter().take(SHOWN) {
writeln!(
f,
" - {} {} points at a missing {}",
dangling.kind, dangling.row, dangling.target
)?;
}
more(f, self.dangling.len())?;
}
if self.removed > 0 {
writeln!(
f,
"Deleted {} orphan blob file(s), reclaiming {}",
self.removed,
bytes(self.reclaimed)
)?;
}
Ok(())
}
}
fn more(f: &mut fmt::Formatter<'_>, total: usize) -> fmt::Result {
if total > SHOWN {
writeln!(
f,
" … and {} more (use --json for the full list)",
total - SHOWN
)?;
}
Ok(())
}