Skip to main content

mbx_cache_store/
lib.rs

1//! Store inspection and garbage collection.
2//!
3//! The store holds six 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, `build-receipts/v1` for exact completed build closures, and
7//! `sessions/v1` for per-build event streams. Only the first two are collected
8//! for size; manifests and receipts are small, checkout records expire with
9//! their checkout claims, and session streams are bounded by age and count
10//! because they are history rather than cache content.
11
12mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16    ActionPrediction, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata,
17    TaskActionManifest, is_task_identity, task_manifest_actions,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashSet};
21use std::path::{Component, Path, PathBuf};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24const CAS_DIR: &str = "cas/v1";
25const ACTION_RESULTS_DIR: &str = "action-results/v1";
26const CHECKOUTS_DIR: &str = "checkouts/v1";
27const SWEEP_STAMP: &str = "gc/v1/last-sweep";
28const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
29const CHECKOUT_RECORD_VERSION: u8 = 1;
30const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
31const BUILD_RECEIPT_VERSION: u8 = 1;
32const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
33const EXPORT_VERSION: u8 = 1;
34
35const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
36const MAX_SESSIONS: usize = 256;
37
38/// How long a checkout's claim outlives the last build that renewed it.
39///
40/// Existence alone is not enough to keep a claim alive. An identity covers one
41/// exact command line against one exact lockfile, so a checkout that lives for
42/// years accumulates an identity per lockfile it ever had -- and every one of
43/// them would go on rooting the actions of a build nobody will run again.
44/// Without this bound the rooted set only grows, until everything is rooted and
45/// the ordering means nothing. A build renews the claims it uses, so anything
46/// this stale belongs to a command that has moved on.
47const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
48
49#[derive(Debug, Default, PartialEq, Eq)]
50pub struct StoreStats {
51    pub objects: u64,
52    pub object_bytes: u64,
53    pub action_results: u64,
54    pub action_result_bytes: u64,
55    pub live_checkouts: u64,
56    /// Claims that root nothing any more: a checkout that is gone, or one that
57    /// is still there but has not renewed this claim inside the retention
58    /// window. Reported as stale rather than gone because those are different
59    /// things and only one of them means the directory is missing.
60    pub stale_checkouts: u64,
61}
62
63impl StoreStats {
64    pub fn total_bytes(&self) -> u64 {
65        self.object_bytes.saturating_add(self.action_result_bytes)
66    }
67}
68
69#[derive(Debug, Default, PartialEq, Eq)]
70pub struct GcOutcome {
71    pub removed_objects: u64,
72    pub removed_action_results: u64,
73    pub removed_checkout_records: u64,
74    /// Event streams dropped on age or count. These bytes are not included in
75    /// `remaining_bytes` because session history is not cache content.
76    pub removed_session_streams: u64,
77    pub removed_bytes: u64,
78    pub remaining_bytes: u64,
79}
80
81#[derive(Debug, PartialEq, Eq)]
82pub struct ProjectUsage {
83    pub workspace_root: PathBuf,
84    pub identities: u64,
85    pub action_bytes: u64,
86    pub target_bytes: u64,
87    pub live: bool,
88}
89
90#[derive(Debug, PartialEq, Eq)]
91pub struct LargestEntry {
92    pub kind: &'static str,
93    pub path: PathBuf,
94    pub bytes: u64,
95}
96
97#[derive(Debug, Default, PartialEq, Eq)]
98pub struct VerifyOutcome {
99    pub checked_objects: u64,
100    pub checked_action_results: u64,
101    pub problems: Vec<PathBuf>,
102}
103
104#[derive(Debug, Default, PartialEq, Eq)]
105pub struct RemoveProjectOutcome {
106    pub removed_checkout_records: u64,
107}
108
109#[derive(Debug, Default, PartialEq, Eq)]
110pub struct TransferOutcome {
111    pub actions: u64,
112    pub objects: u64,
113    pub bytes: u64,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118struct ExportManifest {
119    version: u8,
120    tasks: Vec<TaskActionManifest>,
121    actions: Vec<CacheDigest>,
122}
123
124/// The exact cache predictions completed by one top-level build command.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127struct BuildReceipt {
128    version: u8,
129    workspace_root: PathBuf,
130    identity: String,
131    completed_nanos: u64,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    group: Option<String>,
134    predictions: Vec<ActionPrediction>,
135}
136
137/// Record an exact completed build for checkout and grouped exports.
138pub fn record_build_receipt(
139    store: &Path,
140    run: &str,
141    identity: &str,
142    workspace_root: &Path,
143    group: Option<&str>,
144    predictions: Vec<ActionPrediction>,
145) -> Result<()> {
146    if !is_task_identity(run) || !is_task_identity(identity) {
147        eyre::bail!("invalid build receipt identity");
148    }
149    if !(TaskActionManifest {
150        version: 1,
151        task: identity.to_owned(),
152        predictions: predictions.clone(),
153    })
154    .validate()
155    {
156        eyre::bail!("invalid build receipt prediction");
157    }
158    if let Some(group) = group {
159        validate_export_group(group)?;
160    }
161    let key = workspace_key(workspace_root);
162    let lock_path = store
163        .join(BUILD_RECEIPTS_DIR)
164        .join("locks")
165        .join(format!("{key}.lock"));
166    std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
167    let mut lock = fslock::LockFile::open(&lock_path)?;
168    lock.lock()?;
169    let completed_nanos = SystemTime::now()
170        .duration_since(UNIX_EPOCH)
171        .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
172        .unwrap_or_default();
173    let receipt = BuildReceipt {
174        version: BUILD_RECEIPT_VERSION,
175        workspace_root: workspace_root.to_path_buf(),
176        identity: identity.to_owned(),
177        completed_nanos,
178        group: group.map(str::to_owned),
179        predictions,
180    };
181    let bytes = serde_json::to_vec(&receipt)?;
182    write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
183    if let Some(group) = group {
184        write_atomic(&group_receipt_path(store, group, run), &bytes)?;
185    }
186    Ok(())
187}
188
189fn workspace_key(workspace_root: &Path) -> String {
190    CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
191}
192
193fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
194    store
195        .join(BUILD_RECEIPTS_DIR)
196        .join("checkouts")
197        .join(format!("{}.json", workspace_key(workspace_root)))
198}
199
200fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
201    store
202        .join(BUILD_RECEIPTS_DIR)
203        .join("groups")
204        .join(group_key(group))
205        .join(format!("{run}.json"))
206}
207
208fn group_key(group: &str) -> String {
209    CacheDigest::blake3(group.as_bytes()).hash
210}
211
212fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
213    let bytes = std::fs::read(path).ok()?;
214    let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
215    let valid = TaskActionManifest {
216        version: 1,
217        task: receipt.identity.clone(),
218        predictions: receipt.predictions.clone(),
219    }
220    .validate();
221    (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
222}
223
224fn validate_export_group(group: &str) -> Result<()> {
225    if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
226        eyre::bail!("invalid build export group");
227    }
228    Ok(())
229}
230
231/// Export the complete local cache closure of this checkout's most recent build.
232pub fn export_checkout(
233    store: &Path,
234    workspace_root: &Path,
235    archive: &Path,
236) -> Result<TransferOutcome> {
237    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
238        .filter(|receipt| receipt.workspace_root == workspace_root)
239        .ok_or_else(|| {
240            eyre::eyre!(
241                "no completed mbx build is recorded for {}",
242                workspace_root.display()
243            )
244        })?;
245    export_receipts(store, vec![receipt], archive)
246}
247
248/// Export the union of every completed build recorded under one CI group.
249pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
250    validate_export_group(group)?;
251    let root = store
252        .join(BUILD_RECEIPTS_DIR)
253        .join("groups")
254        .join(group_key(group));
255    let receipts = walk_files(&root)?
256        .into_iter()
257        .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
258        .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
259        .collect::<Vec<_>>();
260    if receipts.is_empty() {
261        eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
262    }
263    let outcome = export_receipts(
264        store,
265        receipts
266            .iter()
267            .map(|(_, receipt)| receipt.clone())
268            .collect(),
269        archive,
270    )?;
271    // A receipt is a pending-export root. Retire only the files this export
272    // consumed, and only after its complete archive has been published. A
273    // concurrent build can add another uniquely named receipt to the group
274    // without this cleanup deleting work the archive did not include.
275    for (path, _) in receipts {
276        std::fs::remove_file(path)?;
277    }
278    let _ = std::fs::remove_dir(root);
279    Ok(outcome)
280}
281
282fn export_receipts(
283    store: &Path,
284    mut receipts: Vec<BuildReceipt>,
285    archive: &Path,
286) -> Result<TransferOutcome> {
287    receipts.sort_by_key(|receipt| receipt.completed_nanos);
288    let mut actions = BTreeSet::new();
289    let mut tasks = BTreeMap::new();
290    for receipt in receipts {
291        actions.extend(
292            receipt
293                .predictions
294                .iter()
295                .map(|prediction| prediction.action.clone()),
296        );
297        // One task manifest can carry only one action per invocation. The
298        // newest run of the same task is the useful prediction set to import;
299        // every older action remains in `actions` and therefore in the bundle.
300        tasks.insert(
301            receipt.identity.clone(),
302            TaskActionManifest {
303                version: 1,
304                task: receipt.identity,
305                predictions: receipt.predictions,
306            },
307        );
308    }
309    let tasks = tasks.into_values().collect::<Vec<_>>();
310    let (objects, result_paths) = strict_closure(store, &actions)?;
311    let parent = archive
312        .parent()
313        .filter(|parent| !parent.as_os_str().is_empty())
314        .unwrap_or_else(|| Path::new("."));
315    std::fs::create_dir_all(parent)?;
316    let temporary = tempfile::Builder::new()
317        .prefix(".mbx-export-")
318        .tempfile_in(parent)?;
319    let mut builder = tar::Builder::new(temporary.reopen()?);
320    append_bytes(
321        &mut builder,
322        Path::new(EXPORT_MANIFEST),
323        &serde_json::to_vec(&ExportManifest {
324            version: EXPORT_VERSION,
325            tasks,
326            actions: actions.iter().cloned().collect(),
327        })?,
328    )?;
329    for path in objects.iter().chain(result_paths.iter()) {
330        append_file(&mut builder, store, path)?;
331    }
332    builder.finish()?;
333    drop(builder);
334    temporary
335        .persist(archive)
336        .map_err(|error| error.error)
337        .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
338    let bytes = std::fs::metadata(archive)?.len();
339    Ok(TransferOutcome {
340        actions: actions.len() as u64,
341        objects: objects.len() as u64,
342        bytes,
343    })
344}
345
346/// Validate a cache export in isolation, then publish its objects and actions.
347pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
348    let staging = tempfile::tempdir()?;
349    let file = std::fs::File::open(archive)
350        .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
351    let mut bundle = tar::Archive::new(file);
352    let mut seen = BTreeSet::new();
353    for entry in bundle.entries()? {
354        let mut entry = entry?;
355        let entry_type = entry.header().entry_type();
356        if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
357            eyre::bail!("cache export contains a non-file entry");
358        }
359        let path = entry.path()?.into_owned();
360        validate_archive_path(&path)?;
361        if !seen.insert(path.clone()) {
362            eyre::bail!("cache export contains duplicate entry {}", path.display());
363        }
364        let destination = staging.path().join(&path);
365        std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
366        // `unpack` understands GNU sparse maps. A plain stream copy expands
367        // holes into physical zeroes, which is both slower and much larger for
368        // Rust artifacts containing sparse sections.
369        entry.unpack(&destination)?;
370    }
371    let manifest: ExportManifest =
372        serde_json::from_slice(&std::fs::read(staging.path().join(EXPORT_MANIFEST))?)?;
373    let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
374    let task_identities = manifest
375        .tasks
376        .iter()
377        .map(|task| task.task.as_str())
378        .collect::<BTreeSet<_>>();
379    if manifest.version != EXPORT_VERSION
380        || manifest.tasks.is_empty()
381        || actions.len() != manifest.actions.len()
382        || task_identities.len() != manifest.tasks.len()
383        || manifest.tasks.iter().any(|task| !task.validate())
384        || manifest
385            .actions
386            .iter()
387            .any(|action| action.algorithm != "blake3" || action.validate().is_err())
388        || manifest.tasks.iter().any(|task| {
389            task.predictions
390                .iter()
391                .any(|prediction| !actions.contains(&prediction.action))
392        })
393    {
394        eyre::bail!("unsupported or invalid cache export manifest");
395    }
396    let (objects, result_paths) = strict_closure(staging.path(), &actions)
397        .wrap_err("cache export is incomplete or corrupt")?;
398
399    let cas = LocalCas::new(store);
400    for path in &objects {
401        let relative = path.strip_prefix(staging.path())?;
402        let source = staging.path().join(relative);
403        let digest = addressed_digest(staging.path(), &source, false)
404            .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
405        cas.store_file(&digest, &source)?;
406    }
407    let action_cache = mbx_cache_core::LocalActionCache::new(store);
408    for path in &result_paths {
409        let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)?;
410        action_cache.store(&result)?;
411    }
412    for task in manifest.tasks {
413        merge_imported_manifest(store, task)?;
414    }
415    Ok(TransferOutcome {
416        actions: actions.len() as u64,
417        objects: objects.len() as u64,
418        bytes: std::fs::metadata(archive)?.len(),
419    })
420}
421
422fn strict_closure(
423    store: &Path,
424    actions: &BTreeSet<CacheDigest>,
425) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
426    let cas = LocalCas::new(store);
427    let action_cache = mbx_cache_core::LocalActionCache::new(store);
428    let mut objects = BTreeSet::new();
429    let mut results = BTreeSet::new();
430    let mut directories = BTreeSet::new();
431    for action in actions {
432        let result = action_cache
433            .find(action)?
434            .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
435        results.insert(action_cache.path_for(action)?);
436        require_object(&cas, &mut objects, &result.action)?;
437        if let Some(metadata) = &result.metadata {
438            require_object(&cas, &mut objects, metadata)?;
439            let captured: CapturedOutput =
440                serde_json::from_slice(&std::fs::read(cas.path_for(metadata)?)?)
441                    .wrap_err("action metadata is invalid")?;
442            require_object(&cas, &mut objects, &captured.stdout)?;
443            require_object(&cas, &mut objects, &captured.stderr)?;
444        }
445        if let Some(root) = &result.output_root {
446            require_object(&cas, &mut objects, root)?;
447            let mut pending = vec![root.clone()];
448            while let Some(digest) = pending.pop() {
449                if !directories.insert(digest.clone()) {
450                    continue;
451                }
452                let directory: CacheDirectory =
453                    serde_json::from_slice(&std::fs::read(cas.path_for(&digest)?)?)
454                        .wrap_err("output directory is invalid")?;
455                for file in directory.files {
456                    require_object(&cas, &mut objects, &file.digest)?;
457                }
458                for child in directory.directories {
459                    require_object(&cas, &mut objects, &child.digest)?;
460                    pending.push(child.digest);
461                }
462            }
463        }
464    }
465    Ok((objects, results))
466}
467
468#[derive(Deserialize)]
469struct CapturedOutput {
470    stdout: CacheDigest,
471    stderr: CacheDigest,
472}
473
474fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
475    let identity = imported.task.clone();
476    let destination_path = task_manifest_path(destination, &identity);
477    if let Ok(bytes) = std::fs::read(&destination_path)
478        && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
479        && existing.task == identity
480        && existing.validate()
481    {
482        let mut predictions = imported
483            .predictions
484            .into_iter()
485            .map(|prediction| (prediction.invocation.clone(), prediction))
486            .collect::<BTreeMap<_, _>>();
487        predictions.extend(
488            existing
489                .predictions
490                .iter()
491                .cloned()
492                .map(|prediction| (prediction.invocation.clone(), prediction)),
493        );
494        imported.predictions = predictions.into_values().collect();
495        if !imported.validate() {
496            return Ok(());
497        }
498    }
499    write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
500}
501
502fn require_object(
503    cas: &LocalCas,
504    objects: &mut BTreeSet<PathBuf>,
505    digest: &CacheDigest,
506) -> Result<()> {
507    let path = cas
508        .find(digest)?
509        .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
510    objects.insert(path);
511    Ok(())
512}
513
514fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
515    store
516        .join("task-manifests/v1")
517        .join(format!("{identity}.json"))
518}
519
520fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
521    let name = path.strip_prefix(root)?;
522    builder.append_path_with_name(path, name)?;
523    Ok(())
524}
525
526fn append_bytes(
527    builder: &mut tar::Builder<std::fs::File>,
528    name: &Path,
529    bytes: &[u8],
530) -> Result<()> {
531    let mut header = tar::Header::new_gnu();
532    header.set_size(bytes.len() as u64);
533    header.set_mode(0o644);
534    header.set_cksum();
535    builder.append_data(&mut header, name, bytes)?;
536    Ok(())
537}
538
539fn validate_archive_path(path: &Path) -> Result<()> {
540    if path.as_os_str().is_empty()
541        || path.is_absolute()
542        || path
543            .components()
544            .any(|component| !matches!(component, Component::Normal(_)))
545    {
546        eyre::bail!("cache export contains unsafe path {}", path.display());
547    }
548    let text = path.to_string_lossy();
549    if text != EXPORT_MANIFEST
550        && !text.starts_with("cas/v1/")
551        && !text.starts_with("action-results/v1/")
552    {
553        eyre::bail!("cache export contains unexpected path {}", path.display());
554    }
555    Ok(())
556}
557
558/// One checkout's claim on the actions a build identity recorded.
559///
560/// The target directory is not read back by anything yet; it is recorded
561/// because it is the other half of what the shim mapped, and a record that
562/// names only half of it would have to be rewritten to answer where the
563/// artifacts went.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565#[serde(deny_unknown_fields)]
566struct CheckoutRecord {
567    version: u8,
568    workspace_root: PathBuf,
569    target_dir: PathBuf,
570    updated_secs: u64,
571}
572
573/// Summarize what the store currently holds.
574pub fn stats(store: &Path) -> Result<StoreStats> {
575    let objects = walk_files(&store.join(CAS_DIR))?;
576    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
577    let checkouts = scan_checkouts(store)?;
578    Ok(StoreStats {
579        objects: objects.len() as u64,
580        object_bytes: objects.iter().map(|entry| entry.size).sum(),
581        action_results: results.len() as u64,
582        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
583        live_checkouts: checkouts.live_records,
584        stale_checkouts: checkouts.stale_records.len() as u64,
585    })
586}
587
588/// Size the target tree a checkout record names, if it names one.
589///
590/// A build with no target directory of its own -- one driven by make or CMake,
591/// which writes wherever it was told -- records the checkout in that field
592/// rather than inventing a directory. Walking it would report the whole source
593/// tree, `.git` included, as though it were build output.
594fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
595    if record.target_dir == record.workspace_root {
596        return 0;
597    }
598    cached_tree_bytes(sizes, &record.target_dir)
599}
600
601/// Attribute cache and target bytes to each recorded workspace.
602///
603/// Shared objects are counted for every workspace that can reach them. That
604/// makes each row answer "how much keeps this workspace warm" without
605/// pretending shared storage can be divided exactly between projects.
606pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
607    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
608    let mut target_sizes = BTreeMap::new();
609    let root = store.join(CHECKOUTS_DIR);
610    for identity_entry in read_dir_or_empty(&root)? {
611        let identity = identity_entry.file_name().to_string_lossy().into_owned();
612        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
613            continue;
614        }
615        for entry in walk_files(&identity_entry.path())? {
616            let Some(record) = read_checkout_record(&entry.path) else {
617                continue;
618            };
619            let project = projects.entry(record.workspace_root.clone()).or_default();
620            if claim_is_live(store, &record) {
621                project.0.insert(identity.clone());
622                project.1 = true;
623            }
624            project.2 = project
625                .2
626                .max(checkout_target_bytes(&mut target_sizes, &record));
627        }
628    }
629    let action_cache = mbx_cache_core::LocalActionCache::new(store);
630    let mut usages = Vec::new();
631    for (workspace_root, (identities, live, target_bytes)) in projects {
632        let mut paths = rooted_objects(store, &identities)?;
633        for identity in &identities {
634            for action in task_manifest_actions(store, identity).unwrap_or_default() {
635                if let Ok(path) = action_cache.path_for(&action) {
636                    paths.insert(path);
637                }
638            }
639        }
640        let action_bytes = paths
641            .iter()
642            .filter_map(|path| std::fs::metadata(path).ok())
643            .map(|metadata| metadata.len())
644            .sum();
645        usages.push(ProjectUsage {
646            workspace_root,
647            identities: identities.len() as u64,
648            action_bytes,
649            target_bytes,
650            live,
651        });
652    }
653    usages.sort_by(|left, right| {
654        right
655            .action_bytes
656            .saturating_add(right.target_bytes)
657            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
658            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
659    });
660    Ok(usages)
661}
662
663/// Return the largest blobs and action-result records in descending order.
664pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
665    let mut entries = Vec::new();
666    for (kind, root, action_result) in [
667        ("object", store.join(CAS_DIR), false),
668        ("action result", store.join(ACTION_RESULTS_DIR), true),
669    ] {
670        entries.extend(
671            walk_files(&root)?
672                .into_iter()
673                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
674                .map(|entry| LargestEntry {
675                    kind,
676                    path: entry.path,
677                    bytes: entry.size,
678                }),
679        );
680    }
681    entries.sort_by(|left, right| {
682        right
683            .bytes
684            .cmp(&left.bytes)
685            .then_with(|| left.path.cmp(&right.path))
686    });
687    entries.truncate(limit);
688    Ok(entries)
689}
690
691/// Verify every addressable CAS object and action-result record.
692pub fn verify(store: &Path) -> Result<VerifyOutcome> {
693    let cas = LocalCas::new(store);
694    let action_cache = mbx_cache_core::LocalActionCache::new(store);
695    // Verification reports the store as it is on disk and removes nothing, so
696    // no object is pending removal the way one is mid-sweep.
697    let virtually_removed = HashSet::new();
698    let mut outcome = VerifyOutcome::default();
699    for entry in walk_files(&store.join(CAS_DIR))? {
700        let Some(digest) = addressed_digest(store, &entry.path, false) else {
701            continue;
702        };
703        outcome.checked_objects += 1;
704        if !matches!(cas.find(&digest), Ok(Some(_))) {
705            outcome.problems.push(entry.path);
706        }
707    }
708    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
709        let Some(digest) = addressed_digest(store, &entry.path, true) else {
710            continue;
711        };
712        outcome.checked_action_results += 1;
713        if !matches!(action_cache.find(&digest), Ok(Some(_)))
714            || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
715        {
716            outcome.problems.push(entry.path);
717        }
718    }
719    outcome.problems.sort();
720    Ok(outcome)
721}
722
723/// Remove checkout claims belonging to exactly one workspace.
724pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
725    let mut outcome = RemoveProjectOutcome::default();
726    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
727        if read_checkout_record(&entry.path)
728            .is_some_and(|record| record.workspace_root == workspace_root)
729        {
730            std::fs::remove_file(&entry.path)?;
731            outcome.removed_checkout_records += 1;
732            if let Some(parent) = entry.path.parent() {
733                let _ = std::fs::remove_dir(parent);
734            }
735        }
736    }
737    match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
738        Ok(()) => {}
739        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
740        Err(error) => return Err(error.into()),
741    }
742    Ok(outcome)
743}
744
745/// Record that `workspace_root` built `identity`.
746///
747/// Identities are shared by every checkout of one dependency graph, so this is
748/// what later tells the collector whether anything still needs what a build
749/// cached: one file per checkout under the identity it built. Writing a whole
750/// file per checkout rather than merging a list into one keeps concurrent
751/// builds out of each other's way -- there is nothing to merge, so there is no
752/// lock and no lost update.
753///
754/// A build with no target directory of its own passes `workspace_root` for
755/// `target_dir`, which reads as "none" rather than as a tree to measure.
756pub fn record_checkout(
757    store: &Path,
758    identity: &str,
759    workspace_root: &Path,
760    target_dir: &Path,
761) -> Result<()> {
762    let record = CheckoutRecord {
763        version: CHECKOUT_RECORD_VERSION,
764        workspace_root: workspace_root.to_path_buf(),
765        target_dir: target_dir.to_path_buf(),
766        updated_secs: SystemTime::now()
767            .duration_since(UNIX_EPOCH)
768            .map(|since| since.as_secs())
769            .unwrap_or_default(),
770    };
771    let mut contents = serde_json::to_vec(&record)?;
772    contents.push(b'\n');
773    // Refreshed by every build and read back only as a last-used stamp, so a
774    // power cut costs one build's worth of recency, not a wait on the disk.
775    write_advisory(
776        &checkout_record_path(store, identity, workspace_root),
777        &contents,
778    )
779}
780
781fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
782    // The path is hashed rather than escaped: it is only ever compared against
783    // the same hash of the same path, and every filesystem in play disagrees
784    // about which characters a name may hold.
785    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
786    store
787        .join(CHECKOUTS_DIR)
788        .join(identity)
789        .join(format!("{key}.json"))
790}
791
792/// Evict objects until the store fits within `max_bytes`.
793///
794/// Objects no live checkout can reach are evicted first, so deleting a worktree
795/// releases what only that worktree needed. Within each class eviction is
796/// least-recently-used by access time where the filesystem records it, falling
797/// back to modification time. A restore verifies the blob it serves, which
798/// means it reads the whole file and the access time is real -- but only where
799/// the mount records one, so the ordering is an approximation either way.
800/// Evicting a live object costs a recompile, never correctness.
801pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
802    gc_with_mode(store, max_bytes, false)
803}
804
805/// Describe the collection `gc` would perform without changing the store.
806pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
807    gc_with_mode(store, max_bytes, true)
808}
809
810fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
811    let mut objects = walk_files(&store.join(CAS_DIR))?;
812    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
813    let mut live_bytes = objects
814        .iter()
815        .chain(results.iter())
816        .map(|entry| entry.size)
817        .sum::<u64>();
818
819    let mut outcome = GcOutcome::default();
820
821    // Prune before deciding what is rooted, so a checkout deleted since the
822    // last sweep stops protecting its artifacts during this one.
823    let checkouts = scan_checkouts(store)?;
824    for path in &checkouts.stale_records {
825        if dry_run || matches!(remove(path)?, Removal::Removed) {
826            outcome.removed_checkout_records += 1;
827        }
828        // The identity directory is left behind empty otherwise. It may hold
829        // other checkouts, in which case this fails and that is the answer.
830        if !dry_run && let Some(parent) = path.parent() {
831            let _ = std::fs::remove_dir(parent);
832        }
833    }
834
835    let mut virtually_removed = HashSet::new();
836    if live_bytes > max_bytes {
837        // Reachability costs a read per action result and per output tree, so
838        // it is computed only when something is actually about to be evicted.
839        // Under budget a sweep stays a directory walk and nothing more.
840        let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
841        // A grouped CI export deliberately retains the exact actions from
842        // every command in the job. Later commands can replace the current
843        // task-manifest prediction for the same invocation, but the earlier
844        // receipt still has to remain exportable until the post-job step runs.
845        // Receipt bookkeeping is not part of the cache budget, so consult it
846        // only when a sweep is already necessary and root its object closure.
847        rooted.extend(rooted_action_objects(
848            store,
849            grouped_receipt_actions(store)?,
850        ));
851        // `false` sorts first, so unrooted objects go before rooted ones and
852        // each class goes oldest-first. A store with no records at all roots
853        // nothing and this is exactly the LRU it was before.
854        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
855        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
856            if dry_run {
857                virtually_removed.insert(path.to_path_buf());
858                Ok(Removal::Removed)
859            } else {
860                remove(path)
861            }
862        })?;
863        live_bytes = evicted.remaining_bytes;
864        outcome.removed_objects += evicted.removed_objects;
865        outcome.removed_bytes += evicted.removed_bytes;
866    }
867
868    // An action result whose objects are gone can only produce a miss, so drop
869    // it rather than leave the index pointing at nothing. This runs whether or
870    // not this call evicted anything, since another process may have, and a
871    // store that is over budget on results alone still needs the sweep.
872    let cas = LocalCas::new(store);
873    for entry in &results {
874        if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
875            match if dry_run {
876                Removal::Removed
877            } else {
878                remove(&entry.path)?
879            } {
880                Removal::Removed => {
881                    live_bytes = live_bytes.saturating_sub(entry.size);
882                    outcome.removed_action_results += 1;
883                    outcome.removed_bytes += entry.size;
884                }
885                Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
886                Removal::Blocked => {}
887            }
888        }
889    }
890
891    let sessions = prune_sessions(store, dry_run)?;
892    outcome.removed_session_streams += sessions.removed_streams;
893    outcome.removed_bytes += sessions.removed_bytes;
894
895    outcome.remaining_bytes = live_bytes;
896    Ok(outcome)
897}
898
899#[derive(Debug, Default, PartialEq, Eq)]
900struct SessionPrune {
901    removed_streams: u64,
902    removed_bytes: u64,
903}
904
905fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
906    let mut prune = SessionPrune::default();
907    let ids = events::session_ids(store);
908    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
909    for (index, id) in ids.iter().enumerate() {
910        let paths = events::session_paths(store, id);
911        let metadata = match std::fs::metadata(&paths.events) {
912            Ok(metadata) => metadata,
913            Err(_) => continue,
914        };
915        let stale = metadata
916            .modified()
917            .ok()
918            .and_then(|at| SystemTime::now().duration_since(at).ok())
919            .is_some_and(|age| age > SESSION_RETENTION);
920        if index >= surplus && !stale {
921            continue;
922        }
923        if events::session_is_live(store, id) {
924            continue;
925        }
926        if dry_run {
927            prune.removed_streams += 1;
928            prune.removed_bytes += metadata.len();
929            continue;
930        }
931        if matches!(remove(&paths.events)?, Removal::Removed) {
932            prune.removed_streams += 1;
933            prune.removed_bytes += metadata.len();
934            let _ = std::fs::remove_file(&paths.lock);
935        }
936    }
937    if !dry_run {
938        for lock in events::orphaned_locks(store) {
939            let _ = std::fs::remove_file(lock);
940        }
941    }
942    Ok(prune)
943}
944
945/// Sweep the store if `interval` has passed since the last attempt.
946///
947/// The stamp is written before the sweep, not after. Two builds finishing
948/// together should cost one sweep between them, and a sweep that dies partway
949/// should wait its turn like any other rather than retry on every build.
950pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
951    if !claim_sweep(store, interval)? {
952        return Ok(None);
953    }
954    gc(store, max_bytes).map(Some)
955}
956
957/// Atomically stamp a due sweep before its callers perform coordinated GC.
958pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
959    let lock_path = store.join(SWEEP_LOCK);
960    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
961    let mut lock = fslock::LockFile::open(&lock_path)?;
962    lock.lock()?;
963
964    let stamp = store.join(SWEEP_STAMP);
965    if let Ok(metadata) = std::fs::metadata(&stamp)
966        && let Ok(modified) = metadata.modified()
967        && let Ok(since) = modified.elapsed()
968        && since < interval
969    {
970        return Ok(false);
971    }
972    write_atomic(&stamp, b"")?;
973    Ok(true)
974}
975
976/// What the checkout registry says about the identities in this store.
977struct CheckoutScan {
978    live_identities: BTreeSet<String>,
979    live_records: u64,
980    stale_records: Vec<PathBuf>,
981}
982
983fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
984    let root = store.join(CHECKOUTS_DIR);
985    let mut scan = CheckoutScan {
986        live_identities: BTreeSet::new(),
987        live_records: 0,
988        stale_records: Vec::new(),
989    };
990    let listing = match std::fs::read_dir(&root) {
991        Ok(listing) => listing,
992        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
993        Err(error) => {
994            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
995        }
996    };
997    for entry in listing {
998        let entry = entry?;
999        let name = entry.file_name().to_string_lossy().into_owned();
1000        // Anything that is not a directory named for an identity was not written
1001        // by mbx. Both halves matter: `walk_files` tolerates only a missing
1002        // directory, so a plain file whose name happens to look like an identity
1003        // would fail the whole scan -- and that scan runs inside every sweep,
1004        // including the automatic one after a build.
1005        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
1006            continue;
1007        }
1008        for record in walk_files(&entry.path())? {
1009            match read_checkout_record(&record.path) {
1010                Some(checkout) if claim_is_live(store, &checkout) => {
1011                    scan.live_identities.insert(name.clone());
1012                    scan.live_records += 1;
1013                }
1014                Some(_) => scan.stale_records.push(record.path),
1015                // A record this build cannot read claims nothing, but it is not
1016                // evidence the checkout is gone either, so leave it alone.
1017                None => {}
1018            }
1019        }
1020    }
1021    Ok(scan)
1022}
1023
1024fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
1025    let bytes = std::fs::read(path).ok()?;
1026    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
1027    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
1028}
1029
1030/// Actions whose successful grouped export has not retired its receipts yet.
1031fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
1032    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
1033    Ok(walk_files(&root)?
1034        .into_iter()
1035        .filter_map(|entry| read_build_receipt(&entry.path))
1036        .flat_map(|receipt| receipt.predictions.into_iter())
1037        .map(|prediction| prediction.action)
1038        .collect())
1039}
1040
1041/// Whether a recorded claim still speaks for a checkout that is using this
1042/// store.
1043fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
1044    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
1045}
1046
1047fn claim_has_expired(record: &CheckoutRecord) -> bool {
1048    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1049        return false;
1050    };
1051    now.as_secs()
1052        .saturating_sub(record.updated_secs)
1053        .gt(&CHECKOUT_RETENTION.as_secs())
1054}
1055
1056/// Whether the checkout a record names is still on disk.
1057///
1058/// Absence is believed only when the checkout is definitely absent and its
1059/// nearest existing ancestor is on the same filesystem as the store. Walking
1060/// all the way to that ancestor matters for worktree managers and temporary
1061/// directories, which commonly remove a checkout together with one or more of
1062/// its otherwise-empty parents.
1063///
1064/// A different filesystem can be a mount whose contents are temporarily
1065/// unavailable, so uncertainty there remains live. Errors are treated the same
1066/// way: being wrong in that direction only delays collection; the other way
1067/// round throws away a warm cache someone is still using.
1068pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
1069    if !matches!(workspace_root.try_exists(), Ok(false)) {
1070        return true;
1071    }
1072
1073    let Ok(store_metadata) = std::fs::metadata(store) else {
1074        return true;
1075    };
1076    for ancestor in workspace_root.ancestors().skip(1) {
1077        match std::fs::metadata(ancestor) {
1078            Ok(ancestor_metadata) => {
1079                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
1080            }
1081            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1082            Err(_) => return true,
1083        }
1084    }
1085    true
1086}
1087
1088/// Whether a checkout is still on disk, corroborating absence through its
1089/// immediate parent.
1090///
1091/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
1092/// root is available to distinguish deletion from an unavailable filesystem.
1093pub fn checkout_is_live(workspace_root: &Path) -> bool {
1094    let Some(parent) = workspace_root.parent() else {
1095        return true;
1096    };
1097    checkout_is_live_on(parent, workspace_root)
1098}
1099
1100#[cfg(unix)]
1101fn same_filesystem(
1102    _a_path: &Path,
1103    a: &std::fs::Metadata,
1104    _b_path: &Path,
1105    b: &std::fs::Metadata,
1106) -> bool {
1107    use std::os::unix::fs::MetadataExt as _;
1108    a.dev() == b.dev()
1109}
1110
1111#[cfg(windows)]
1112fn same_filesystem(
1113    a_path: &Path,
1114    _a: &std::fs::Metadata,
1115    b_path: &Path,
1116    _b: &std::fs::Metadata,
1117) -> bool {
1118    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
1119}
1120
1121#[cfg(windows)]
1122fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
1123    use std::os::windows::ffi::OsStrExt as _;
1124    use windows_sys::Win32::Storage::FileSystem::{
1125        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
1126    };
1127
1128    let path = path
1129        .as_os_str()
1130        .encode_wide()
1131        .chain(Some(0))
1132        .collect::<Vec<_>>();
1133    // Both APIs document MAX_PATH-sized output buffers for volume paths and
1134    // names. Failure is uncertainty, which deliberately keeps the checkout
1135    // live.
1136    let mut mount = vec![0_u16; 261];
1137    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
1138    // number of UTF-16 code units.
1139    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
1140        return None;
1141    }
1142    let mut volume = vec![0_u16; 261];
1143    // SAFETY: the successful call above left `mount` nul-terminated, and
1144    // `volume` is writable for the stated number of UTF-16 code units.
1145    if unsafe {
1146        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
1147    } == 0
1148    {
1149        return None;
1150    }
1151    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
1152    Some(volume)
1153}
1154
1155#[cfg(not(any(unix, windows)))]
1156fn same_filesystem(
1157    _a_path: &Path,
1158    _a: &std::fs::Metadata,
1159    _b_path: &Path,
1160    _b: &std::fs::Metadata,
1161) -> bool {
1162    false
1163}
1164
1165/// CAS paths reachable from the builds that live checkouts still depend on.
1166///
1167/// Recursing into output trees is the point: the descriptor blobs are tiny and
1168/// the leaf artifacts are where the bytes are, so rooting that stopped at the
1169/// top level would protect nothing worth protecting. Every read here is
1170/// tolerant -- a blob that has already been evicted, or that no longer parses,
1171/// simply roots less, and rooting less can only mean collecting sooner.
1172fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
1173    let mut actions = BTreeSet::new();
1174    for identity in identities {
1175        // One manifest this build cannot read is not worth abandoning a sweep
1176        // over. It roots nothing, which can only mean collecting sooner.
1177        let manifest_actions = match task_manifest_actions(store, identity) {
1178            Ok(actions) => actions,
1179            Err(error) => {
1180                log::debug!("could not read the manifest for {identity}: {error}");
1181                continue;
1182            }
1183        };
1184        actions.extend(manifest_actions);
1185    }
1186    Ok(rooted_action_objects(store, actions))
1187}
1188
1189/// Return every local CAS path needed to restore the supplied actions.
1190fn rooted_action_objects(
1191    store: &Path,
1192    actions: impl IntoIterator<Item = CacheDigest>,
1193) -> HashSet<PathBuf> {
1194    let cas = LocalCas::new(store);
1195    let mut rooted = HashSet::new();
1196    let mut visited = BTreeSet::new();
1197    for action in actions {
1198        let Some(result) = read_action_result(store, &action) else {
1199            continue;
1200        };
1201        root_digest(&cas, &mut rooted, &result.action);
1202        if let Some(metadata) = &result.metadata {
1203            root_digest(&cas, &mut rooted, metadata);
1204            // The metadata blob is a descriptor too: it names the captured
1205            // stdout and stderr, and a restore reads both. Stopping at the
1206            // descriptor would leave the diagnostics of every action
1207            // unrooted -- including the one empty blob that every silent
1208            // compilation shares, which is enough on its own to turn a
1209            // rooted hit back into a miss.
1210            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
1211                root_digest(&cas, &mut rooted, &rustc.stdout);
1212                root_digest(&cas, &mut rooted, &rustc.stderr);
1213            }
1214        }
1215        if let Some(output_root) = &result.output_root {
1216            root_digest(&cas, &mut rooted, output_root);
1217            root_tree(&cas, output_root, &mut rooted, &mut visited);
1218        }
1219    }
1220    rooted
1221}
1222
1223fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
1224    let path = mbx_cache_core::LocalActionCache::new(store)
1225        .path_for(action)
1226        .ok()?;
1227    let bytes = std::fs::read(path).ok()?;
1228    serde_json::from_slice(&bytes).ok()
1229}
1230
1231fn root_tree(
1232    cas: &LocalCas,
1233    output_root: &CacheDigest,
1234    rooted: &mut HashSet<PathBuf>,
1235    visited: &mut BTreeSet<CacheDigest>,
1236) {
1237    let mut pending = vec![output_root.clone()];
1238    while let Some(digest) = pending.pop() {
1239        if !visited.insert(digest.clone()) {
1240            continue;
1241        }
1242        let Some(directory) = read_directory(cas, &digest) else {
1243            continue;
1244        };
1245        for file in &directory.files {
1246            root_digest(cas, rooted, &file.digest);
1247        }
1248        for child in &directory.directories {
1249            root_digest(cas, rooted, &child.digest);
1250            pending.push(child.digest.clone());
1251        }
1252    }
1253}
1254
1255fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
1256    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1257    serde_json::from_slice(&bytes).ok()
1258}
1259
1260fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
1261    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1262    serde_json::from_slice(&bytes).ok()
1263}
1264
1265fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
1266    if let Ok(path) = cas.path_for(digest) {
1267        rooted.insert(path);
1268    }
1269}
1270
1271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1272enum Removal {
1273    Removed,
1274    Missing,
1275    Blocked,
1276}
1277
1278fn remove(path: &Path) -> Result<Removal> {
1279    match std::fs::remove_file(path) {
1280        Ok(()) => Ok(Removal::Removed),
1281        // A concurrent build may have evicted or replaced it already.
1282        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
1283        // Windows refuses to unlink a file another process holds open, which is
1284        // what a concurrent build reading this blob looks like. Skipping it
1285        // leaves the store over budget until the next sweep; failing would
1286        // abandon the sweep and leave it over budget for longer.
1287        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1288            log::debug!("could not evict {}: {error}", path.display());
1289            Ok(Removal::Blocked)
1290        }
1291        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
1292    }
1293}
1294
1295#[derive(Debug, Default, PartialEq, Eq)]
1296struct ObjectEvictions {
1297    removed_objects: u64,
1298    removed_bytes: u64,
1299    remaining_bytes: u64,
1300}
1301
1302/// Evict sorted objects without crossing the checkout-protection boundary
1303/// when an unrooted object cannot be removed.
1304fn evict_objects(
1305    objects: &[Entry],
1306    rooted: &HashSet<PathBuf>,
1307    mut live_bytes: u64,
1308    max_bytes: u64,
1309    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
1310) -> Result<ObjectEvictions> {
1311    let mut outcome = ObjectEvictions::default();
1312    let mut blocked_unrooted = false;
1313    for entry in objects {
1314        if live_bytes <= max_bytes {
1315            break;
1316        }
1317        let is_rooted = rooted.contains(&entry.path);
1318        if is_rooted && blocked_unrooted {
1319            // A locked unrooted blob is temporarily part of the irreducible
1320            // store. Deleting a live checkout's artifacts in its place would
1321            // invert the protection ordering this collector promises.
1322            break;
1323        }
1324        match remove_file(&entry.path)? {
1325            Removal::Removed => {
1326                live_bytes = live_bytes.saturating_sub(entry.size);
1327                outcome.removed_objects += 1;
1328                outcome.removed_bytes += entry.size;
1329            }
1330            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1331            Removal::Blocked if !is_rooted => blocked_unrooted = true,
1332            Removal::Blocked => {}
1333        }
1334    }
1335    outcome.remaining_bytes = live_bytes;
1336    Ok(outcome)
1337}
1338
1339/// Whether an action result references an object the store no longer has.
1340///
1341/// The digests checked here are exactly the ones `LocalActionCache::store`
1342/// requires. If the sweep checked fewer, eviction could leave an entry that
1343/// cannot be republished -- `store` would reject the identical result for a
1344/// missing blob while the index still claimed to hold it.
1345///
1346/// Presence is all that is checked, not content. Verifying would re-hash a
1347/// large fraction of the store on every sweep, which a chore could afford and
1348/// an automatic sweep cannot; it would also refresh the access time of every
1349/// descriptor blob while leaving the artifacts alone, biasing the very
1350/// ordering this module depends on. A blob that is present but corrupt now
1351/// keeps its result and turns into a miss on restore, and both CAS write paths
1352/// republish over a corrupt blob rather than trusting it.
1353///
1354/// Only the top-level objects are checked; a result whose output tree lost a
1355/// nested object still restores as a miss, which is safe.
1356fn action_result_is_dangling(
1357    cas: &LocalCas,
1358    path: &Path,
1359    virtually_removed: &HashSet<PathBuf>,
1360) -> Result<bool> {
1361    let bytes = match std::fs::read(path) {
1362        Ok(bytes) => bytes,
1363        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1364        Err(error) => {
1365            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
1366        }
1367    };
1368    // An unparseable result is already useless; the cache rejects it on read.
1369    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
1370        return Ok(false);
1371    };
1372    for digest in [
1373        Some(&result.action),
1374        result.metadata.as_ref(),
1375        result.output_root.as_ref(),
1376    ]
1377    .into_iter()
1378    .flatten()
1379    {
1380        match cas.path_for(digest) {
1381            Ok(path) => {
1382                if virtually_removed.contains(&path) || !path.exists() {
1383                    return Ok(true);
1384                }
1385            }
1386            // A digest the CAS cannot even address is not one it can hold.
1387            Err(_) => return Ok(true),
1388        }
1389    }
1390    Ok(false)
1391}
1392
1393struct Entry {
1394    path: PathBuf,
1395    size: u64,
1396    used: SystemTime,
1397}
1398
1399fn walk_files(root: &Path) -> Result<Vec<Entry>> {
1400    let mut entries = Vec::new();
1401    let mut pending = vec![root.to_path_buf()];
1402    while let Some(directory) = pending.pop() {
1403        let listing = match std::fs::read_dir(&directory) {
1404            Ok(listing) => listing,
1405            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1406            Err(error) => {
1407                return Err(error)
1408                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
1409            }
1410        };
1411        for entry in listing {
1412            let entry = entry?;
1413            let metadata = match entry.metadata() {
1414                Ok(metadata) => metadata,
1415                // A concurrent build may be publishing into the store.
1416                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1417                Err(error) => return Err(error.into()),
1418            };
1419            if metadata.is_dir() {
1420                pending.push(entry.path());
1421            } else if metadata.is_file() {
1422                let used = metadata
1423                    .accessed()
1424                    .or_else(|_| metadata.modified())
1425                    .unwrap_or(SystemTime::UNIX_EPOCH);
1426                entries.push(Entry {
1427                    path: entry.path(),
1428                    size: metadata.len(),
1429                    used,
1430                });
1431            }
1432        }
1433    }
1434    Ok(entries)
1435}
1436
1437fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
1438    match std::fs::read_dir(root) {
1439        Ok(entries) => entries
1440            .collect::<std::io::Result<Vec<_>>>()
1441            .map_err(Into::into),
1442        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
1443        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
1444    }
1445}
1446
1447fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
1448    let name = path.file_name()?.to_str()?;
1449    let name = if action_result {
1450        name.strip_suffix(".json")?
1451    } else {
1452        name
1453    };
1454    let (hash, size) = name.rsplit_once('-')?;
1455    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
1456    let digest = CacheDigest {
1457        algorithm,
1458        hash: hash.to_string(),
1459        size: size.parse().ok()?,
1460    };
1461    digest.validate().ok()?;
1462    Some(digest)
1463}
1464
1465fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
1466    let digest = digest_from_path(path, action_result)?;
1467    let canonical = if action_result {
1468        mbx_cache_core::LocalActionCache::new(store)
1469            .path_for(&digest)
1470            .ok()?
1471    } else {
1472        LocalCas::new(store).path_for(&digest).ok()?
1473    };
1474    (canonical == path).then_some(digest)
1475}
1476
1477fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
1478    *cache
1479        .entry(root.to_path_buf())
1480        .or_insert_with(|| tree_bytes(root))
1481}
1482
1483fn tree_bytes(root: &Path) -> u64 {
1484    let Ok(root_metadata) = std::fs::metadata(root) else {
1485        return 0;
1486    };
1487    if root_metadata.is_file() {
1488        return root_metadata.len();
1489    }
1490    let Ok(entries) = std::fs::read_dir(root) else {
1491        return 0;
1492    };
1493    let mut bytes = 0_u64;
1494    let mut pending = entries
1495        .filter_map(std::result::Result::ok)
1496        .map(|entry| entry.path())
1497        .collect::<Vec<_>>();
1498    while let Some(path) = pending.pop() {
1499        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1500            continue;
1501        };
1502        if metadata.is_file() {
1503            bytes = bytes.saturating_add(metadata.len());
1504        } else if metadata.is_dir()
1505            && let Ok(entries) = std::fs::read_dir(path)
1506        {
1507            pending.extend(
1508                entries
1509                    .filter_map(std::result::Result::ok)
1510                    .map(|entry| entry.path()),
1511            );
1512        }
1513    }
1514    bytes
1515}
1516
1517#[cfg(test)]
1518#[path = "store_tests.rs"]
1519mod tests;
1520
1521/// [`write_atomic`] without the sync: for records every build rewrites and
1522/// that a torn or missing file only makes one build older.
1523fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
1524    let parent = path
1525        .parent()
1526        .filter(|parent| !parent.as_os_str().is_empty())
1527        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1528    std::fs::create_dir_all(parent)
1529        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1530    let mut temporary = tempfile::Builder::new()
1531        .prefix(".mbx-")
1532        .tempfile_in(parent)?;
1533    use std::io::Write as _;
1534    temporary.write_all(contents)?;
1535    temporary
1536        .persist(path)
1537        .map_err(|error| error.error)
1538        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1539    Ok(())
1540}
1541
1542fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
1543    let parent = path
1544        .parent()
1545        .filter(|parent| !parent.as_os_str().is_empty())
1546        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1547    std::fs::create_dir_all(parent)
1548        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1549    let mut temporary = tempfile::Builder::new()
1550        .prefix(".mbx-")
1551        .tempfile_in(parent)?;
1552    use std::io::Write as _;
1553    temporary.write_all(contents)?;
1554    temporary.as_file_mut().sync_all()?;
1555    temporary
1556        .persist(path)
1557        .map_err(|error| error.error)
1558        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1559    Ok(())
1560}