Skip to main content

mbx_cache_store/
lib.rs

1//! Store inspection and garbage collection.
2//!
3//! The store holds five trees: `cas/v1` for content-addressed objects,
4//! `action-results/v1` for the results that reference them, `task-manifests/v1`
5//! for the prediction index, `checkouts/v1` for the checkouts that have built
6//! each identity, and `sessions/v1` for per-build event streams. Only the first
7//! two are collected for size; manifests are small and make cold builds fast,
8//! checkout records expire with their checkout claims, and session streams are
9//! bounded by age and count because they are history rather than cache content.
10
11mod events;
12
13use eyre::{Context, Result};
14use mbx_cache_core::{
15    CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata, is_task_identity,
16    task_manifest_actions,
17};
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet, HashSet};
20use std::path::{Path, PathBuf};
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23const CAS_DIR: &str = "cas/v1";
24const ACTION_RESULTS_DIR: &str = "action-results/v1";
25const CHECKOUTS_DIR: &str = "checkouts/v1";
26const SWEEP_STAMP: &str = "gc/v1/last-sweep";
27const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
28const CHECKOUT_RECORD_VERSION: u8 = 1;
29
30const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
31const MAX_SESSIONS: usize = 256;
32
33/// How long a checkout's claim outlives the last build that renewed it.
34///
35/// Existence alone is not enough to keep a claim alive. An identity covers one
36/// exact command line against one exact lockfile, so a checkout that lives for
37/// years accumulates an identity per lockfile it ever had -- and every one of
38/// them would go on rooting the actions of a build nobody will run again.
39/// Without this bound the rooted set only grows, until everything is rooted and
40/// the ordering means nothing. A build renews the claims it uses, so anything
41/// this stale belongs to a command that has moved on.
42const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
43
44#[derive(Debug, Default, PartialEq, Eq)]
45pub struct StoreStats {
46    pub objects: u64,
47    pub object_bytes: u64,
48    pub action_results: u64,
49    pub action_result_bytes: u64,
50    pub live_checkouts: u64,
51    /// Claims that root nothing any more: a checkout that is gone, or one that
52    /// is still there but has not renewed this claim inside the retention
53    /// window. Reported as stale rather than gone because those are different
54    /// things and only one of them means the directory is missing.
55    pub stale_checkouts: u64,
56}
57
58impl StoreStats {
59    pub fn total_bytes(&self) -> u64 {
60        self.object_bytes.saturating_add(self.action_result_bytes)
61    }
62}
63
64#[derive(Debug, Default, PartialEq, Eq)]
65pub struct GcOutcome {
66    pub removed_objects: u64,
67    pub removed_action_results: u64,
68    pub removed_checkout_records: u64,
69    /// Event streams dropped on age or count. These bytes are not included in
70    /// `remaining_bytes` because session history is not cache content.
71    pub removed_session_streams: u64,
72    pub removed_bytes: u64,
73    pub remaining_bytes: u64,
74}
75
76#[derive(Debug, PartialEq, Eq)]
77pub struct ProjectUsage {
78    pub workspace_root: PathBuf,
79    pub identities: u64,
80    pub action_bytes: u64,
81    pub target_bytes: u64,
82    pub live: bool,
83}
84
85#[derive(Debug, PartialEq, Eq)]
86pub struct LargestEntry {
87    pub kind: &'static str,
88    pub path: PathBuf,
89    pub bytes: u64,
90}
91
92#[derive(Debug, Default, PartialEq, Eq)]
93pub struct VerifyOutcome {
94    pub checked_objects: u64,
95    pub checked_action_results: u64,
96    pub problems: Vec<PathBuf>,
97}
98
99#[derive(Debug, Default, PartialEq, Eq)]
100pub struct RemoveProjectOutcome {
101    pub removed_checkout_records: u64,
102}
103
104/// One checkout's claim on the actions a build identity recorded.
105///
106/// The target directory is not read back by anything yet; it is recorded
107/// because it is the other half of what the shim mapped, and a record that
108/// names only half of it would have to be rewritten to answer where the
109/// artifacts went.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112struct CheckoutRecord {
113    version: u8,
114    workspace_root: PathBuf,
115    target_dir: PathBuf,
116    updated_secs: u64,
117}
118
119/// Summarize what the store currently holds.
120pub fn stats(store: &Path) -> Result<StoreStats> {
121    let objects = walk_files(&store.join(CAS_DIR))?;
122    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
123    let checkouts = scan_checkouts(store)?;
124    Ok(StoreStats {
125        objects: objects.len() as u64,
126        object_bytes: objects.iter().map(|entry| entry.size).sum(),
127        action_results: results.len() as u64,
128        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
129        live_checkouts: checkouts.live_records,
130        stale_checkouts: checkouts.stale_records.len() as u64,
131    })
132}
133
134/// Size the target tree a checkout record names, if it names one.
135///
136/// A build with no target directory of its own -- one driven by make or CMake,
137/// which writes wherever it was told -- records the checkout in that field
138/// rather than inventing a directory. Walking it would report the whole source
139/// tree, `.git` included, as though it were build output.
140fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
141    if record.target_dir == record.workspace_root {
142        return 0;
143    }
144    cached_tree_bytes(sizes, &record.target_dir)
145}
146
147/// Attribute cache and target bytes to each recorded workspace.
148///
149/// Shared objects are counted for every workspace that can reach them. That
150/// makes each row answer "how much keeps this workspace warm" without
151/// pretending shared storage can be divided exactly between projects.
152pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
153    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
154    let mut target_sizes = BTreeMap::new();
155    let root = store.join(CHECKOUTS_DIR);
156    for identity_entry in read_dir_or_empty(&root)? {
157        let identity = identity_entry.file_name().to_string_lossy().into_owned();
158        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
159            continue;
160        }
161        for entry in walk_files(&identity_entry.path())? {
162            let Some(record) = read_checkout_record(&entry.path) else {
163                continue;
164            };
165            let project = projects.entry(record.workspace_root.clone()).or_default();
166            if claim_is_live(&record) {
167                project.0.insert(identity.clone());
168                project.1 = true;
169            }
170            project.2 = project
171                .2
172                .max(checkout_target_bytes(&mut target_sizes, &record));
173        }
174    }
175    let action_cache = mbx_cache_core::LocalActionCache::new(store);
176    let mut usages = Vec::new();
177    for (workspace_root, (identities, live, target_bytes)) in projects {
178        let mut paths = rooted_objects(store, &identities)?;
179        for identity in &identities {
180            for action in task_manifest_actions(store, identity).unwrap_or_default() {
181                if let Ok(path) = action_cache.path_for(&action) {
182                    paths.insert(path);
183                }
184            }
185        }
186        let action_bytes = paths
187            .iter()
188            .filter_map(|path| std::fs::metadata(path).ok())
189            .map(|metadata| metadata.len())
190            .sum();
191        usages.push(ProjectUsage {
192            workspace_root,
193            identities: identities.len() as u64,
194            action_bytes,
195            target_bytes,
196            live,
197        });
198    }
199    usages.sort_by(|left, right| {
200        right
201            .action_bytes
202            .saturating_add(right.target_bytes)
203            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
204            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
205    });
206    Ok(usages)
207}
208
209/// Return the largest blobs and action-result records in descending order.
210pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
211    let mut entries = Vec::new();
212    for (kind, root, action_result) in [
213        ("object", store.join(CAS_DIR), false),
214        ("action result", store.join(ACTION_RESULTS_DIR), true),
215    ] {
216        entries.extend(
217            walk_files(&root)?
218                .into_iter()
219                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
220                .map(|entry| LargestEntry {
221                    kind,
222                    path: entry.path,
223                    bytes: entry.size,
224                }),
225        );
226    }
227    entries.sort_by(|left, right| {
228        right
229            .bytes
230            .cmp(&left.bytes)
231            .then_with(|| left.path.cmp(&right.path))
232    });
233    entries.truncate(limit);
234    Ok(entries)
235}
236
237/// Verify every addressable CAS object and action-result record.
238pub fn verify(store: &Path) -> Result<VerifyOutcome> {
239    let cas = LocalCas::new(store);
240    let action_cache = mbx_cache_core::LocalActionCache::new(store);
241    // Verification reports the store as it is on disk and removes nothing, so
242    // no object is pending removal the way one is mid-sweep.
243    let virtually_removed = HashSet::new();
244    let mut outcome = VerifyOutcome::default();
245    for entry in walk_files(&store.join(CAS_DIR))? {
246        let Some(digest) = addressed_digest(store, &entry.path, false) else {
247            continue;
248        };
249        outcome.checked_objects += 1;
250        if !matches!(cas.find(&digest), Ok(Some(_))) {
251            outcome.problems.push(entry.path);
252        }
253    }
254    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
255        let Some(digest) = addressed_digest(store, &entry.path, true) else {
256            continue;
257        };
258        outcome.checked_action_results += 1;
259        if !matches!(action_cache.find(&digest), Ok(Some(_)))
260            || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
261        {
262            outcome.problems.push(entry.path);
263        }
264    }
265    outcome.problems.sort();
266    Ok(outcome)
267}
268
269/// Remove checkout claims belonging to exactly one workspace.
270pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
271    let mut outcome = RemoveProjectOutcome::default();
272    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
273        if read_checkout_record(&entry.path)
274            .is_some_and(|record| record.workspace_root == workspace_root)
275        {
276            std::fs::remove_file(&entry.path)?;
277            outcome.removed_checkout_records += 1;
278            if let Some(parent) = entry.path.parent() {
279                let _ = std::fs::remove_dir(parent);
280            }
281        }
282    }
283    Ok(outcome)
284}
285
286/// Record that `workspace_root` built `identity`.
287///
288/// Identities are shared by every checkout of one dependency graph, so this is
289/// what later tells the collector whether anything still needs what a build
290/// cached: one file per checkout under the identity it built. Writing a whole
291/// file per checkout rather than merging a list into one keeps concurrent
292/// builds out of each other's way -- there is nothing to merge, so there is no
293/// lock and no lost update.
294///
295/// A build with no target directory of its own passes `workspace_root` for
296/// `target_dir`, which reads as "none" rather than as a tree to measure.
297pub fn record_checkout(
298    store: &Path,
299    identity: &str,
300    workspace_root: &Path,
301    target_dir: &Path,
302) -> Result<()> {
303    let record = CheckoutRecord {
304        version: CHECKOUT_RECORD_VERSION,
305        workspace_root: workspace_root.to_path_buf(),
306        target_dir: target_dir.to_path_buf(),
307        updated_secs: SystemTime::now()
308            .duration_since(UNIX_EPOCH)
309            .map(|since| since.as_secs())
310            .unwrap_or_default(),
311    };
312    let mut contents = serde_json::to_vec(&record)?;
313    contents.push(b'\n');
314    write_atomic(
315        &checkout_record_path(store, identity, workspace_root),
316        &contents,
317    )
318}
319
320fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
321    // The path is hashed rather than escaped: it is only ever compared against
322    // the same hash of the same path, and every filesystem in play disagrees
323    // about which characters a name may hold.
324    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
325    store
326        .join(CHECKOUTS_DIR)
327        .join(identity)
328        .join(format!("{key}.json"))
329}
330
331/// Evict objects until the store fits within `max_bytes`.
332///
333/// Objects no live checkout can reach are evicted first, so deleting a worktree
334/// releases what only that worktree needed. Within each class eviction is
335/// least-recently-used by access time where the filesystem records it, falling
336/// back to modification time. A restore verifies the blob it serves, which
337/// means it reads the whole file and the access time is real -- but only where
338/// the mount records one, so the ordering is an approximation either way.
339/// Evicting a live object costs a recompile, never correctness.
340pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
341    gc_with_mode(store, max_bytes, false)
342}
343
344/// Describe the collection `gc` would perform without changing the store.
345pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
346    gc_with_mode(store, max_bytes, true)
347}
348
349fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
350    let mut objects = walk_files(&store.join(CAS_DIR))?;
351    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
352    let mut live_bytes = objects
353        .iter()
354        .chain(results.iter())
355        .map(|entry| entry.size)
356        .sum::<u64>();
357
358    let mut outcome = GcOutcome::default();
359
360    // Prune before deciding what is rooted, so a checkout deleted since the
361    // last sweep stops protecting its artifacts during this one.
362    let checkouts = scan_checkouts(store)?;
363    for path in &checkouts.stale_records {
364        if dry_run || matches!(remove(path)?, Removal::Removed) {
365            outcome.removed_checkout_records += 1;
366        }
367        // The identity directory is left behind empty otherwise. It may hold
368        // other checkouts, in which case this fails and that is the answer.
369        if !dry_run && let Some(parent) = path.parent() {
370            let _ = std::fs::remove_dir(parent);
371        }
372    }
373
374    let mut virtually_removed = HashSet::new();
375    if live_bytes > max_bytes {
376        // Reachability costs a read per action result and per output tree, so
377        // it is computed only when something is actually about to be evicted.
378        // Under budget a sweep stays a directory walk and nothing more.
379        let rooted = rooted_objects(store, &checkouts.live_identities)?;
380        // `false` sorts first, so unrooted objects go before rooted ones and
381        // each class goes oldest-first. A store with no records at all roots
382        // nothing and this is exactly the LRU it was before.
383        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
384        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
385            if dry_run {
386                virtually_removed.insert(path.to_path_buf());
387                Ok(Removal::Removed)
388            } else {
389                remove(path)
390            }
391        })?;
392        live_bytes = evicted.remaining_bytes;
393        outcome.removed_objects += evicted.removed_objects;
394        outcome.removed_bytes += evicted.removed_bytes;
395    }
396
397    // An action result whose objects are gone can only produce a miss, so drop
398    // it rather than leave the index pointing at nothing. This runs whether or
399    // not this call evicted anything, since another process may have, and a
400    // store that is over budget on results alone still needs the sweep.
401    let cas = LocalCas::new(store);
402    for entry in &results {
403        if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
404            match if dry_run {
405                Removal::Removed
406            } else {
407                remove(&entry.path)?
408            } {
409                Removal::Removed => {
410                    live_bytes = live_bytes.saturating_sub(entry.size);
411                    outcome.removed_action_results += 1;
412                    outcome.removed_bytes += entry.size;
413                }
414                Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
415                Removal::Blocked => {}
416            }
417        }
418    }
419
420    let sessions = prune_sessions(store, dry_run)?;
421    outcome.removed_session_streams += sessions.removed_streams;
422    outcome.removed_bytes += sessions.removed_bytes;
423
424    outcome.remaining_bytes = live_bytes;
425    Ok(outcome)
426}
427
428#[derive(Debug, Default, PartialEq, Eq)]
429struct SessionPrune {
430    removed_streams: u64,
431    removed_bytes: u64,
432}
433
434fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
435    let mut prune = SessionPrune::default();
436    let ids = events::session_ids(store);
437    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
438    for (index, id) in ids.iter().enumerate() {
439        let paths = events::session_paths(store, id);
440        let metadata = match std::fs::metadata(&paths.events) {
441            Ok(metadata) => metadata,
442            Err(_) => continue,
443        };
444        let stale = metadata
445            .modified()
446            .ok()
447            .and_then(|at| SystemTime::now().duration_since(at).ok())
448            .is_some_and(|age| age > SESSION_RETENTION);
449        if index >= surplus && !stale {
450            continue;
451        }
452        if events::session_is_live(store, id) {
453            continue;
454        }
455        if dry_run {
456            prune.removed_streams += 1;
457            prune.removed_bytes += metadata.len();
458            continue;
459        }
460        if matches!(remove(&paths.events)?, Removal::Removed) {
461            prune.removed_streams += 1;
462            prune.removed_bytes += metadata.len();
463            let _ = std::fs::remove_file(&paths.lock);
464        }
465    }
466    if !dry_run {
467        for lock in events::orphaned_locks(store) {
468            let _ = std::fs::remove_file(lock);
469        }
470    }
471    Ok(prune)
472}
473
474/// Sweep the store if `interval` has passed since the last attempt.
475///
476/// The stamp is written before the sweep, not after. Two builds finishing
477/// together should cost one sweep between them, and a sweep that dies partway
478/// should wait its turn like any other rather than retry on every build.
479pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
480    if !claim_sweep(store, interval)? {
481        return Ok(None);
482    }
483    gc(store, max_bytes).map(Some)
484}
485
486/// Atomically stamp a due sweep before its callers perform coordinated GC.
487pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
488    let lock_path = store.join(SWEEP_LOCK);
489    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
490    let mut lock = fslock::LockFile::open(&lock_path)?;
491    lock.lock()?;
492
493    let stamp = store.join(SWEEP_STAMP);
494    if let Ok(metadata) = std::fs::metadata(&stamp)
495        && let Ok(modified) = metadata.modified()
496        && let Ok(since) = modified.elapsed()
497        && since < interval
498    {
499        return Ok(false);
500    }
501    write_atomic(&stamp, b"")?;
502    Ok(true)
503}
504
505/// What the checkout registry says about the identities in this store.
506struct CheckoutScan {
507    live_identities: BTreeSet<String>,
508    live_records: u64,
509    stale_records: Vec<PathBuf>,
510}
511
512fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
513    let root = store.join(CHECKOUTS_DIR);
514    let mut scan = CheckoutScan {
515        live_identities: BTreeSet::new(),
516        live_records: 0,
517        stale_records: Vec::new(),
518    };
519    let listing = match std::fs::read_dir(&root) {
520        Ok(listing) => listing,
521        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
522        Err(error) => {
523            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
524        }
525    };
526    for entry in listing {
527        let entry = entry?;
528        let name = entry.file_name().to_string_lossy().into_owned();
529        // Anything that is not a directory named for an identity was not written
530        // by mbx. Both halves matter: `walk_files` tolerates only a missing
531        // directory, so a plain file whose name happens to look like an identity
532        // would fail the whole scan -- and that scan runs inside every sweep,
533        // including the automatic one after a build.
534        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
535            continue;
536        }
537        for record in walk_files(&entry.path())? {
538            match read_checkout_record(&record.path) {
539                Some(checkout) if claim_is_live(&checkout) => {
540                    scan.live_identities.insert(name.clone());
541                    scan.live_records += 1;
542                }
543                Some(_) => scan.stale_records.push(record.path),
544                // A record this build cannot read claims nothing, but it is not
545                // evidence the checkout is gone either, so leave it alone.
546                None => {}
547            }
548        }
549    }
550    Ok(scan)
551}
552
553fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
554    let bytes = std::fs::read(path).ok()?;
555    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
556    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
557}
558
559/// Whether a recorded claim still speaks for a checkout that is using this
560/// store.
561fn claim_is_live(record: &CheckoutRecord) -> bool {
562    checkout_is_live(&record.workspace_root) && !claim_has_expired(record)
563}
564
565fn claim_has_expired(record: &CheckoutRecord) -> bool {
566    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
567        return false;
568    };
569    now.as_secs()
570        .saturating_sub(record.updated_secs)
571        .gt(&CHECKOUT_RETENTION.as_secs())
572}
573
574/// Whether the checkout a record names is still on disk.
575///
576/// Absence is believed only when the checkout is definitely absent *and* its
577/// parent directory is definitely still there. Deleting a worktree, or a whole
578/// project directory, leaves the parent behind; a volume being ejected or a
579/// network mount going away takes several levels with it, and that is the case
580/// worth being careful about -- a temporarily absent mount must not un-root a
581/// checkout that is really there.
582///
583/// So both questions are asked the same way: only a definite answer counts, and
584/// an error at either step means live. Being wrong in that direction only
585/// delays collection; the other way round throws away a warm cache someone is
586/// still using.
587pub fn checkout_is_live(workspace_root: &Path) -> bool {
588    if !matches!(workspace_root.try_exists(), Ok(false)) {
589        return true;
590    }
591    match workspace_root.parent() {
592        Some(parent) => !matches!(parent.try_exists(), Ok(true)),
593        // A root directory has no parent to corroborate anything with.
594        None => true,
595    }
596}
597
598/// CAS paths reachable from the builds that live checkouts still depend on.
599///
600/// Recursing into output trees is the point: the descriptor blobs are tiny and
601/// the leaf artifacts are where the bytes are, so rooting that stopped at the
602/// top level would protect nothing worth protecting. Every read here is
603/// tolerant -- a blob that has already been evicted, or that no longer parses,
604/// simply roots less, and rooting less can only mean collecting sooner.
605fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
606    let cas = LocalCas::new(store);
607    let mut rooted = HashSet::new();
608    let mut visited = BTreeSet::new();
609    for identity in identities {
610        // One manifest this build cannot read is not worth abandoning a sweep
611        // over. It roots nothing, which can only mean collecting sooner.
612        let actions = match task_manifest_actions(store, identity) {
613            Ok(actions) => actions,
614            Err(error) => {
615                log::debug!("could not read the manifest for {identity}: {error}");
616                continue;
617            }
618        };
619        for action in actions {
620            let Some(result) = read_action_result(store, &action) else {
621                continue;
622            };
623            root_digest(&cas, &mut rooted, &result.action);
624            if let Some(metadata) = &result.metadata {
625                root_digest(&cas, &mut rooted, metadata);
626                // The metadata blob is a descriptor too: it names the captured
627                // stdout and stderr, and a restore reads both. Stopping at the
628                // descriptor would leave the diagnostics of every action
629                // unrooted -- including the one empty blob that every silent
630                // compilation shares, which is enough on its own to turn a
631                // rooted hit back into a miss.
632                if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
633                    root_digest(&cas, &mut rooted, &rustc.stdout);
634                    root_digest(&cas, &mut rooted, &rustc.stderr);
635                }
636            }
637            if let Some(output_root) = &result.output_root {
638                root_digest(&cas, &mut rooted, output_root);
639                root_tree(&cas, output_root, &mut rooted, &mut visited);
640            }
641        }
642    }
643    Ok(rooted)
644}
645
646fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
647    let path = mbx_cache_core::LocalActionCache::new(store)
648        .path_for(action)
649        .ok()?;
650    let bytes = std::fs::read(path).ok()?;
651    serde_json::from_slice(&bytes).ok()
652}
653
654fn root_tree(
655    cas: &LocalCas,
656    output_root: &CacheDigest,
657    rooted: &mut HashSet<PathBuf>,
658    visited: &mut BTreeSet<CacheDigest>,
659) {
660    let mut pending = vec![output_root.clone()];
661    while let Some(digest) = pending.pop() {
662        if !visited.insert(digest.clone()) {
663            continue;
664        }
665        let Some(directory) = read_directory(cas, &digest) else {
666            continue;
667        };
668        for file in &directory.files {
669            root_digest(cas, rooted, &file.digest);
670        }
671        for child in &directory.directories {
672            root_digest(cas, rooted, &child.digest);
673            pending.push(child.digest.clone());
674        }
675    }
676}
677
678fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
679    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
680    serde_json::from_slice(&bytes).ok()
681}
682
683fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
684    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
685    serde_json::from_slice(&bytes).ok()
686}
687
688fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
689    if let Ok(path) = cas.path_for(digest) {
690        rooted.insert(path);
691    }
692}
693
694#[derive(Clone, Copy, Debug, PartialEq, Eq)]
695enum Removal {
696    Removed,
697    Missing,
698    Blocked,
699}
700
701fn remove(path: &Path) -> Result<Removal> {
702    match std::fs::remove_file(path) {
703        Ok(()) => Ok(Removal::Removed),
704        // A concurrent build may have evicted or replaced it already.
705        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
706        // Windows refuses to unlink a file another process holds open, which is
707        // what a concurrent build reading this blob looks like. Skipping it
708        // leaves the store over budget until the next sweep; failing would
709        // abandon the sweep and leave it over budget for longer.
710        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
711            log::debug!("could not evict {}: {error}", path.display());
712            Ok(Removal::Blocked)
713        }
714        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
715    }
716}
717
718#[derive(Debug, Default, PartialEq, Eq)]
719struct ObjectEvictions {
720    removed_objects: u64,
721    removed_bytes: u64,
722    remaining_bytes: u64,
723}
724
725/// Evict sorted objects without crossing the checkout-protection boundary
726/// when an unrooted object cannot be removed.
727fn evict_objects(
728    objects: &[Entry],
729    rooted: &HashSet<PathBuf>,
730    mut live_bytes: u64,
731    max_bytes: u64,
732    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
733) -> Result<ObjectEvictions> {
734    let mut outcome = ObjectEvictions::default();
735    let mut blocked_unrooted = false;
736    for entry in objects {
737        if live_bytes <= max_bytes {
738            break;
739        }
740        let is_rooted = rooted.contains(&entry.path);
741        if is_rooted && blocked_unrooted {
742            // A locked unrooted blob is temporarily part of the irreducible
743            // store. Deleting a live checkout's artifacts in its place would
744            // invert the protection ordering this collector promises.
745            break;
746        }
747        match remove_file(&entry.path)? {
748            Removal::Removed => {
749                live_bytes = live_bytes.saturating_sub(entry.size);
750                outcome.removed_objects += 1;
751                outcome.removed_bytes += entry.size;
752            }
753            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
754            Removal::Blocked if !is_rooted => blocked_unrooted = true,
755            Removal::Blocked => {}
756        }
757    }
758    outcome.remaining_bytes = live_bytes;
759    Ok(outcome)
760}
761
762/// Whether an action result references an object the store no longer has.
763///
764/// The digests checked here are exactly the ones `LocalActionCache::store`
765/// requires. If the sweep checked fewer, eviction could leave an entry that
766/// cannot be republished -- `store` would reject the identical result for a
767/// missing blob while the index still claimed to hold it.
768///
769/// Presence is all that is checked, not content. Verifying would re-hash a
770/// large fraction of the store on every sweep, which a chore could afford and
771/// an automatic sweep cannot; it would also refresh the access time of every
772/// descriptor blob while leaving the artifacts alone, biasing the very
773/// ordering this module depends on. A blob that is present but corrupt now
774/// keeps its result and turns into a miss on restore, and both CAS write paths
775/// republish over a corrupt blob rather than trusting it.
776///
777/// Only the top-level objects are checked; a result whose output tree lost a
778/// nested object still restores as a miss, which is safe.
779fn action_result_is_dangling(
780    cas: &LocalCas,
781    path: &Path,
782    virtually_removed: &HashSet<PathBuf>,
783) -> Result<bool> {
784    let bytes = match std::fs::read(path) {
785        Ok(bytes) => bytes,
786        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
787        Err(error) => {
788            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
789        }
790    };
791    // An unparseable result is already useless; the cache rejects it on read.
792    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
793        return Ok(false);
794    };
795    for digest in [
796        Some(&result.action),
797        result.metadata.as_ref(),
798        result.output_root.as_ref(),
799    ]
800    .into_iter()
801    .flatten()
802    {
803        match cas.path_for(digest) {
804            Ok(path) => {
805                if virtually_removed.contains(&path) || !path.exists() {
806                    return Ok(true);
807                }
808            }
809            // A digest the CAS cannot even address is not one it can hold.
810            Err(_) => return Ok(true),
811        }
812    }
813    Ok(false)
814}
815
816struct Entry {
817    path: PathBuf,
818    size: u64,
819    used: SystemTime,
820}
821
822fn walk_files(root: &Path) -> Result<Vec<Entry>> {
823    let mut entries = Vec::new();
824    let mut pending = vec![root.to_path_buf()];
825    while let Some(directory) = pending.pop() {
826        let listing = match std::fs::read_dir(&directory) {
827            Ok(listing) => listing,
828            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
829            Err(error) => {
830                return Err(error)
831                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
832            }
833        };
834        for entry in listing {
835            let entry = entry?;
836            let metadata = match entry.metadata() {
837                Ok(metadata) => metadata,
838                // A concurrent build may be publishing into the store.
839                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
840                Err(error) => return Err(error.into()),
841            };
842            if metadata.is_dir() {
843                pending.push(entry.path());
844            } else if metadata.is_file() {
845                let used = metadata
846                    .accessed()
847                    .or_else(|_| metadata.modified())
848                    .unwrap_or(SystemTime::UNIX_EPOCH);
849                entries.push(Entry {
850                    path: entry.path(),
851                    size: metadata.len(),
852                    used,
853                });
854            }
855        }
856    }
857    Ok(entries)
858}
859
860fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
861    match std::fs::read_dir(root) {
862        Ok(entries) => entries
863            .collect::<std::io::Result<Vec<_>>>()
864            .map_err(Into::into),
865        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
866        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
867    }
868}
869
870fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
871    let name = path.file_name()?.to_str()?;
872    let name = if action_result {
873        name.strip_suffix(".json")?
874    } else {
875        name
876    };
877    let (hash, size) = name.rsplit_once('-')?;
878    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
879    let digest = CacheDigest {
880        algorithm,
881        hash: hash.to_string(),
882        size: size.parse().ok()?,
883    };
884    digest.validate().ok()?;
885    Some(digest)
886}
887
888fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
889    let digest = digest_from_path(path, action_result)?;
890    let canonical = if action_result {
891        mbx_cache_core::LocalActionCache::new(store)
892            .path_for(&digest)
893            .ok()?
894    } else {
895        LocalCas::new(store).path_for(&digest).ok()?
896    };
897    (canonical == path).then_some(digest)
898}
899
900fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
901    *cache
902        .entry(root.to_path_buf())
903        .or_insert_with(|| tree_bytes(root))
904}
905
906fn tree_bytes(root: &Path) -> u64 {
907    let Ok(root_metadata) = std::fs::metadata(root) else {
908        return 0;
909    };
910    if root_metadata.is_file() {
911        return root_metadata.len();
912    }
913    let Ok(entries) = std::fs::read_dir(root) else {
914        return 0;
915    };
916    let mut bytes = 0_u64;
917    let mut pending = entries
918        .filter_map(std::result::Result::ok)
919        .map(|entry| entry.path())
920        .collect::<Vec<_>>();
921    while let Some(path) = pending.pop() {
922        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
923            continue;
924        };
925        if metadata.is_file() {
926            bytes = bytes.saturating_add(metadata.len());
927        } else if metadata.is_dir()
928            && let Ok(entries) = std::fs::read_dir(path)
929        {
930            pending.extend(
931                entries
932                    .filter_map(std::result::Result::ok)
933                    .map(|entry| entry.path()),
934            );
935        }
936    }
937    bytes
938}
939
940#[cfg(test)]
941#[path = "store_tests.rs"]
942mod tests;
943
944fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
945    let parent = path
946        .parent()
947        .filter(|parent| !parent.as_os_str().is_empty())
948        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
949    std::fs::create_dir_all(parent)
950        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
951    let mut temporary = tempfile::Builder::new()
952        .prefix(".mbx-")
953        .tempfile_in(parent)?;
954    use std::io::Write as _;
955    temporary.write_all(contents)?;
956    temporary.as_file_mut().sync_all()?;
957    temporary
958        .persist(path)
959        .map_err(|error| error.error)
960        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
961    Ok(())
962}