use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::fs::OpenOptions;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use fs2::FileExt;
use serde::Serialize;
use crate::error::ForgeError;
use crate::fsutil::{create_dir_all, lock_exclusive_cancellable, lock_shared_cancellable};
use crate::paths::app_home;
use crate::state::journal::load_pending;
use crate::state::read_registry_document;
const CARGO_TARGET_CAPACITY_BYTES: u64 = 8 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct CacheGcReport {
pub(crate) removed: Vec<PathBuf>,
pub(crate) retained: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct CacheStatus {
pub(crate) root: PathBuf,
pub(crate) files: u64,
pub(crate) bytes: u64,
pub(crate) allocated_bytes: u64,
pub(crate) reclaimable_bytes: u64,
pub(crate) oldest_modified: Option<u64>,
pub(crate) newest_modified: Option<u64>,
pub(crate) classes: BTreeMap<String, CacheClassStatus>,
pub(crate) artifact_count: u64,
pub(crate) download_count: u64,
pub(crate) quarantine_count: u64,
pub(crate) cargo_source_caches: u64,
pub(crate) cargo_work_shards: u64,
pub(crate) pending_journal_count: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub(crate) struct CacheClassStatus {
pub(crate) files: u64,
pub(crate) generations: u64,
pub(crate) logical_bytes: u64,
pub(crate) allocated_bytes: u64,
pub(crate) oldest_modified: Option<u64>,
pub(crate) newest_modified: Option<u64>,
}
pub(crate) fn status() -> Result<CacheStatus, ForgeError> {
let home = app_home();
let root = home.clone();
let cache = home.join("cache");
let work = home.join("work");
let class_roots = BTreeMap::from([
("downloads".to_string(), vec![cache.join("downloads")]),
(
"sources".to_string(),
vec![cache.join("cargo").join("sources"), home.join("sources")],
),
(
"staging".to_string(),
cargo_work_category_roots(&work.join("cargo"), "staging")?,
),
(
"targets".to_string(),
cargo_work_category_roots(&work.join("cargo"), "targets")?,
),
("artifacts".to_string(), vec![home.join("artifacts")]),
("quarantine".to_string(), vec![cache.join("quarantine")]),
]);
let mut classes = BTreeMap::new();
for (name, roots) in class_roots {
classes.insert(name, class_status(&roots)?);
}
let total = class_status(std::slice::from_ref(&home))?;
let files = total.files;
let bytes = total.logical_bytes;
let allocated_bytes = total.allocated_bytes;
let reclaimable_bytes = ["staging", "quarantine"]
.into_iter()
.filter_map(|name| classes.get(name))
.map(|class| class.logical_bytes)
.sum();
let artifact_count = directory_entries(&home.join("artifacts"))?;
let download_count = directory_entries(&cache.join("downloads"))?;
let quarantine_count = directory_entries(&cache.join("quarantine"))?;
let cargo_source_caches = directory_entries(&cache.join("cargo").join("sources"))?;
let cargo_work_shards = cargo_work_shards(&home.join("work").join("cargo"))?;
Ok(CacheStatus {
root,
files,
bytes,
allocated_bytes,
reclaimable_bytes,
oldest_modified: total.oldest_modified,
newest_modified: total.newest_modified,
classes,
artifact_count,
download_count,
quarantine_count,
cargo_source_caches,
cargo_work_shards,
pending_journal_count: load_pending()?.len(),
})
}
fn cargo_work_category_roots(root: &Path, category: &str) -> Result<Vec<PathBuf>, ForgeError> {
Ok(read_directories(root)?
.into_iter()
.map(|component| component.join(category))
.collect())
}
fn class_status(roots: &[PathBuf]) -> Result<CacheClassStatus, ForgeError> {
let mut status = CacheClassStatus::default();
for root in roots {
status.generations = status.generations.saturating_add(directory_entries(root)?);
accumulate_usage(root, &mut status)?;
}
Ok(status)
}
fn accumulate_usage(root: &Path, status: &mut CacheClassStatus) -> Result<(), ForgeError> {
if !root.exists() {
return Ok(());
}
let metadata = fs::symlink_metadata(root).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
if metadata.is_file() {
status.files = status.files.saturating_add(1);
status.logical_bytes = status.logical_bytes.saturating_add(metadata.len());
status.allocated_bytes = status
.allocated_bytes
.saturating_add(allocated_size(&metadata));
if let Ok(modified) = metadata.modified().and_then(|time| {
time.duration_since(SystemTime::UNIX_EPOCH)
.map_err(std::io::Error::other)
}) {
let seconds = modified.as_secs();
status.oldest_modified = Some(
status
.oldest_modified
.map_or(seconds, |old| old.min(seconds)),
);
status.newest_modified = Some(
status
.newest_modified
.map_or(seconds, |new| new.max(seconds)),
);
}
return Ok(());
}
if metadata.is_dir() {
for entry in fs::read_dir(root).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
accumulate_usage(&entry.path(), status)?;
}
}
Ok(())
}
#[cfg(unix)]
fn allocated_size(metadata: &fs::Metadata) -> u64 {
metadata.blocks().saturating_mul(512)
}
#[cfg(not(unix))]
fn allocated_size(metadata: &fs::Metadata) -> u64 {
metadata.len()
}
pub(crate) fn garbage_collect(max_age: Duration) -> Result<CacheGcReport, ForgeError> {
collect(max_age, false)
}
pub(crate) fn garbage_collect_preview(max_age: Duration) -> Result<CacheGcReport, ForgeError> {
collect(max_age, true)
}
pub(crate) struct CacheUsageLease {
file: std::fs::File,
}
pub(crate) fn acquire_usage_lease() -> Result<CacheUsageLease, ForgeError> {
acquire_cache_lease(false)
}
fn acquire_cache_lease(exclusive: bool) -> Result<CacheUsageLease, ForgeError> {
let directory = app_home().join("locks");
create_dir_all(&directory)?;
let path = directory.join("cache-lifecycle.lock");
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
if exclusive {
lock_exclusive_cancellable(&file, &path, "cache lifecycle exclusive lock")?;
} else {
lock_shared_cancellable(&file, &path, "cache lifecycle shared lock")?;
}
Ok(CacheUsageLease { file })
}
impl Drop for CacheUsageLease {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
fn collect(max_age: Duration, preview: bool) -> Result<CacheGcReport, ForgeError> {
let _lease = acquire_cache_lease(true)?;
let registry = read_registry_document()?;
let mut referenced = registry
.entries
.iter()
.flat_map(|entry| {
entry
.artifact_id
.iter()
.chain(entry.previous_artifact_id.iter())
})
.cloned()
.collect::<BTreeSet<_>>();
for checkpoint in load_pending()? {
referenced.extend(checkpoint.artifact_id);
referenced.extend(checkpoint.previous_artifact_id);
}
let home = app_home();
let mut report = collect_unreferenced(&home.join("artifacts"), &referenced, max_age, preview)?;
merge_report(
&mut report,
collect_unreferenced(
&home.join("cache").join("downloads"),
&BTreeSet::new(),
max_age,
preview,
)?,
);
merge_report(
&mut report,
collect_unreferenced(
&home.join("cache").join("quarantine"),
&BTreeSet::new(),
max_age,
preview,
)?,
);
merge_report(
&mut report,
collect_unreferenced(
&home.join("cache").join("cargo").join("sources"),
&BTreeSet::new(),
max_age,
preview,
)?,
);
merge_report(&mut report, collect_cargo_work(&home, max_age, preview)?);
report.removed.sort();
report.retained.sort();
Ok(report)
}
fn merge_report(report: &mut CacheGcReport, next: CacheGcReport) {
report.removed.extend(next.removed);
report.retained.extend(next.retained);
}
fn collect_cargo_work(
home: &Path,
max_age: Duration,
preview: bool,
) -> Result<CacheGcReport, ForgeError> {
let root = home.join("work").join("cargo");
let mut report = CacheGcReport {
removed: Vec::new(),
retained: Vec::new(),
};
if !root.is_dir() {
return Ok(report);
}
for component in read_directories(&root)? {
for category in ["staging", "targets"] {
let category_root = component.join(category);
for shard in read_directories(&category_root)? {
let fingerprint = shard
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
let age = path_age(&shard);
let active = cargo_work_locked(home, fingerprint)?;
if active || age < max_age {
report.retained.push(shard);
} else {
if !preview {
fs::remove_dir_all(&shard).map_err(|source| ForgeError::Io {
path: shard.clone(),
source,
})?;
}
report.removed.push(shard);
}
}
}
}
enforce_target_capacity(home, &root, preview, &mut report)?;
Ok(report)
}
fn enforce_target_capacity(
home: &Path,
root: &Path,
preview: bool,
report: &mut CacheGcReport,
) -> Result<(), ForgeError> {
let mut shards = Vec::new();
let mut total = 0_u64;
for component in read_directories(root)? {
for shard in read_directories(&component.join("targets"))? {
if report.removed.contains(&shard) {
continue;
}
let fingerprint = shard
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
let active = cargo_work_locked(home, fingerprint)?;
let bytes = tree_usage(&shard)?.1;
total = total.saturating_add(bytes);
let modified = shard
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
shards.push((modified, shard, bytes, active));
}
}
shards.sort_by_key(|(modified, _, _, _)| *modified);
for (_, shard, bytes, active) in shards {
if total <= CARGO_TARGET_CAPACITY_BYTES {
break;
}
if active {
continue;
}
if !preview {
fs::remove_dir_all(&shard).map_err(|source| ForgeError::Io {
path: shard.clone(),
source,
})?;
}
report.retained.retain(|path| path != &shard);
report.removed.push(shard);
total = total.saturating_sub(bytes);
}
Ok(())
}
fn cargo_work_locked(home: &Path, fingerprint: &str) -> Result<bool, ForgeError> {
let path = home
.join("locks")
.join("cargo-work")
.join(format!("{fingerprint}.lock"));
if !path.is_file() {
return Ok(false);
}
let file = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
match file.try_lock_exclusive() {
Ok(()) => {
let _ = FileExt::unlock(&file);
Ok(false)
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(true),
Err(source) => Err(ForgeError::Io { path, source }),
}
}
fn read_directories(root: &Path) -> Result<Vec<PathBuf>, ForgeError> {
if !root.is_dir() {
return Ok(Vec::new());
}
fs::read_dir(root)
.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?
.filter_map(|entry| match entry {
Ok(entry) if entry.path().is_dir() => Some(Ok(entry.path())),
Ok(_) => None,
Err(source) => Some(Err(ForgeError::Io {
path: root.to_path_buf(),
source,
})),
})
.collect()
}
fn path_age(path: &Path) -> Duration {
path.metadata()
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.unwrap_or_default()
}
fn cargo_work_shards(root: &Path) -> Result<u64, ForgeError> {
let mut count = 0;
for component in read_directories(root)? {
for category in ["staging", "targets"] {
count += read_directories(&component.join(category))?.len() as u64;
}
}
Ok(count)
}
fn collect_unreferenced(
root: &Path,
referenced: &BTreeSet<String>,
max_age: Duration,
preview: bool,
) -> Result<CacheGcReport, ForgeError> {
let mut report = CacheGcReport {
removed: Vec::new(),
retained: Vec::new(),
};
if !root.is_dir() {
return Ok(report);
}
for entry in fs::read_dir(root).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
let path = entry.path();
let id = entry.file_name().to_string_lossy().to_string();
let age = path_age(&path);
if referenced.contains(&id) || age < max_age {
report.retained.push(path);
} else {
if !preview {
if path.is_dir() {
fs::remove_dir_all(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
} else {
fs::remove_file(&path).map_err(|source| ForgeError::Io {
path: path.clone(),
source,
})?;
}
}
report.removed.push(path);
}
}
Ok(report)
}
fn tree_usage(root: &Path) -> Result<(u64, u64), ForgeError> {
if !root.is_dir() {
return Ok((0, 0));
}
let mut files = 0;
let mut bytes = 0;
for entry in fs::read_dir(root).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})? {
let entry = entry.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?;
let metadata = entry.metadata().map_err(|source| ForgeError::Io {
path: entry.path(),
source,
})?;
if metadata.is_dir() {
let nested = tree_usage(&entry.path())?;
files += nested.0;
bytes += nested.1;
} else if metadata.is_file() {
files += 1;
bytes += metadata.len();
}
}
Ok((files, bytes))
}
fn directory_entries(root: &Path) -> Result<u64, ForgeError> {
if !root.is_dir() {
return Ok(0);
}
fs::read_dir(root)
.map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})?
.try_fold(0_u64, |count, entry| {
entry.map(|_| count + 1).map_err(|source| ForgeError::Io {
path: root.to_path_buf(),
source,
})
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::fs;
use std::time::Duration;
use crate::state::cache::{class_status, collect_unreferenced};
use crate::util::now_secs;
#[test]
fn gc_never_removes_referenced_artifact() {
let root = std::env::temp_dir().join(format!("bot-forge-gc-{}", std::process::id()));
fs::create_dir_all(root.join("keep")).unwrap();
fs::create_dir_all(root.join("remove")).unwrap();
let report = collect_unreferenced(
&root,
&BTreeSet::from(["keep".into()]),
Duration::ZERO,
false,
)
.unwrap();
assert!(root.join("keep").is_dir());
assert!(!root.join("remove").exists());
assert_eq!(report.removed, vec![root.join("remove")]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn total_usage_includes_managed_state_outside_cache_classes() {
let root = std::env::temp_dir().join(format!(
"bot-forge-cache-total-{}-{}",
std::process::id(),
now_secs()
));
fs::create_dir_all(root.join("logs")).unwrap();
fs::create_dir_all(root.join("cache/downloads")).unwrap();
fs::write(root.join("logs/run.json"), vec![0_u8; 11]).unwrap();
fs::write(root.join("cache/downloads/item"), vec![0_u8; 7]).unwrap();
let total = class_status(std::slice::from_ref(&root)).unwrap();
let downloads = class_status(&[root.join("cache/downloads")]).unwrap();
assert_eq!(total.files, 2);
assert_eq!(total.logical_bytes, 18);
assert!(total.oldest_modified.is_some());
assert!(total.newest_modified.is_some());
assert_eq!(downloads.logical_bytes, 7);
fs::remove_dir_all(root).unwrap();
}
}