use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use rayon::prelude::*;
use serde::Serialize;
use super::journal::{JOURNAL_EXT, JournalEntry, JournalOp};
use super::stats::{
HealScope, parse_journal_state, walk_journal_paths, walk_journal_paths_bounded,
};
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct AutoHealReport {
#[serde(rename = "type")]
pub r#type: &'static str,
pub removed: u64,
pub preserved: u64,
pub dead_owner_removed: u64,
pub malformed: u64,
pub bytes_reclaimed: u64,
pub threshold_secs: u64,
}
pub fn auto_heal_on_startup(
workspace: &Path,
threshold_secs: u64,
max_duration_ms: u64,
) -> Result<AutoHealReport> {
let start = Instant::now();
let paths = walk_journal_paths(workspace)?;
Ok(heal_paths(&paths, threshold_secs, max_duration_ms, start))
}
pub fn auto_heal_scoped(
scopes: &[HealScope],
threshold_secs: u64,
max_duration_ms: u64,
) -> AutoHealReport {
let start = Instant::now();
let deadline = start + Duration::from_millis(max_duration_ms);
let paths = walk_journal_paths_bounded(scopes, deadline);
heal_paths(&paths, threshold_secs, max_duration_ms, start)
}
fn heal_paths(
paths: &[PathBuf],
threshold_secs: u64,
max_duration_ms: u64,
start: Instant,
) -> AutoHealReport {
let mut removed = 0u64;
let mut preserved = 0u64;
let mut malformed = 0u64;
let mut dead_owner_removed = 0u64;
let mut bytes_reclaimed = 0u64;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let chunk = crate::concurrency::effective_threads(None).max(1)
* crate::constants::WAL_HEAL_CHUNK_PER_THREAD;
if crate::concurrency::should_parallelize(paths.len()) {
crate::concurrency::ensure_global_pool();
}
for chunk_paths in paths.chunks(chunk) {
if start.elapsed().as_millis() as u64 > max_duration_ms {
break;
}
let actions: Vec<HealClass> = if crate::concurrency::should_parallelize(chunk_paths.len()) {
chunk_paths
.par_iter()
.map(|path| classify_heal(path, now, threshold_secs))
.collect()
} else {
chunk_paths
.iter()
.map(|path| classify_heal(path, now, threshold_secs))
.collect()
};
let to_remove: Vec<(PathBuf, u64, HealReason)> = actions
.into_iter()
.filter_map(|a| match a {
HealClass::Remove { path, size, reason } => Some((path, size, reason)),
HealClass::Preserve => {
preserved += 1;
None
}
HealClass::Malformed => {
malformed += 1;
None
}
})
.collect();
if start.elapsed().as_millis() as u64 > max_duration_ms {
preserved += to_remove.len() as u64;
break;
}
let outcomes: Vec<(bool, u64, HealReason)> =
if crate::concurrency::should_parallelize(to_remove.len()) {
to_remove
.par_iter()
.map(|(path, size, reason)| match std::fs::remove_file(path) {
Ok(()) => (true, *size, *reason),
Err(_) => (false, 0, *reason),
})
.collect()
} else {
to_remove
.iter()
.map(|(path, size, reason)| match std::fs::remove_file(path) {
Ok(()) => (true, *size, *reason),
Err(_) => (false, 0, *reason),
})
.collect()
};
for (ok, size, reason) in outcomes {
if ok {
removed += 1;
if matches!(reason, HealReason::DeadOwner) {
dead_owner_removed += 1;
}
bytes_reclaimed += size;
} else {
preserved += 1;
}
}
}
AutoHealReport {
r#type: "wal_heal",
removed,
preserved,
dead_owner_removed,
malformed,
bytes_reclaimed,
threshold_secs,
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum HealReason {
StaleTerminal,
DeadOwner,
}
pub(crate) enum HealClass {
Remove {
path: PathBuf,
size: u64,
reason: HealReason,
},
Preserve,
Malformed,
}
#[cfg(unix)]
fn owner_is_alive(pid: u32) -> bool {
use nix::sys::signal::kill;
use nix::unistd::Pid;
if pid == 0 {
return true;
}
match kill(Pid::from_raw(pid as i32), None) {
Ok(()) | Err(nix::Error::EPERM) => true,
Err(_) => false,
}
}
#[cfg(not(unix))]
fn owner_is_alive(_pid: u32) -> bool {
true
}
pub(crate) fn classify_heal(path: &Path, now: u64, threshold_secs: u64) -> HealClass {
let (state, last_unix) = match parse_journal_state(path) {
Some(s) => s,
None => return HealClass::Malformed,
};
match state {
"Committed" | "Aborted" => {
let age = now.saturating_sub(last_unix);
if age > threshold_secs {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
HealClass::Remove {
path: path.to_path_buf(),
size,
reason: HealReason::StaleTerminal,
}
} else {
HealClass::Preserve
}
}
"Started" => {
let dead = parse_orphan(path)
.ok()
.flatten()
.is_some_and(|report| !owner_is_alive(report.pid));
if dead {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
HealClass::Remove {
path: path.to_path_buf(),
size,
reason: HealReason::DeadOwner,
}
} else {
HealClass::Preserve
}
}
_ => HealClass::Malformed,
}
}
#[derive(Debug, Clone, Serialize)]
#[allow(clippy::struct_field_names)]
pub struct OrphanJournalReport {
pub journal_path: String,
pub target: String,
pub op_id: String,
pub op: JournalOp,
pub expected_new_checksum: String,
pub checksum_before: Option<String>,
pub started_at_unix: u64,
pub pid: u32,
}
pub fn recover_orphan_journals(dir: &Path) -> Result<Vec<OrphanJournalReport>> {
if !dir.exists() {
return Ok(Vec::new());
}
let entries =
fs::read_dir(dir).with_context(|| format!("failed to read dir {}", dir.display()))?;
let journal_paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|path| {
path.is_file()
&& path
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|name| {
name.starts_with(crate::constants::WAL_JOURNAL_PREFIX)
&& name.ends_with(JOURNAL_EXT)
})
})
.collect();
if crate::concurrency::should_parallelize(journal_paths.len()) {
crate::concurrency::ensure_global_pool();
}
let parsed: Vec<Result<Option<OrphanJournalReport>>> =
if crate::concurrency::should_parallelize(journal_paths.len()) {
journal_paths
.par_iter()
.map(|path| parse_orphan(path))
.collect()
} else {
journal_paths
.iter()
.map(|path| parse_orphan(path))
.collect()
};
let mut reports = Vec::new();
for (path, result) in journal_paths.iter().zip(parsed) {
match result {
Ok(Some(report)) => reports.push(report),
Ok(None) => {
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to parse journal");
}
}
}
Ok(reports)
}
pub(crate) fn parse_orphan(path: &Path) -> Result<Option<OrphanJournalReport>> {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read journal {}", path.display()))?;
let mut last_started: Option<JournalEntry> = None;
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let entry: JournalEntry = serde_json::from_str(line)
.with_context(|| format!("invalid JSON in journal {}", path.display()))?;
match &entry {
JournalEntry::Started { .. } => last_started = Some(entry),
JournalEntry::Committed { .. } | JournalEntry::Aborted { .. } => {
last_started = None;
}
}
}
let Some(last) = last_started else {
return Ok(None);
};
let JournalEntry::Started {
op_id,
op,
target,
checksum_before,
checksum_after,
pid,
started_at_unix,
} = last
else {
return Ok(None);
};
Ok(Some(OrphanJournalReport {
journal_path: path.display().to_string(),
target,
op_id,
op,
expected_new_checksum: checksum_after,
checksum_before,
started_at_unix,
pid,
}))
}