use std::path::{Path, PathBuf};
use haz_domain::settings::cache_clean::max_age::MaxAge;
use haz_domain::settings::cache_clean::max_size::MaxSize;
use haz_vfs::{EntryKind, FsError, WritableFilesystem};
use snafu::Snafu;
use crate::layout;
use crate::manifest::{HashFunctionLabel, Manifest};
use crate::writer::CacheWriter;
#[derive(Debug, Snafu)]
pub enum CleanError {
#[snafu(display("filesystem error during cache invalidation: {source}"))]
Io {
source: FsError,
},
}
#[derive(Debug, Snafu)]
pub enum CleanFailure {
#[snafu(display("failed to read cache shard at: {}: {source}", path.display()))]
Shard {
path: PathBuf,
source: FsError,
},
#[snafu(display("failed to read entry manifest at: {}: {source}", path.display()))]
Manifest {
path: PathBuf,
source: FsError,
},
#[snafu(display("failed to remove cache entry at: {}: {source}", path.display()))]
Entry {
path: PathBuf,
source: FsError,
},
#[snafu(display("failed to remove orphan tmp directory at: {}: {source}", path.display()))]
Tmp {
path: PathBuf,
source: FsError,
},
#[snafu(display(
"failed to remove orphan restore directory at: {}: {source}",
path.display()
))]
Restore {
path: PathBuf,
source: FsError,
},
}
#[derive(Debug, Default)]
pub struct CleanOutcome {
pub report: CleanReport,
pub failures: Vec<CleanFailure>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CleanOptions {
pub soft: bool,
pub max_age: Option<MaxAge>,
pub max_size: Option<MaxSize>,
pub dry_run: bool,
pub now_unix: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EvictionMode {
Soft,
MaxAge,
MaxSize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EvictedEntry {
pub key_hex_prefix: String,
pub created_at_unix: u64,
pub footprint: u64,
pub matched_mode: EvictionMode,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CleanReport {
pub inspected: u64,
pub evicted_by_soft: u64,
pub evicted_by_max_age: u64,
pub evicted_by_max_size: u64,
pub removed_tmp_dirs: u64,
pub removed_restore_dirs: u64,
pub bytes_reclaimed: u64,
pub evicted_entries: Vec<EvictedEntry>,
}
impl<Fs: WritableFilesystem> CacheWriter<Fs> {
pub fn clear(&self) -> Result<(), CleanError> {
match self.fs().remove_dir_all(self.cache_root()) {
Ok(()) | Err(FsError::NotFound { .. }) => Ok(()),
Err(e) => Err(CleanError::Io { source: e }),
}
}
pub fn clean(&self, opts: &CleanOptions) -> Result<CleanOutcome, CleanError> {
let Some(enumerated) = self.enumerate_for_clean()? else {
return Ok(CleanOutcome::default());
};
let CleanEnumeration {
well_formed,
corrupt,
tmp_paths,
restore_paths,
mut failures,
} = enumerated;
let mut report = CleanReport {
inspected: (well_formed.len() + corrupt.len()) as u64,
..CleanReport::default()
};
let mut plan: Vec<PlannedEviction> = Vec::new();
apply_soft_pass(opts, corrupt, &mut plan);
let survivors = apply_max_age_pass(opts, well_formed, &mut plan);
apply_max_size_pass(opts, survivors, &mut plan);
let mut removed: Vec<EvictedEntry> = Vec::new();
for planned in plan {
match self.evict_path(opts.dry_run, &planned.path) {
Ok(()) => {
bump_mode_count(&mut report, planned.detail.matched_mode);
report.bytes_reclaimed = report
.bytes_reclaimed
.saturating_add(planned.detail.footprint);
removed.push(planned.detail);
}
Err(source) => failures.push(CleanFailure::Entry {
path: planned.path,
source,
}),
}
}
if opts.soft {
for path in tmp_paths {
match self.evict_path(opts.dry_run, &path) {
Ok(()) => {
report.removed_tmp_dirs = report.removed_tmp_dirs.saturating_add(1);
}
Err(source) => failures.push(CleanFailure::Tmp { path, source }),
}
}
for path in restore_paths {
match self.evict_path(opts.dry_run, &path) {
Ok(()) => {
report.removed_restore_dirs = report.removed_restore_dirs.saturating_add(1);
}
Err(source) => failures.push(CleanFailure::Restore { path, source }),
}
}
}
report.evicted_entries = finalize_evicted_entries(removed);
Ok(CleanOutcome { report, failures })
}
fn evict_path(&self, dry_run: bool, path: &Path) -> Result<(), FsError> {
if dry_run {
Ok(())
} else {
self.fs().remove_dir_all(path)
}
}
fn enumerate_for_clean(&self) -> Result<Option<CleanEnumeration>, CleanError> {
let cache_entries = match self.fs().read_dir(self.cache_root()) {
Ok(es) => es,
Err(FsError::NotFound { .. }) => return Ok(None),
Err(e) => return Err(CleanError::Io { source: e }),
};
let mut e = CleanEnumeration::default();
for cache_entry in cache_entries {
let name = cache_entry
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if name.starts_with(".restore-") {
e.restore_paths.push(cache_entry.path);
continue;
}
if cache_entry.metadata.kind != EntryKind::Dir {
continue;
}
self.clean_classify_shard(
&cache_entry.path,
&mut e.well_formed,
&mut e.corrupt,
&mut e.tmp_paths,
&mut e.failures,
);
}
Ok(Some(e))
}
fn clean_classify_shard(
&self,
shard_dir: &Path,
well_formed: &mut Vec<EntryRecord>,
corrupt: &mut Vec<EntryRecord>,
tmp_paths: &mut Vec<PathBuf>,
failures: &mut Vec<CleanFailure>,
) {
let shard_entries = match self.fs().read_dir(shard_dir) {
Ok(entries) => entries,
Err(source) => {
failures.push(CleanFailure::Shard {
path: shard_dir.to_path_buf(),
source,
});
return;
}
};
for shard_entry in shard_entries {
let sname = shard_entry
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if sname.starts_with(".tmp-") {
tmp_paths.push(shard_entry.path);
continue;
}
if shard_entry.metadata.kind != EntryKind::Dir {
continue;
}
self.clean_classify_entry(&shard_entry.path, &sname, well_formed, corrupt, failures);
}
}
fn clean_classify_entry(
&self,
entry_dir: &Path,
basename: &str,
well_formed: &mut Vec<EntryRecord>,
corrupt: &mut Vec<EntryRecord>,
failures: &mut Vec<CleanFailure>,
) {
let key_hex_prefix: String = basename.chars().take(8).collect();
let manifest_path = entry_dir.join(layout::MANIFEST_FILE_NAME);
let bytes = match self.fs().read(&manifest_path) {
Ok(b) => b,
Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => {
corrupt.push(EntryRecord {
path: entry_dir.to_path_buf(),
key_hex_prefix,
created_at_unix: 0,
footprint: 0,
});
return;
}
Err(source) => {
failures.push(CleanFailure::Manifest {
path: manifest_path,
source,
});
return;
}
};
let Ok(manifest) = Manifest::from_json(&bytes) else {
corrupt.push(EntryRecord {
path: entry_dir.to_path_buf(),
key_hex_prefix,
created_at_unix: 0,
footprint: 0,
});
return;
};
let chapter_ok = manifest.current_chapter_revision_matches();
let hash_ok = HashFunctionLabel::from(self.hash_algo()) == manifest.hash_function;
let footprint = manifest_footprint(&manifest);
let record = EntryRecord {
path: entry_dir.to_path_buf(),
key_hex_prefix,
created_at_unix: manifest.created_at_unix,
footprint,
};
if chapter_ok && hash_ok {
well_formed.push(record);
} else {
corrupt.push(record);
}
}
}
struct EntryRecord {
path: PathBuf,
key_hex_prefix: String,
created_at_unix: u64,
footprint: u64,
}
struct PlannedEviction {
path: PathBuf,
detail: EvictedEntry,
}
#[derive(Default)]
struct CleanEnumeration {
well_formed: Vec<EntryRecord>,
corrupt: Vec<EntryRecord>,
tmp_paths: Vec<PathBuf>,
restore_paths: Vec<PathBuf>,
failures: Vec<CleanFailure>,
}
fn apply_soft_pass(
opts: &CleanOptions,
corrupt: Vec<EntryRecord>,
plan: &mut Vec<PlannedEviction>,
) {
if !opts.soft {
return;
}
for c in corrupt {
plan.push(PlannedEviction {
path: c.path,
detail: EvictedEntry {
key_hex_prefix: c.key_hex_prefix,
created_at_unix: c.created_at_unix,
footprint: c.footprint,
matched_mode: EvictionMode::Soft,
},
});
}
}
fn apply_max_age_pass(
opts: &CleanOptions,
well_formed: Vec<EntryRecord>,
plan: &mut Vec<PlannedEviction>,
) -> Vec<EntryRecord> {
let Some(max_age) = opts.max_age else {
return well_formed;
};
let cutoff = opts
.now_unix
.saturating_sub(max_age.as_duration().as_secs());
let mut survivors: Vec<EntryRecord> = Vec::with_capacity(well_formed.len());
for wf in well_formed {
if wf.created_at_unix < cutoff {
plan.push(PlannedEviction {
path: wf.path.clone(),
detail: EvictedEntry {
key_hex_prefix: wf.key_hex_prefix.clone(),
created_at_unix: wf.created_at_unix,
footprint: wf.footprint,
matched_mode: EvictionMode::MaxAge,
},
});
} else {
survivors.push(wf);
}
}
survivors
}
fn apply_max_size_pass(
opts: &CleanOptions,
mut survivors: Vec<EntryRecord>,
plan: &mut Vec<PlannedEviction>,
) {
let Some(max_size) = opts.max_size else {
return;
};
let limit = max_size.as_bytes();
let total: u64 = survivors.iter().map(|e| e.footprint).sum();
if total <= limit {
return;
}
survivors.sort_by(|a, b| {
a.created_at_unix
.cmp(&b.created_at_unix)
.then_with(|| a.key_hex_prefix.cmp(&b.key_hex_prefix))
});
let mut remaining = total;
for wf in &survivors {
if remaining <= limit {
break;
}
plan.push(PlannedEviction {
path: wf.path.clone(),
detail: EvictedEntry {
key_hex_prefix: wf.key_hex_prefix.clone(),
created_at_unix: wf.created_at_unix,
footprint: wf.footprint,
matched_mode: EvictionMode::MaxSize,
},
});
remaining = remaining.saturating_sub(wf.footprint);
}
}
fn bump_mode_count(report: &mut CleanReport, mode: EvictionMode) {
match mode {
EvictionMode::Soft => {
report.evicted_by_soft = report.evicted_by_soft.saturating_add(1);
}
EvictionMode::MaxAge => {
report.evicted_by_max_age = report.evicted_by_max_age.saturating_add(1);
}
EvictionMode::MaxSize => {
report.evicted_by_max_size = report.evicted_by_max_size.saturating_add(1);
}
}
}
fn finalize_evicted_entries(mut details: Vec<EvictedEntry>) -> Vec<EvictedEntry> {
details.sort_by(|a, b| {
mode_rank(a.matched_mode)
.cmp(&mode_rank(b.matched_mode))
.then(a.created_at_unix.cmp(&b.created_at_unix))
.then_with(|| a.key_hex_prefix.cmp(&b.key_hex_prefix))
});
details
}
fn manifest_footprint(m: &Manifest) -> u64 {
let mut total = m.stdout_len.saturating_add(m.stderr_len);
for o in &m.outputs {
total = total.saturating_add(o.size);
}
total
}
const fn mode_rank(m: EvictionMode) -> u8 {
match m {
EvictionMode::Soft => 0,
EvictionMode::MaxAge => 1,
EvictionMode::MaxSize => 2,
}
}
#[cfg(test)]
mod tests {
use std::io::ErrorKind;
use std::path::Path;
use haz_domain::path::CanonicalPath;
use haz_domain::settings::cache::HashAlgo;
use haz_domain::settings::cache_clean::max_age::MaxAge;
use haz_domain::settings::cache_clean::max_size::MaxSize;
use haz_vfs::{Filesystem, WritableFilesystem};
use haz_vfs_testing::{MemFaultOp, MemFilesystem};
use crate::clean::{CleanError, CleanFailure, CleanOptions, CleanReport, EvictionMode};
use crate::key::CacheKey;
use crate::key::prefix::CHAPTER_REVISION;
use crate::layout;
use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
use crate::store::{StoreInputs, StoredOutput};
use crate::writer::CacheWriter;
fn cp(s: &str) -> CanonicalPath {
CanonicalPath::parse_workspace_absolute(s)
.expect("test helper expects a valid workspace-absolute path")
}
const WORKSPACE_ROOT: &str = "/ws";
fn make_cache(fs: MemFilesystem, algo: HashAlgo) -> CacheWriter<MemFilesystem> {
CacheWriter::new(fs, Path::new(WORKSPACE_ROOT), algo)
}
fn key_with_first_byte(first: u8) -> CacheKey {
let mut bytes = [0u8; 32];
bytes[0] = first;
CacheKey::from_bytes(bytes)
}
fn store_entry_at(
cache: &CacheWriter<MemFilesystem>,
key: &CacheKey,
rel: &str,
bytes: &[u8],
created_at_unix: u64,
) {
let target = Path::new(WORKSPACE_ROOT).join(rel);
let anchored = format!("/{rel}");
cache.fs().create_dir_all(target.parent().unwrap()).unwrap();
cache.fs().write_file(&target, bytes).unwrap();
let outs = [StoredOutput {
workspace_absolute_path: &anchored,
on_disk_path: &target,
mode: 0o644,
}];
cache
.store(
key,
&StoreInputs {
outputs: &outs,
stdout: b"",
stderr: b"",
created_at_unix,
},
)
.unwrap();
}
fn store_a_valid_entry(
cache: &CacheWriter<MemFilesystem>,
key: &CacheKey,
rel: &str,
bytes: &[u8],
) {
store_entry_at(cache, key, rel, bytes, 0);
}
fn write_manifest_to_entry(
cache: &CacheWriter<MemFilesystem>,
key: &CacheKey,
manifest: &Manifest,
) {
cache
.fs()
.create_dir_all(&layout::entry_dir(cache.cache_root(), key))
.unwrap();
cache
.fs()
.write_file(
&layout::manifest_path(cache.cache_root(), key),
&manifest.to_json_bytes(),
)
.unwrap();
}
fn soft_only() -> CleanOptions {
CleanOptions {
soft: true,
..Default::default()
}
}
#[test]
fn cache_021_clear_empties_a_populated_cache() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
store_a_valid_entry(&cache, &key, "proj/out", b"x");
assert!(
cache.reader().lookup(&key).is_some(),
"precondition: entry present"
);
cache.clear().unwrap();
assert!(
cache.reader().lookup(&key).is_none(),
"lookup must be a miss after clear"
);
}
#[test]
fn cache_021_clear_on_fresh_cache_is_a_noop_not_an_error() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
cache.clear().unwrap();
}
#[test]
fn cache_021_clear_does_not_touch_files_outside_cache_root() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
fs.add_file("/ws/unrelated.txt", b"keep me".to_vec())
.unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
store_a_valid_entry(&cache, &key, "proj/out", b"x");
cache.clear().unwrap();
assert_eq!(
cache.fs().read(Path::new("/ws/unrelated.txt")).unwrap(),
b"keep me"
);
}
#[test]
fn cache_022_clean_soft_on_fresh_cache_is_a_noop_with_zero_counts() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report, CleanReport::default());
}
#[test]
fn aux_022_clean_with_no_modes_is_a_noop_on_a_populated_cache() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
store_a_valid_entry(&cache, &key, "proj/out", b"x");
let report = cache.clean(&CleanOptions::default()).unwrap().report;
assert_eq!(report.evicted_by_soft, 0);
assert_eq!(report.evicted_by_max_age, 0);
assert_eq!(report.evicted_by_max_size, 0);
assert_eq!(report.removed_tmp_dirs, 0);
assert_eq!(report.removed_restore_dirs, 0);
assert_eq!(report.inspected, 1);
assert!(cache.reader().lookup(&key).is_some());
}
#[test]
fn cache_022_clean_soft_keeps_a_valid_entry_intact() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
store_a_valid_entry(&cache, &key, "proj/out", b"x");
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 0);
assert!(cache.reader().lookup(&key).is_some());
}
#[test]
fn cache_022_clean_soft_removes_entry_with_chapter_revision_mismatch() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
let manifest = Manifest {
chapter_revision: CHAPTER_REVISION.saturating_add(1),
hash_function: HashFunctionLabel::Blake3,
key,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key, &manifest);
assert!(
cache
.fs()
.metadata(&layout::entry_dir(cache.cache_root(), &key))
.is_ok()
);
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 1);
assert!(
cache
.fs()
.metadata(&layout::entry_dir(cache.cache_root(), &key))
.is_err()
);
}
#[test]
fn cache_022_clean_soft_removes_entry_with_hash_function_mismatch() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
let manifest = Manifest {
chapter_revision: CHAPTER_REVISION,
hash_function: HashFunctionLabel::Sha256,
key,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key, &manifest);
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 1);
}
#[test]
fn cache_022_clean_soft_removes_entry_without_a_manifest() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
cache
.fs()
.create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
.unwrap();
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 1);
}
#[test]
fn cache_022_clean_soft_removes_entry_with_unparseable_manifest() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
cache
.fs()
.create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
.unwrap();
cache
.fs()
.write_file(
&layout::manifest_path(cache.cache_root(), &key),
b"this is not json",
)
.unwrap();
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 1);
}
#[test]
fn cache_022_clean_soft_removes_store_tmp_directory() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
let tmp = layout::tmp_entry_dir(cache.cache_root(), &key, "abcdef");
cache.fs().create_dir_all(&tmp).unwrap();
cache
.fs()
.write_file(&tmp.join("manifest.json"), b"partial")
.unwrap();
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.removed_tmp_dirs, 1);
assert!(cache.fs().metadata(&tmp).is_err());
}
#[test]
fn cache_022_clean_soft_removes_restore_staging_directory() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
let staging = layout::restore_staging_dir(cache.cache_root(), &key, "feedface");
cache.fs().create_dir_all(&staging).unwrap();
cache
.fs()
.write_file(&staging.join("00000000"), b"leftover")
.unwrap();
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.removed_restore_dirs, 1);
assert!(cache.fs().metadata(&staging).is_err());
}
#[test]
fn cache_022_clean_soft_is_selective_when_mixed_state_is_present() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_good = key_with_first_byte(0xAB);
store_a_valid_entry(&cache, &key_good, "proj/out", b"x");
let key_stale = key_with_first_byte(0xCD);
let stale_manifest = Manifest {
chapter_revision: CHAPTER_REVISION,
hash_function: HashFunctionLabel::Sha256,
key: key_stale,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key_stale, &stale_manifest);
let key_tmp = key_with_first_byte(0xEF);
let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_tmp, "rnd1");
cache.fs().create_dir_all(&tmp).unwrap();
let key_restore = key_with_first_byte(0x12);
let staging = layout::restore_staging_dir(cache.cache_root(), &key_restore, "rnd2");
cache.fs().create_dir_all(&staging).unwrap();
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 1);
assert_eq!(report.removed_tmp_dirs, 1);
assert_eq!(report.removed_restore_dirs, 1);
assert!(cache.reader().lookup(&key_good).is_some());
}
#[test]
fn cache_022_clean_soft_does_not_touch_files_outside_cache_root() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
fs.add_file("/ws/sibling.txt", b"don't touch".to_vec())
.unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
cache
.fs()
.create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
.unwrap();
cache.clean(&soft_only()).unwrap();
assert_eq!(
cache.fs().read(Path::new("/ws/sibling.txt")).unwrap(),
b"don't touch"
);
}
#[test]
fn cache_022_clean_soft_does_not_inspect_blob_contents() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAB);
let manifest = Manifest {
chapter_revision: CHAPTER_REVISION,
hash_function: HashFunctionLabel::Blake3,
key,
outputs: vec![OutputBlob {
workspace_absolute_path: cp("/proj/out"),
content_hash: [0xAAu8; 32],
size: 42,
mode: 0o644,
}],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key, &manifest);
let report = cache.clean(&soft_only()).unwrap().report;
assert_eq!(report.evicted_by_soft, 0);
assert!(
cache
.fs()
.metadata(&layout::entry_dir(cache.cache_root(), &key))
.is_ok()
);
}
#[test]
fn aux_023_clean_max_age_evicts_entries_strictly_older_than_cutoff() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_old = key_with_first_byte(0xAA);
store_entry_at(&cache, &key_old, "proj/old", b"x", 100);
let key_new = key_with_first_byte(0xBB);
store_entry_at(&cache, &key_new, "proj/new", b"y", 260);
let opts = CleanOptions {
max_age: Some(MaxAge::parse("50s").unwrap()),
now_unix: 300,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_age, 1);
assert_eq!(report.evicted_by_soft, 0);
assert_eq!(report.evicted_by_max_size, 0);
assert!(cache.reader().lookup(&key_old).is_none());
assert!(cache.reader().lookup(&key_new).is_some());
}
#[test]
fn aux_023_clean_max_age_keeps_entry_at_exactly_cutoff() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAA);
store_entry_at(&cache, &key, "proj/x", b"x", 100);
let opts = CleanOptions {
max_age: Some(MaxAge::parse("100s").unwrap()),
now_unix: 200,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_age, 0);
assert!(cache.reader().lookup(&key).is_some());
}
#[test]
fn aux_023_clean_max_age_ignores_corrupt_entries() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_corrupt = key_with_first_byte(0xCC);
let m = Manifest {
chapter_revision: CHAPTER_REVISION,
hash_function: HashFunctionLabel::Sha256,
key: key_corrupt,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key_corrupt, &m);
let key_stale = key_with_first_byte(0xAA);
store_entry_at(&cache, &key_stale, "proj/x", b"x", 100);
let opts = CleanOptions {
max_age: Some(MaxAge::parse("50s").unwrap()),
now_unix: 300,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_age, 1);
assert_eq!(report.evicted_by_soft, 0);
assert!(
cache
.fs()
.metadata(&layout::entry_dir(cache.cache_root(), &key_corrupt))
.is_ok()
);
assert!(cache.reader().lookup(&key_stale).is_none());
}
#[test]
fn aux_023_clean_max_size_is_noop_when_under_limit() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAA);
store_entry_at(&cache, &key, "proj/x", b"hello", 100);
let opts = CleanOptions {
max_size: Some(MaxSize::parse("1KB").unwrap()),
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_size, 0);
assert!(cache.reader().lookup(&key).is_some());
}
#[test]
fn aux_023_clean_max_size_evicts_oldest_first_until_at_or_below_limit() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let bytes = b"0123456789"; let key_old = key_with_first_byte(0x11);
let key_mid = key_with_first_byte(0x22);
let key_new = key_with_first_byte(0x33);
store_entry_at(&cache, &key_old, "proj/a", bytes, 100);
store_entry_at(&cache, &key_mid, "proj/b", bytes, 200);
store_entry_at(&cache, &key_new, "proj/c", bytes, 300);
let opts = CleanOptions {
max_size: Some(MaxSize::parse("15").unwrap()),
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_size, 2);
assert!(cache.reader().lookup(&key_old).is_none());
assert!(cache.reader().lookup(&key_mid).is_none());
assert!(cache.reader().lookup(&key_new).is_some());
assert_eq!(report.bytes_reclaimed, 20);
}
#[test]
fn aux_023_clean_max_size_zero_evicts_every_well_formed_entry() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAA);
store_entry_at(&cache, &key, "proj/x", b"x", 100);
let opts = CleanOptions {
max_size: Some(MaxSize::parse("0").unwrap()),
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_size, 1);
assert!(cache.reader().lookup(&key).is_none());
}
#[test]
fn aux_023_clean_soft_and_max_age_count_separately_per_priority() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_corrupt = key_with_first_byte(0xCC);
let m = Manifest {
chapter_revision: CHAPTER_REVISION.saturating_add(1),
hash_function: HashFunctionLabel::Blake3,
key: key_corrupt,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key_corrupt, &m);
let key_stale = key_with_first_byte(0xAA);
store_entry_at(&cache, &key_stale, "proj/x", b"x", 100);
let opts = CleanOptions {
soft: true,
max_age: Some(MaxAge::parse("50s").unwrap()),
now_unix: 300,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_soft, 1);
assert_eq!(report.evicted_by_max_age, 1);
assert_eq!(report.inspected, 2);
}
#[test]
fn aux_023_clean_evicted_entries_sorted_by_mode_then_created_at() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_a = key_with_first_byte(0x11);
let key_b = key_with_first_byte(0x22);
store_entry_at(&cache, &key_a, "proj/a", b"x", 100);
store_entry_at(&cache, &key_b, "proj/b", b"y", 200);
let key_corrupt = key_with_first_byte(0xCC);
let stale = Manifest {
chapter_revision: CHAPTER_REVISION.saturating_add(1),
hash_function: HashFunctionLabel::Blake3,
key: key_corrupt,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
};
write_manifest_to_entry(&cache, &key_corrupt, &stale);
let opts = CleanOptions {
soft: true,
max_age: Some(MaxAge::parse("50s").unwrap()),
now_unix: 300,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_entries.len(), 3);
assert_eq!(report.evicted_entries[0].matched_mode, EvictionMode::Soft);
assert_eq!(report.evicted_entries[1].matched_mode, EvictionMode::MaxAge);
assert_eq!(
report.evicted_entries[1].created_at_unix, 100,
"older max-age entry sorts before newer one"
);
assert_eq!(report.evicted_entries[2].matched_mode, EvictionMode::MaxAge);
assert_eq!(report.evicted_entries[2].created_at_unix, 200);
}
#[test]
fn aux_023_clean_dry_run_does_not_modify_disk() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key = key_with_first_byte(0xAA);
store_entry_at(&cache, &key, "proj/x", b"x", 100);
let opts = CleanOptions {
max_age: Some(MaxAge::parse("50s").unwrap()),
now_unix: 300,
dry_run: true,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_age, 1);
assert_eq!(report.evicted_entries.len(), 1);
assert_eq!(report.evicted_entries[0].matched_mode, EvictionMode::MaxAge);
assert!(
cache.reader().lookup(&key).is_some(),
"dry-run must leave the entry on disk"
);
}
#[test]
fn aux_023_clean_dry_run_under_soft_keeps_tmp_and_restore_dirs() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_tmp = key_with_first_byte(0xEF);
let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_tmp, "rnd1");
cache.fs().create_dir_all(&tmp).unwrap();
let key_restore = key_with_first_byte(0x12);
let staging = layout::restore_staging_dir(cache.cache_root(), &key_restore, "rnd2");
cache.fs().create_dir_all(&staging).unwrap();
let opts = CleanOptions {
soft: true,
dry_run: true,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.removed_tmp_dirs, 1);
assert_eq!(report.removed_restore_dirs, 1);
assert!(cache.fs().metadata(&tmp).is_ok());
assert!(cache.fs().metadata(&staging).is_ok());
}
#[test]
fn aux_023_clean_bytes_reclaimed_sums_evicted_footprints() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache = make_cache(fs, HashAlgo::Blake3);
let key_a = key_with_first_byte(0x11);
store_entry_at(&cache, &key_a, "proj/a", b"hello", 100); let key_b = key_with_first_byte(0x22);
store_entry_at(&cache, &key_b, "proj/b", b"world!", 200);
let opts = CleanOptions {
max_age: Some(MaxAge::parse("50s").unwrap()),
now_unix: 300,
..Default::default()
};
let report = cache.clean(&opts).unwrap().report;
assert_eq!(report.evicted_by_max_age, 2);
assert_eq!(report.bytes_reclaimed, 11);
}
fn corrupt_manifest(key: CacheKey) -> Manifest {
Manifest {
chapter_revision: CHAPTER_REVISION,
hash_function: HashFunctionLabel::Sha256,
key,
outputs: vec![],
stdout_len: 0,
stderr_len: 0,
stdout_hash: [0u8; 32],
stderr_hash: [0u8; 32],
exit_status: 0,
created_at_unix: 0,
}
}
#[test]
fn aux_028_clean_soft_is_best_effort_when_one_removal_fails() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
let key_stuck = key_with_first_byte(0xAA);
let key_ok = key_with_first_byte(0xBB);
let stuck_dir = layout::entry_dir(&cache_root, &key_stuck);
let ok_dir = layout::entry_dir(&cache_root, &key_ok);
fs.fail_on(
MemFaultOp::RemoveDirAll,
&stuck_dir,
ErrorKind::PermissionDenied,
);
let cache = make_cache(fs, HashAlgo::Blake3);
write_manifest_to_entry(&cache, &key_stuck, &corrupt_manifest(key_stuck));
write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
let outcome = cache.clean(&soft_only()).unwrap();
assert!(
cache.fs().metadata(&stuck_dir).is_ok(),
"stuck entry remains"
);
assert!(
cache.fs().metadata(&ok_dir).is_err(),
"removable entry evicted despite the other failing"
);
assert_eq!(outcome.report.evicted_by_soft, 1);
assert_eq!(outcome.report.evicted_entries.len(), 1);
assert_eq!(outcome.failures.len(), 1);
assert!(matches!(
&outcome.failures[0],
CleanFailure::Entry { path, .. } if path == &stuck_dir
));
}
#[test]
fn aux_028_clean_collects_every_removal_failure_not_just_the_first() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
let key_a = key_with_first_byte(0xA1);
let key_b = key_with_first_byte(0xB2);
fs.fail_on(
MemFaultOp::RemoveDirAll,
layout::entry_dir(&cache_root, &key_a),
ErrorKind::PermissionDenied,
);
fs.fail_on(
MemFaultOp::RemoveDirAll,
layout::entry_dir(&cache_root, &key_b),
ErrorKind::PermissionDenied,
);
let cache = make_cache(fs, HashAlgo::Blake3);
write_manifest_to_entry(&cache, &key_a, &corrupt_manifest(key_a));
write_manifest_to_entry(&cache, &key_b, &corrupt_manifest(key_b));
let outcome = cache.clean(&soft_only()).unwrap();
assert_eq!(
outcome.report.evicted_by_soft, 0,
"neither removal succeeded"
);
assert_eq!(
outcome.failures.len(),
2,
"both failures surfaced, not just the first"
);
}
#[test]
fn aux_028_clean_soft_best_effort_across_tmp_and_restore() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
let tmp = layout::tmp_entry_dir(&cache_root, &key_with_first_byte(0xEF), "rnd1");
let staging = layout::restore_staging_dir(&cache_root, &key_with_first_byte(0x12), "rnd2");
fs.fail_on(MemFaultOp::RemoveDirAll, &tmp, ErrorKind::PermissionDenied);
let cache = make_cache(fs, HashAlgo::Blake3);
cache.fs().create_dir_all(&tmp).unwrap();
cache.fs().create_dir_all(&staging).unwrap();
let outcome = cache.clean(&soft_only()).unwrap();
assert!(
cache.fs().metadata(&staging).is_err(),
"restore dir removed"
);
assert!(cache.fs().metadata(&tmp).is_ok(), "stuck tmp dir remains");
assert_eq!(outcome.report.removed_restore_dirs, 1);
assert_eq!(
outcome.report.removed_tmp_dirs, 0,
"tmp removal failed, not counted"
);
assert_eq!(outcome.failures.len(), 1);
assert!(matches!(
&outcome.failures[0],
CleanFailure::Tmp { path, .. } if path == &tmp
));
}
#[test]
fn aux_028_clean_skips_unreadable_shard_and_cleans_the_rest() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
let key_blocked = key_with_first_byte(0xAA);
let key_ok = key_with_first_byte(0xBB);
let blocked_shard = layout::shard_dir(&cache_root, &key_blocked);
fs.fail_on(
MemFaultOp::ReadDir,
&blocked_shard,
ErrorKind::PermissionDenied,
);
let cache = make_cache(fs, HashAlgo::Blake3);
write_manifest_to_entry(&cache, &key_blocked, &corrupt_manifest(key_blocked));
write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
let outcome = cache.clean(&soft_only()).unwrap();
assert!(
cache
.fs()
.metadata(&layout::entry_dir(&cache_root, &key_ok))
.is_err(),
"entry in the readable shard is evicted"
);
assert!(
cache
.fs()
.metadata(&layout::entry_dir(&cache_root, &key_blocked))
.is_ok(),
"entry in the unreadable shard is left untouched"
);
assert_eq!(outcome.report.evicted_by_soft, 1);
assert_eq!(outcome.failures.len(), 1);
assert!(matches!(
&outcome.failures[0],
CleanFailure::Shard { path, .. } if path == &blocked_shard
));
}
#[test]
fn aux_028_clean_skips_unreadable_manifest_and_cleans_the_rest() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
let key_blocked = key_with_first_byte(0xAA);
let key_ok = key_with_first_byte(0xCC);
let blocked_manifest = layout::manifest_path(&cache_root, &key_blocked);
fs.fail_on(
MemFaultOp::Read,
&blocked_manifest,
ErrorKind::PermissionDenied,
);
let cache = make_cache(fs, HashAlgo::Blake3);
write_manifest_to_entry(&cache, &key_blocked, &corrupt_manifest(key_blocked));
write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
let outcome = cache.clean(&soft_only()).unwrap();
assert!(
cache
.fs()
.metadata(&layout::entry_dir(&cache_root, &key_ok))
.is_err(),
"entry with a readable manifest is evicted"
);
assert!(
cache
.fs()
.metadata(&layout::entry_dir(&cache_root, &key_blocked))
.is_ok(),
"entry with an unreadable manifest is left untouched"
);
assert_eq!(outcome.report.evicted_by_soft, 1);
assert_eq!(outcome.failures.len(), 1);
assert!(matches!(
&outcome.failures[0],
CleanFailure::Manifest { path, .. } if path == &blocked_manifest
));
}
#[test]
fn aux_028_clean_unreadable_cache_root_is_fatal_with_no_report() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
fs.fail_on(
MemFaultOp::ReadDir,
&cache_root,
ErrorKind::PermissionDenied,
);
let cache = make_cache(fs, HashAlgo::Blake3);
let result = cache.clean(&soft_only());
assert!(
matches!(result, Err(CleanError::Io { .. })),
"an unreadable cache root is fatal"
);
}
#[test]
fn aux_028_dry_run_records_no_failures_even_with_a_remove_fault() {
let mut fs = MemFilesystem::new();
fs.add_dir("/ws").unwrap();
let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
let key = key_with_first_byte(0xAA);
let entry_dir = layout::entry_dir(&cache_root, &key);
fs.fail_on(
MemFaultOp::RemoveDirAll,
&entry_dir,
ErrorKind::PermissionDenied,
);
let cache = make_cache(fs, HashAlgo::Blake3);
write_manifest_to_entry(&cache, &key, &corrupt_manifest(key));
let opts = CleanOptions {
soft: true,
dry_run: true,
..Default::default()
};
let outcome = cache.clean(&opts).unwrap();
assert!(
outcome.failures.is_empty(),
"dry-run never touches disk, so no removal failure is recorded"
);
assert_eq!(
outcome.report.evicted_by_soft, 1,
"dry-run still projects the plan"
);
assert!(
cache.fs().metadata(&entry_dir).is_ok(),
"dry-run leaves the entry on disk"
);
}
}