use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use ignore::WalkBuilder;
use rayon::prelude::*;
use serde::Serialize;
use super::journal::JOURNAL_EXT;
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct WalStats {
#[serde(rename = "type")]
pub r#type: &'static str,
pub total_journals: u64,
pub by_state: WalStateBreakdown,
pub oldest_journal_age_secs: u64,
pub total_size_bytes: u64,
pub by_directory: Vec<WalDirEntry>,
pub auto_heal_recommended: bool,
pub estimated_reclaim_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema)]
pub struct WalStateBreakdown {
pub started: u64,
pub committed: u64,
pub aborted: u64,
pub malformed: u64,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct WalDirEntry {
pub path: String,
pub count: u64,
}
pub fn compute_wal_stats(workspace: &Path) -> Result<WalStats> {
use std::collections::BTreeMap;
let paths = walk_journal_paths(workspace)?;
let workspace = workspace.to_path_buf();
let rows: Vec<(PathBuf, u64, &'static str, u64)> =
if crate::concurrency::should_parallelize(paths.len()) {
paths
.par_iter()
.map(|path| {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let (state, last_unix) =
parse_journal_state(path).unwrap_or(("malformed", 0));
(path.clone(), size, state, last_unix)
})
.collect()
} else {
paths
.iter()
.map(|path| {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let (state, last_unix) =
parse_journal_state(path).unwrap_or(("malformed", 0));
(path.clone(), size, state, last_unix)
})
.collect()
};
let mut total: u64 = 0;
let mut by_state = WalStateBreakdown::default();
let mut oldest_unix: u64 = u64::MAX;
let mut total_size: u64 = 0;
let mut by_dir: BTreeMap<String, u64> = BTreeMap::new();
for (path, size, state, last_unix) in rows {
total += 1;
total_size += size;
let rel_dir = path
.parent()
.and_then(|p| p.strip_prefix(&workspace).ok())
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string());
*by_dir.entry(rel_dir).or_insert(0) += 1;
match state {
"Committed" => by_state.committed += 1,
"Aborted" => by_state.aborted += 1,
"Started" => by_state.started += 1,
_ => by_state.malformed += 1,
}
if state != "malformed" && last_unix < oldest_unix {
oldest_unix = last_unix;
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let oldest_age = if oldest_unix == u64::MAX {
0
} else {
now.saturating_sub(oldest_unix)
};
let mut by_directory: Vec<WalDirEntry> = by_dir
.into_iter()
.map(|(path, count)| WalDirEntry { path, count })
.collect();
by_directory.sort_by(|a, b| b.count.cmp(&a.count));
by_directory.truncate(crate::constants::WAL_STATS_TOP_DIRS);
let auto_heal_recommended = total > crate::constants::WAL_AUTO_HEAL_COUNT_THRESHOLD
|| oldest_age > crate::constants::WAL_AUTO_HEAL_AGE_SECS;
let estimated_reclaim_bytes = if auto_heal_recommended { total_size } else { 0 };
Ok(WalStats {
r#type: "wal_stats",
total_journals: total,
by_state,
oldest_journal_age_secs: oldest_age,
total_size_bytes: total_size,
by_directory,
auto_heal_recommended,
estimated_reclaim_bytes,
})
}
pub fn walk_journal_paths(workspace: &Path) -> Result<Vec<PathBuf>> {
if !workspace.exists() {
return Ok(Vec::new());
}
let mut builder = WalkBuilder::new(workspace);
builder.hidden(false).git_ignore(false);
crate::concurrency::apply_walk_threads(&mut builder, None);
let mut out = crate::concurrency::collect_mapped_parallel(&builder, |entry| {
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
return None;
}
let name = entry.file_name().to_str()?;
if name.ends_with(JOURNAL_EXT) {
Some(entry.path().to_path_buf())
} else {
None
}
});
crate::concurrency::sort_paths_parallel(&mut out);
Ok(out)
}
pub(crate) fn parse_journal_state(path: &Path) -> Option<(&'static str, u64)> {
let content = std::fs::read_to_string(path).ok()?;
let mut state = "malformed";
let mut last_unix: u64 = 0;
for line in content.lines() {
let val: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
state = "malformed";
continue;
}
};
let phase = val.get("phase").and_then(|v| v.as_str()).unwrap_or("");
match phase {
"started" => {
state = "Started";
last_unix = val
.get("started_at_unix")
.and_then(|v| v.as_u64())
.unwrap_or(0);
}
"committed" => {
state = "Committed";
last_unix = val
.get("committed_at_unix")
.and_then(|v| v.as_u64())
.unwrap_or(0);
}
"aborted" => {
state = "Aborted";
last_unix = val
.get("aborted_at_unix")
.and_then(|v| v.as_u64())
.unwrap_or(0);
}
_ => {
state = "malformed";
}
}
}
Some((state, last_unix))
}