use eyre::{Context, Result};
use mbx_cache_core::{
CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata, is_task_identity,
task_manifest_actions,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CAS_DIR: &str = "cas/v1";
const ACTION_RESULTS_DIR: &str = "action-results/v1";
const CHECKOUTS_DIR: &str = "checkouts/v1";
const SWEEP_STAMP: &str = "gc/v1/last-sweep";
const CHECKOUT_RECORD_VERSION: u8 = 1;
const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
#[derive(Debug, Default, PartialEq, Eq)]
pub struct StoreStats {
pub objects: u64,
pub object_bytes: u64,
pub action_results: u64,
pub action_result_bytes: u64,
pub live_checkouts: u64,
pub stale_checkouts: 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_checkout_records: u64,
pub removed_bytes: u64,
pub remaining_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CheckoutRecord {
version: u8,
workspace_root: PathBuf,
target_dir: PathBuf,
updated_secs: 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))?;
let checkouts = scan_checkouts(store)?;
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(),
live_checkouts: checkouts.live_records,
stale_checkouts: checkouts.stale_records.len() as u64,
})
}
pub fn record_checkout(
store: &Path,
identity: &str,
workspace_root: &Path,
target_dir: &Path,
) -> Result<()> {
let record = CheckoutRecord {
version: CHECKOUT_RECORD_VERSION,
workspace_root: workspace_root.to_path_buf(),
target_dir: target_dir.to_path_buf(),
updated_secs: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default(),
};
let mut contents = serde_json::to_vec(&record)?;
contents.push(b'\n');
crate::util::write_atomic(
&checkout_record_path(store, identity, workspace_root),
&contents,
)
}
fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
store
.join(CHECKOUTS_DIR)
.join(identity)
.join(format!("{key}.json"))
}
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();
let checkouts = scan_checkouts(store)?;
for path in &checkouts.stale_records {
if matches!(remove(path)?, Removal::Removed) {
outcome.removed_checkout_records += 1;
}
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir(parent);
}
}
if live_bytes > max_bytes {
let rooted = rooted_objects(store, &checkouts.live_identities)?;
objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, remove)?;
live_bytes = evicted.remaining_bytes;
outcome.removed_objects += evicted.removed_objects;
outcome.removed_bytes += evicted.removed_bytes;
}
let cas = LocalCas::new(store);
for entry in &results {
if action_result_is_dangling(&cas, &entry.path)? {
match remove(&entry.path)? {
Removal::Removed => {
live_bytes = live_bytes.saturating_sub(entry.size);
outcome.removed_action_results += 1;
outcome.removed_bytes += entry.size;
}
Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
Removal::Blocked => {}
}
}
}
outcome.remaining_bytes = live_bytes;
Ok(outcome)
}
pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
let stamp = store.join(SWEEP_STAMP);
if let Ok(metadata) = std::fs::metadata(&stamp)
&& let Ok(modified) = metadata.modified()
&& let Ok(since) = modified.elapsed()
&& since < interval
{
return Ok(None);
}
crate::util::write_atomic(&stamp, b"")?;
gc(store, max_bytes).map(Some)
}
struct CheckoutScan {
live_identities: BTreeSet<String>,
live_records: u64,
stale_records: Vec<PathBuf>,
}
fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
let root = store.join(CHECKOUTS_DIR);
let mut scan = CheckoutScan {
live_identities: BTreeSet::new(),
live_records: 0,
stale_records: Vec::new(),
};
let listing = match std::fs::read_dir(&root) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
Err(error) => {
return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
}
};
for entry in listing {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
for record in walk_files(&entry.path())? {
match read_checkout_record(&record.path) {
Some(checkout) if claim_is_live(&checkout) => {
scan.live_identities.insert(name.clone());
scan.live_records += 1;
}
Some(_) => scan.stale_records.push(record.path),
None => {}
}
}
}
Ok(scan)
}
fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
let bytes = std::fs::read(path).ok()?;
let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
(record.version == CHECKOUT_RECORD_VERSION).then_some(record)
}
fn claim_is_live(record: &CheckoutRecord) -> bool {
checkout_is_live(&record.workspace_root) && !claim_has_expired(record)
}
fn claim_has_expired(record: &CheckoutRecord) -> bool {
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return false;
};
now.as_secs()
.saturating_sub(record.updated_secs)
.gt(&CHECKOUT_RETENTION.as_secs())
}
pub(crate) fn checkout_is_live(workspace_root: &Path) -> bool {
if !matches!(workspace_root.try_exists(), Ok(false)) {
return true;
}
match workspace_root.parent() {
Some(parent) => !matches!(parent.try_exists(), Ok(true)),
None => true,
}
}
fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
let cas = LocalCas::new(store);
let mut rooted = HashSet::new();
let mut visited = BTreeSet::new();
for identity in identities {
let actions = match task_manifest_actions(store, identity) {
Ok(actions) => actions,
Err(error) => {
log::debug!("could not read the manifest for {identity}: {error}");
continue;
}
};
for action in actions {
let Some(result) = read_action_result(store, &action) else {
continue;
};
root_digest(&cas, &mut rooted, &result.action);
if let Some(metadata) = &result.metadata {
root_digest(&cas, &mut rooted, metadata);
if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
root_digest(&cas, &mut rooted, &rustc.stdout);
root_digest(&cas, &mut rooted, &rustc.stderr);
}
}
if let Some(output_root) = &result.output_root {
root_digest(&cas, &mut rooted, output_root);
root_tree(&cas, output_root, &mut rooted, &mut visited);
}
}
}
Ok(rooted)
}
fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
let path = mbx_cache_core::LocalActionCache::new(store)
.path_for(action)
.ok()?;
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn root_tree(
cas: &LocalCas,
output_root: &CacheDigest,
rooted: &mut HashSet<PathBuf>,
visited: &mut BTreeSet<CacheDigest>,
) {
let mut pending = vec![output_root.clone()];
while let Some(digest) = pending.pop() {
if !visited.insert(digest.clone()) {
continue;
}
let Some(directory) = read_directory(cas, &digest) else {
continue;
};
for file in &directory.files {
root_digest(cas, rooted, &file.digest);
}
for child in &directory.directories {
root_digest(cas, rooted, &child.digest);
pending.push(child.digest.clone());
}
}
}
fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
if let Ok(path) = cas.path_for(digest) {
rooted.insert(path);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Removal {
Removed,
Missing,
Blocked,
}
fn remove(path: &Path) -> Result<Removal> {
match std::fs::remove_file(path) {
Ok(()) => Ok(Removal::Removed),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
log::debug!("could not evict {}: {error}", path.display());
Ok(Removal::Blocked)
}
Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ObjectEvictions {
removed_objects: u64,
removed_bytes: u64,
remaining_bytes: u64,
}
fn evict_objects(
objects: &[Entry],
rooted: &HashSet<PathBuf>,
mut live_bytes: u64,
max_bytes: u64,
mut remove_file: impl FnMut(&Path) -> Result<Removal>,
) -> Result<ObjectEvictions> {
let mut outcome = ObjectEvictions::default();
let mut blocked_unrooted = false;
for entry in objects {
if live_bytes <= max_bytes {
break;
}
let is_rooted = rooted.contains(&entry.path);
if is_rooted && blocked_unrooted {
break;
}
match remove_file(&entry.path)? {
Removal::Removed => {
live_bytes = live_bytes.saturating_sub(entry.size);
outcome.removed_objects += 1;
outcome.removed_bytes += entry.size;
}
Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
Removal::Blocked if !is_rooted => blocked_unrooted = true,
Removal::Blocked => {}
}
}
outcome.remaining_bytes = live_bytes;
Ok(outcome)
}
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()
{
match cas.path_for(digest) {
Ok(path) => {
if !path.exists() {
return Ok(true);
}
}
Err(_) => 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::{ActionPrediction, CacheFileNode, LocalActionCache};
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();
}
fn store_result(store: &Path, name: &str, outputs: &[CacheDigest]) -> CacheDigest {
let directory = CacheDirectory {
directories: Vec::new(),
files: outputs
.iter()
.enumerate()
.map(|(index, digest)| CacheFileNode {
digest: digest.clone(),
executable: false,
mode: 0o644,
name: format!("output-{index}"),
})
.collect(),
symlinks: Vec::new(),
version: 1,
};
let encoded = serde_json::to_vec(&directory).unwrap();
let output_root = store_object(store, &encoded);
let action = store_object(store, name.as_bytes());
LocalActionCache::new(store)
.store(&RemoteActionResult {
version: 1,
action: action.clone(),
metadata: None,
output_root: Some(output_root),
})
.unwrap();
action
}
fn record_build(store: &Path, identity: &str, workspace_root: &Path, actions: &[CacheDigest]) {
record_checkout(
store,
identity,
workspace_root,
&workspace_root.join("target"),
)
.unwrap();
let predictions = actions
.iter()
.enumerate()
.map(|(index, action)| ActionPrediction {
invocation: CacheDigest::blake3(format!("{identity}-{index}").as_bytes()),
action: action.clone(),
adapter: "rustc".into(),
payload: "{}".into(),
})
.collect::<Vec<_>>();
let manifest = serde_json::json!({
"version": 1,
"task": identity,
"predictions": predictions,
});
let path = store
.join("task-manifests")
.join("v1")
.join(format!("{identity}.json"));
crate::util::write_atomic(&path, &serde_json::to_vec(&manifest).unwrap()).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 a_blocked_unrooted_object_does_not_cost_a_rooted_one() {
let locked = PathBuf::from("locked-unrooted");
let removable = PathBuf::from("removable-unrooted");
let protected = PathBuf::from("rooted");
let objects = [&locked, &removable, &protected]
.into_iter()
.map(|path| Entry {
path: path.clone(),
size: 10,
used: SystemTime::UNIX_EPOCH,
})
.collect::<Vec<_>>();
let rooted = HashSet::from([protected.clone()]);
let mut attempted = Vec::new();
let outcome = evict_objects(&objects, &rooted, 30, 5, |path| {
attempted.push(path.to_path_buf());
Ok(if path == locked {
Removal::Blocked
} else {
Removal::Removed
})
})
.unwrap();
assert_eq!(attempted, vec![locked, removable]);
assert_eq!(
outcome,
ObjectEvictions {
removed_objects: 1,
removed_bytes: 10,
remaining_bytes: 20,
},
"a locked unrooted blob should leave the store over budget before protected objects are evicted"
);
}
#[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 keeps_an_action_result_whose_blob_is_present_but_corrupt() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
let action = store_object(store, b"action key");
LocalActionCache::new(store)
.store(&RemoteActionResult {
version: 1,
action: action.clone(),
metadata: None,
output_root: None,
})
.unwrap();
let path = LocalCas::new(store).path_for(&action).unwrap();
std::fs::write(&path, b"corrupted!").unwrap();
let outcome = gc(store, u64::MAX).unwrap();
assert_eq!(outcome.removed_action_results, 0);
assert!(path.exists());
}
#[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()
);
}
#[test]
fn evicts_objects_no_live_checkout_needs_before_older_rooted_ones() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let live = directory.path().join("live");
let deleted = directory.path().join("deleted");
std::fs::create_dir_all(&live).unwrap();
std::fs::create_dir_all(&deleted).unwrap();
let kept = store_object(&store, b"0123456789");
let dropped = store_object(&store, b"abcdefghij");
record_build(
&store,
&"a".repeat(64),
&live,
&[store_result(
&store,
"live action",
std::slice::from_ref(&kept),
)],
);
record_build(
&store,
&"b".repeat(64),
&deleted,
&[store_result(
&store,
"deleted action",
std::slice::from_ref(&dropped),
)],
);
age(&store, &kept, Duration::from_secs(60 * 60));
age(&store, &dropped, Duration::from_secs(1));
std::fs::remove_dir_all(&deleted).unwrap();
gc(&store, stats(&store).unwrap().total_bytes() - 10).unwrap();
let cas = LocalCas::new(&store);
assert!(
cas.find(&kept).unwrap().is_some(),
"a live checkout still needs this object"
);
assert!(
cas.find(&dropped).unwrap().is_none(),
"nothing that still exists needs this object"
);
}
#[test]
fn keeps_rooting_when_a_sibling_worktree_survives() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let first = directory.path().join("first");
let second = directory.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
let identity = "c".repeat(64);
let shared = store_object(&store, b"shared artifact");
let action = store_result(&store, "shared action", std::slice::from_ref(&shared));
record_build(&store, &identity, &first, std::slice::from_ref(&action));
record_build(&store, &identity, &second, &[action]);
let spare = store_object(&store, b"unrooted spare");
std::fs::remove_dir_all(&second).unwrap();
let outcome = gc(&store, stats(&store).unwrap().total_bytes() - 1).unwrap();
assert_eq!(outcome.removed_checkout_records, 1);
let cas = LocalCas::new(&store);
assert!(cas.find(&shared).unwrap().is_some());
assert!(cas.find(&spare).unwrap().is_none());
}
#[test]
fn roots_objects_nested_in_an_output_tree() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let checkout = directory.path().join("checkout");
std::fs::create_dir_all(&checkout).unwrap();
let artifact = store_object(&store, b"the compiled artifact");
let action = store_result(&store, "an action", std::slice::from_ref(&artifact));
record_build(&store, &"d".repeat(64), &checkout, &[action]);
let spare = store_object(&store, b"unrooted spare");
gc(&store, stats(&store).unwrap().total_bytes() - 1).unwrap();
let cas = LocalCas::new(&store);
assert!(cas.find(&artifact).unwrap().is_some());
assert!(cas.find(&spare).unwrap().is_none());
}
#[test]
fn treats_a_store_with_no_checkout_records_as_plain_lru() {
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));
gc(store, 10).unwrap();
let cas = LocalCas::new(store);
assert!(cas.find(&old).unwrap().is_none());
assert!(cas.find(&recent).unwrap().is_some());
}
#[test]
fn drops_checkout_records_whose_worktree_is_gone() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let gone = directory.path().join("gone");
std::fs::create_dir_all(&gone).unwrap();
record_checkout(&store, &"e".repeat(64), &gone, &gone.join("target")).unwrap();
assert_eq!(stats(&store).unwrap().live_checkouts, 1);
std::fs::remove_dir_all(&gone).unwrap();
let after = stats(&store).unwrap();
assert_eq!(after.stale_checkouts, 1);
assert_eq!(after.live_checkouts, 0);
let outcome = gc(&store, u64::MAX).unwrap();
assert_eq!(outcome.removed_checkout_records, 1);
assert_eq!(stats(&store).unwrap(), StoreStats::default());
}
#[test]
fn keeps_a_checkout_recorded_while_its_worktree_exists() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let live = directory.path().join("live");
std::fs::create_dir_all(&live).unwrap();
record_checkout(&store, &"f".repeat(64), &live, &live.join("target")).unwrap();
let outcome = gc(&store, u64::MAX).unwrap();
assert_eq!(outcome.removed_checkout_records, 0);
assert_eq!(stats(&store).unwrap().live_checkouts, 1);
}
#[test]
fn forgets_a_claim_no_build_has_renewed() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let checkout = directory.path().join("checkout");
std::fs::create_dir_all(&checkout).unwrap();
let identity = "2".repeat(64);
let orphan = store_object(&store, b"what that command built");
record_build(
&store,
&identity,
&checkout,
&[store_result(
&store,
"stale action",
std::slice::from_ref(&orphan),
)],
);
let stale = CheckoutRecord {
version: CHECKOUT_RECORD_VERSION,
workspace_root: checkout.clone(),
target_dir: checkout.join("target"),
updated_secs: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- CHECKOUT_RETENTION.as_secs()
- 1,
};
crate::util::write_atomic(
&checkout_record_path(&store, &identity, &checkout),
&serde_json::to_vec(&stale).unwrap(),
)
.unwrap();
assert_eq!(stats(&store).unwrap().stale_checkouts, 1);
gc(&store, stats(&store).unwrap().total_bytes() - 1).unwrap();
assert!(
LocalCas::new(&store).find(&orphan).unwrap().is_none(),
"an expired claim roots nothing"
);
}
#[test]
fn ignores_what_it_did_not_write_beside_the_checkout_records() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let checkout = directory.path().join("project");
std::fs::create_dir_all(&checkout).unwrap();
record_checkout(&store, &"3".repeat(64), &checkout, &checkout.join("target")).unwrap();
let intruder = store.join(CHECKOUTS_DIR).join("4".repeat(64));
std::fs::write(&intruder, b"not a directory of records").unwrap();
std::fs::write(store.join(CHECKOUTS_DIR).join("notes.txt"), b"nor this").unwrap();
assert_eq!(stats(&store).unwrap().live_checkouts, 1);
assert_eq!(gc(&store, u64::MAX).unwrap().removed_checkout_records, 0);
assert!(intruder.exists(), "and nothing of theirs is deleted either");
}
#[test]
fn keeps_a_checkout_whose_absence_it_cannot_corroborate() {
let directory = tempfile::tempdir().unwrap();
let parent = directory.path().join("worktrees");
std::fs::create_dir_all(&parent).unwrap();
assert!(!checkout_is_live(&parent.join("removed")));
assert!(checkout_is_live(
&directory.path().join("gone/deeper/still")
));
assert!(checkout_is_live(directory.path()));
}
#[test]
fn sweeps_only_once_within_the_interval() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path();
store_object(store, b"kept");
assert!(
sweep_if_due(store, u64::MAX, Duration::from_secs(3600))
.unwrap()
.is_some()
);
assert!(
sweep_if_due(store, u64::MAX, Duration::from_secs(3600))
.unwrap()
.is_none(),
"the interval has not passed"
);
assert!(
sweep_if_due(store, u64::MAX, Duration::ZERO)
.unwrap()
.is_some(),
"a zero interval always sweeps"
);
}
#[test]
fn does_not_count_its_own_bookkeeping_against_the_budget() {
let directory = tempfile::tempdir().unwrap();
let store = directory.path().join("store");
let checkout = directory.path().join("checkout");
std::fs::create_dir_all(&checkout).unwrap();
record_checkout(&store, &"1".repeat(64), &checkout, &checkout.join("target")).unwrap();
sweep_if_due(&store, u64::MAX, Duration::ZERO).unwrap();
assert_eq!(stats(&store).unwrap().total_bytes(), 0);
}
}