use eyre::{Context, Result};
use mbx_cache_core::{LocalCas, RemoteActionResult};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const CAS_DIR: &str = "cas/v1";
const ACTION_RESULTS_DIR: &str = "action-results/v1";
#[derive(Debug, Default, PartialEq, Eq)]
pub struct StoreStats {
pub objects: u64,
pub object_bytes: u64,
pub action_results: u64,
pub action_result_bytes: u64,
}
impl StoreStats {
pub fn total_bytes(&self) -> u64 {
self.object_bytes.saturating_add(self.action_result_bytes)
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct GcOutcome {
pub removed_objects: u64,
pub removed_action_results: u64,
pub removed_bytes: u64,
pub remaining_bytes: u64,
}
pub fn stats(store: &Path) -> Result<StoreStats> {
let objects = walk_files(&store.join(CAS_DIR))?;
let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
Ok(StoreStats {
objects: objects.len() as u64,
object_bytes: objects.iter().map(|entry| entry.size).sum(),
action_results: results.len() as u64,
action_result_bytes: results.iter().map(|entry| entry.size).sum(),
})
}
pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
let mut objects = walk_files(&store.join(CAS_DIR))?;
let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
let mut live_bytes = objects
.iter()
.chain(results.iter())
.map(|entry| entry.size)
.sum::<u64>();
let mut outcome = GcOutcome::default();
if live_bytes > max_bytes {
objects.sort_by_key(|entry| entry.used);
for entry in &objects {
if live_bytes <= max_bytes {
break;
}
if remove(&entry.path)? {
live_bytes = live_bytes.saturating_sub(entry.size);
outcome.removed_objects += 1;
outcome.removed_bytes += entry.size;
}
}
}
let cas = LocalCas::new(store);
for entry in &results {
if action_result_is_dangling(&cas, &entry.path)? && remove(&entry.path)? {
live_bytes = live_bytes.saturating_sub(entry.size);
outcome.removed_action_results += 1;
outcome.removed_bytes += entry.size;
}
}
outcome.remaining_bytes = live_bytes;
Ok(outcome)
}
fn remove(path: &Path) -> Result<bool> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
}
}
fn action_result_is_dangling(cas: &LocalCas, path: &Path) -> Result<bool> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
}
};
let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
return Ok(false);
};
for digest in [
Some(&result.action),
result.metadata.as_ref(),
result.output_root.as_ref(),
]
.into_iter()
.flatten()
{
if cas.find(digest).unwrap_or(None).is_none() {
return Ok(true);
}
}
Ok(false)
}
struct Entry {
path: PathBuf,
size: u64,
used: SystemTime,
}
fn walk_files(root: &Path) -> Result<Vec<Entry>> {
let mut entries = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let listing = match std::fs::read_dir(&directory) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(error)
.wrap_err_with(|| format!("failed to read {}", directory.display()));
}
};
for entry in listing {
let entry = entry?;
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
if metadata.is_dir() {
pending.push(entry.path());
} else if metadata.is_file() {
let used = metadata
.accessed()
.or_else(|_| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
entries.push(Entry {
path: entry.path(),
size: metadata.len(),
used,
});
}
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use mbx_cache_core::{CacheDigest, LocalActionCache};
use std::time::Duration;
fn store_object(store: &Path, contents: &[u8]) -> CacheDigest {
let digest = CacheDigest::blake3(contents);
LocalCas::new(store).store_bytes(&digest, contents).unwrap();
digest
}
fn age(store: &Path, digest: &CacheDigest, age: Duration) {
let path = LocalCas::new(store).path_for(digest).unwrap();
let when = SystemTime::now() - age;
let time = filetime::FileTime::from_system_time(when);
filetime::set_file_times(path, time, time).unwrap();
}
#[test]
fn reports_an_empty_store() {
let directory = tempfile::tempdir().unwrap();
assert_eq!(stats(directory.path()).unwrap(), StoreStats::default());
}
#[test]
fn counts_objects_and_results() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
store_object(store, b"first");
store_object(store, b"second object");
let stats = stats(store).unwrap();
assert_eq!(stats.objects, 2);
assert_eq!(stats.object_bytes, 5 + 13);
assert_eq!(stats.total_bytes(), 18);
}
#[test]
fn keeps_the_store_under_its_budget_evicting_oldest_first() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
let old = store_object(store, b"0123456789");
let recent = store_object(store, b"abcdefghij");
age(store, &old, Duration::from_secs(60 * 60));
age(store, &recent, Duration::from_secs(1));
let outcome = gc(store, 10).unwrap();
assert_eq!(outcome.removed_objects, 1);
assert_eq!(outcome.removed_bytes, 10);
assert_eq!(outcome.remaining_bytes, 10);
let cas = LocalCas::new(store);
assert!(cas.find(&old).unwrap().is_none());
assert!(cas.find(&recent).unwrap().is_some());
}
#[test]
fn leaves_a_store_within_budget_alone() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
store_object(store, b"kept");
let outcome = gc(store, 1024).unwrap();
assert_eq!(
outcome,
GcOutcome {
remaining_bytes: 4,
..GcOutcome::default()
}
);
assert_eq!(stats(store).unwrap().objects, 1);
}
#[test]
fn drops_an_action_result_whose_descriptor_blob_is_gone() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
let action = store_object(store, b"action key");
let output_root = store_object(store, b"output root blob");
let result = RemoteActionResult {
version: 1,
action: action.clone(),
metadata: None,
output_root: Some(output_root.clone()),
};
let cache = LocalActionCache::new(store);
cache.store(&result).unwrap();
std::fs::remove_file(LocalCas::new(store).find(&action).unwrap().unwrap()).unwrap();
let outcome = gc(store, u64::MAX).unwrap();
assert_eq!(outcome.removed_action_results, 1);
assert!(cache.find(&action).unwrap().is_none());
}
#[test]
fn drops_action_results_left_without_their_objects() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
let action = store_object(store, b"action key");
let metadata = store_object(store, b"metadata blob");
let result = RemoteActionResult {
version: 1,
action: action.clone(),
metadata: Some(metadata),
output_root: None,
};
LocalActionCache::new(store).store(&result).unwrap();
let outcome = gc(store, 0).unwrap();
assert!(outcome.removed_objects >= 1);
assert_eq!(outcome.removed_action_results, 1);
assert!(
LocalActionCache::new(store)
.find(&action)
.unwrap()
.is_none()
);
}
}