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, ActionSlot, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult,
17    RustcMetadata, TaskActionManifest, is_task_identity, merge_task_action_predictions,
18    task_manifest_actions,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
22use std::path::{Component, Path, PathBuf};
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27const CAS_DIR: &str = "cas/v1";
28const ACTION_RESULTS_DIR: &str = "action-results/v1";
29const CHECKOUTS_DIR: &str = "checkouts/v1";
30const SWEEP_STAMP: &str = "gc/v1/last-sweep";
31const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
32const CHECKOUT_RECORD_VERSION: u8 = 1;
33const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
34const BUILD_RECEIPT_VERSION: u8 = 1;
35const IMPORT_STAGING_DIR: &str = "import-staging";
36const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
37const EXPORT_VERSION: u8 = 2;
38const LEGACY_EXPORT_VERSION: u8 = 1;
39
40const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
41const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
42const MAX_SESSIONS: usize = 256;
43
44/// How long a checkout's claim outlives the last build that renewed it.
45///
46/// Existence alone is not enough to keep a claim alive. An identity covers one
47/// exact command line against one exact lockfile, so a checkout that lives for
48/// years accumulates an identity per lockfile it ever had -- and every one of
49/// them would go on rooting the actions of a build nobody will run again.
50/// Without this bound the rooted set only grows, until everything is rooted and
51/// the ordering means nothing. A build renews the claims it uses, so anything
52/// this stale belongs to a command that has moved on.
53const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
54
55#[derive(Debug, Default, PartialEq, Eq)]
56pub struct StoreStats {
57    pub objects: u64,
58    pub object_bytes: u64,
59    pub action_results: u64,
60    pub action_result_bytes: u64,
61    pub live_checkouts: u64,
62    /// Claims that root nothing any more: a checkout that is gone, or one that
63    /// is still there but has not renewed this claim inside the retention
64    /// window. Reported as stale rather than gone because those are different
65    /// things and only one of them means the directory is missing.
66    pub stale_checkouts: u64,
67}
68
69impl StoreStats {
70    pub fn total_bytes(&self) -> u64 {
71        self.object_bytes.saturating_add(self.action_result_bytes)
72    }
73}
74
75#[derive(Debug, Default, PartialEq, Eq)]
76pub struct GcOutcome {
77    pub removed_objects: u64,
78    pub removed_action_results: u64,
79    pub removed_checkout_records: u64,
80    /// Event streams dropped on age or count. These bytes are not included in
81    /// `remaining_bytes` because session history is not cache content.
82    pub removed_session_streams: u64,
83    pub removed_bytes: u64,
84    pub remaining_bytes: u64,
85}
86
87#[derive(Debug, PartialEq, Eq)]
88pub struct ProjectUsage {
89    pub workspace_root: PathBuf,
90    pub identities: u64,
91    pub action_bytes: u64,
92    pub target_bytes: u64,
93    pub live: bool,
94}
95
96#[derive(Debug, PartialEq, Eq)]
97pub struct LargestEntry {
98    pub kind: &'static str,
99    pub path: PathBuf,
100    pub bytes: u64,
101}
102
103#[derive(Debug, Default, PartialEq, Eq)]
104pub struct VerifyOutcome {
105    pub checked_objects: u64,
106    pub checked_action_results: u64,
107    pub problems: Vec<PathBuf>,
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
111pub struct RemoveProjectOutcome {
112    pub removed_checkout_records: u64,
113}
114
115#[derive(Debug, Default, PartialEq, Eq)]
116pub struct TransferOutcome {
117    pub actions: u64,
118    pub objects: u64,
119    pub bytes: u64,
120}
121
122/// A cache import together with named higher-level CAS roots it carried.
123#[derive(Debug)]
124#[non_exhaustive]
125pub struct ImportOutcome {
126    pub transfer: TransferOutcome,
127    pub attachments: BTreeMap<String, CacheDigest>,
128}
129
130/// Extra CAS roots carried by a cache export for a higher-level transport.
131#[derive(Debug, Default)]
132pub struct ExportAdditions {
133    /// Stable names by which the importer can discover selected objects.
134    pub attachments: BTreeMap<String, CacheDigest>,
135    /// Every object the attachments reach, including the named objects.
136    pub objects: BTreeSet<CacheDigest>,
137}
138
139/// One Cargo workspace and the target directory recorded for its build.
140#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
141pub struct WorkspaceTarget {
142    pub workspace_root: PathBuf,
143    pub target_dir: PathBuf,
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct ExportManifest {
149    version: u8,
150    tasks: Vec<TaskActionManifest>,
151    actions: Vec<CacheDigest>,
152    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153    attachments: BTreeMap<String, CacheDigest>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    objects: Vec<CacheDigest>,
156}
157
158/// The exact cache predictions completed by one top-level build command.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct BuildReceipt {
162    version: u8,
163    workspace_root: PathBuf,
164    identity: String,
165    completed_nanos: u64,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    group: Option<String>,
168    predictions: Vec<ActionPrediction>,
169}
170
171/// Record an exact completed build for checkout and grouped exports.
172pub fn record_build_receipt(
173    store: &Path,
174    run: &str,
175    identity: &str,
176    workspace_root: &Path,
177    group: Option<&str>,
178    predictions: Vec<ActionPrediction>,
179) -> Result<()> {
180    if !is_task_identity(run) || !is_task_identity(identity) {
181        eyre::bail!("invalid build receipt identity");
182    }
183    if !(TaskActionManifest {
184        version: 1,
185        task: identity.to_owned(),
186        predictions: predictions.clone(),
187    })
188    .validate()
189    {
190        eyre::bail!("invalid build receipt prediction");
191    }
192    if let Some(group) = group {
193        validate_export_group(group)?;
194    }
195    // A build that used exactly what the last one did leaves that receipt
196    // standing. Only its timestamp would change, and the export it feeds
197    // wants the prediction set, not the hour. A group receipt is per run and
198    // always written.
199    if group.is_none()
200        && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
201            latest.identity == identity
202                && latest.workspace_root == workspace_root
203                && latest.group.is_none()
204                && latest.predictions == predictions
205        })
206    {
207        return Ok(());
208    }
209    let key = workspace_key(workspace_root);
210    let lock_path = store
211        .join(BUILD_RECEIPTS_DIR)
212        .join("locks")
213        .join(format!("{key}.lock"));
214    std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
215    let mut lock = fslock::LockFile::open(&lock_path)?;
216    lock.lock()?;
217    let completed_nanos = SystemTime::now()
218        .duration_since(UNIX_EPOCH)
219        .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
220        .unwrap_or_default();
221    let receipt = BuildReceipt {
222        version: BUILD_RECEIPT_VERSION,
223        workspace_root: workspace_root.to_path_buf(),
224        identity: identity.to_owned(),
225        completed_nanos,
226        group: group.map(str::to_owned),
227        predictions,
228    };
229    let bytes = serde_json::to_vec(&receipt)?;
230    write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
231    if let Some(group) = group {
232        write_atomic(&group_receipt_path(store, group, run), &bytes)?;
233    }
234    Ok(())
235}
236
237fn workspace_key(workspace_root: &Path) -> String {
238    CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
239}
240
241fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
242    store
243        .join(BUILD_RECEIPTS_DIR)
244        .join("checkouts")
245        .join(format!("{}.json", workspace_key(workspace_root)))
246}
247
248fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
249    store
250        .join(BUILD_RECEIPTS_DIR)
251        .join("groups")
252        .join(group_key(group))
253        .join(format!("{run}.json"))
254}
255
256fn group_key(group: &str) -> String {
257    CacheDigest::blake3(group.as_bytes()).hash
258}
259
260fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
261    let bytes = std::fs::read(path).ok()?;
262    let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
263    let valid = TaskActionManifest {
264        version: 1,
265        task: receipt.identity.clone(),
266        predictions: receipt.predictions.clone(),
267    }
268    .validate();
269    (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
270}
271
272fn validate_export_group(group: &str) -> Result<()> {
273    if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
274        eyre::bail!("invalid build export group");
275    }
276    Ok(())
277}
278
279/// How an export is laid out on disk.
280///
281/// Both forms carry the same manifest and the same `cas/v1` and
282/// `action-results/v1` layout; they differ only in whether that tree is
283/// wrapped in a tar. A transport that archives and compresses a directory
284/// itself, such as `actions/cache`, would otherwise write every byte twice:
285/// once into the tar and once again when the importer unpacks it.
286#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
287pub enum ExportForm {
288    /// One tar file: portable, and what a standalone bundle should be.
289    #[default]
290    Tar,
291    /// A directory tree, for transports that archive directories themselves.
292    Directory,
293}
294
295impl std::str::FromStr for ExportForm {
296    type Err = eyre::Report;
297
298    fn from_str(text: &str) -> Result<Self> {
299        match text {
300            "tar" => Ok(Self::Tar),
301            "directory" => Ok(Self::Directory),
302            other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
303        }
304    }
305}
306
307/// Export the complete local cache closure of this checkout's most recent build.
308pub fn export_checkout(
309    store: &Path,
310    workspace_root: &Path,
311    archive: &Path,
312) -> Result<TransferOutcome> {
313    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
314        .filter(|receipt| receipt.workspace_root == workspace_root)
315        .ok_or_else(|| {
316            eyre::eyre!(
317                "no completed mbx build is recorded for {}",
318                workspace_root.display()
319            )
320        })?;
321    export_receipts(
322        store,
323        vec![receipt],
324        archive,
325        ExportAdditions::default(),
326        ExportForm::Tar,
327    )
328}
329
330/// Return the target directory recorded for this checkout's latest build.
331pub fn checkout_workspace_target(
332    store: &Path,
333    workspace_root: &Path,
334) -> Result<Option<WorkspaceTarget>> {
335    let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
336        .filter(|receipt| receipt.workspace_root == workspace_root)
337    else {
338        return Ok(None);
339    };
340    Ok(workspace_target_for_receipt(store, &receipt))
341}
342
343/// Return every Cargo target represented by the pending receipts in a group.
344pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
345    validate_export_group(group)?;
346    let root = store
347        .join(BUILD_RECEIPTS_DIR)
348        .join("groups")
349        .join(group_key(group));
350    let targets = walk_files(&root)?
351        .into_iter()
352        .filter_map(|entry| read_build_receipt(&entry.path))
353        .filter(|receipt| receipt.group.as_deref() == Some(group))
354        .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
355        .collect::<BTreeSet<_>>();
356    Ok(targets.into_iter().collect())
357}
358
359fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
360    let record = read_checkout_record(&checkout_record_path(
361        store,
362        &receipt.identity,
363        &receipt.workspace_root,
364    ))?;
365    (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
366        .then_some(WorkspaceTarget {
367            workspace_root: record.workspace_root,
368            target_dir: record.target_dir,
369        })
370}
371
372/// Export one checkout's closure together with higher-level CAS attachments.
373pub fn export_checkout_with(
374    store: &Path,
375    workspace_root: &Path,
376    archive: &Path,
377    additions: ExportAdditions,
378) -> Result<TransferOutcome> {
379    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
380        .filter(|receipt| receipt.workspace_root == workspace_root)
381        .ok_or_else(|| {
382            eyre::eyre!(
383                "no completed mbx build is recorded for {}",
384                workspace_root.display()
385            )
386        })?;
387    export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
388}
389
390/// Export one checkout's closure in the requested form.
391pub fn export_checkout_as(
392    store: &Path,
393    workspace_root: &Path,
394    destination: &Path,
395    additions: ExportAdditions,
396    form: ExportForm,
397) -> Result<TransferOutcome> {
398    let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
399        .filter(|receipt| receipt.workspace_root == workspace_root)
400        .ok_or_else(|| {
401            eyre::eyre!(
402                "no completed mbx build is recorded for {}",
403                workspace_root.display()
404            )
405        })?;
406    export_receipts(store, vec![receipt], destination, additions, form)
407}
408
409/// Export the union of every completed build recorded under one CI group.
410pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
411    export_group_with(store, group, archive, ExportAdditions::default())
412}
413
414/// Export a grouped closure together with higher-level CAS attachments.
415pub fn export_group_with(
416    store: &Path,
417    group: &str,
418    archive: &Path,
419    additions: ExportAdditions,
420) -> Result<TransferOutcome> {
421    export_group_as(store, group, archive, additions, ExportForm::Tar)
422}
423
424/// Export a grouped closure in the requested form.
425pub fn export_group_as(
426    store: &Path,
427    group: &str,
428    destination: &Path,
429    additions: ExportAdditions,
430    form: ExportForm,
431) -> Result<TransferOutcome> {
432    validate_export_group(group)?;
433    let root = store
434        .join(BUILD_RECEIPTS_DIR)
435        .join("groups")
436        .join(group_key(group));
437    let receipts = walk_files(&root)?
438        .into_iter()
439        .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
440        .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
441        .collect::<Vec<_>>();
442    if receipts.is_empty() {
443        eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
444    }
445    let outcome = export_receipts(
446        store,
447        receipts
448            .iter()
449            .map(|(_, receipt)| receipt.clone())
450            .collect(),
451        destination,
452        additions,
453        form,
454    )?;
455    // A receipt is a pending-export root. Retire only the files this export
456    // consumed, and only after its complete archive has been published. A
457    // concurrent build can add another uniquely named receipt to the group
458    // without this cleanup deleting work the archive did not include.
459    for (path, _) in receipts {
460        std::fs::remove_file(path)?;
461    }
462    let _ = std::fs::remove_dir(root);
463    Ok(outcome)
464}
465
466fn export_receipts(
467    store: &Path,
468    mut receipts: Vec<BuildReceipt>,
469    archive: &Path,
470    additions: ExportAdditions,
471    form: ExportForm,
472) -> Result<TransferOutcome> {
473    receipts.sort_by(|left, right| {
474        left.completed_nanos
475            .cmp(&right.completed_nanos)
476            .then_with(|| {
477                // Concurrent commands may complete on the same clock tick. Use
478                // their persisted predictions to break ties, not directory order.
479                fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
480                    (
481                        &prediction.invocation,
482                        &prediction.action,
483                        &prediction.adapter,
484                        &prediction.payload,
485                    )
486                }
487                left.predictions
488                    .iter()
489                    .map(key)
490                    .cmp(right.predictions.iter().map(key))
491            })
492    });
493    let mut actions = BTreeSet::new();
494    let mut tasks = BTreeMap::new();
495    for receipt in receipts {
496        actions.extend(
497            receipt
498                .predictions
499                .iter()
500                .map(|prediction| prediction.action.clone()),
501        );
502        // Keep predictions from every command in receipt order. Deduplication
503        // below makes the most recently completed command win an overlap.
504        tasks
505            .entry(receipt.identity)
506            .or_insert_with(Vec::new)
507            .extend(receipt.predictions);
508    }
509    let tasks = tasks
510        .into_iter()
511        .map(|(task, predictions)| {
512            let mut invocations = BTreeSet::new();
513            let mut predictions = predictions
514                .into_iter()
515                .rev()
516                .filter(|prediction| invocations.insert(prediction.invocation.clone()))
517                .collect::<Vec<_>>();
518            predictions.reverse();
519            TaskActionManifest {
520                version: 1,
521                task,
522                predictions,
523            }
524        })
525        .collect::<Vec<_>>();
526    if tasks.iter().any(|task| !task.validate()) {
527        eyre::bail!("combined export predictions exceed task manifest limits");
528    }
529    validate_export_additions(&additions)?;
530    let mut closure = strict_closure(store, &actions)?;
531    let cas = LocalCas::new(store);
532    for digest in &additions.objects {
533        require_object(&cas, &mut closure, digest)?;
534    }
535    // Hash the leaves before they are packed. An export is what another
536    // machine will trust, so publishing a blob this store has since corrupted
537    // would move the failure to whoever restores it.
538    verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
539    let parent = archive
540        .parent()
541        .filter(|parent| !parent.as_os_str().is_empty())
542        .unwrap_or_else(|| Path::new("."));
543    std::fs::create_dir_all(parent)?;
544    let manifest = serde_json::to_vec(&ExportManifest {
545        version: if additions.attachments.is_empty() && additions.objects.is_empty() {
546            LEGACY_EXPORT_VERSION
547        } else {
548            EXPORT_VERSION
549        },
550        tasks,
551        actions: actions.iter().cloned().collect(),
552        attachments: additions.attachments.clone(),
553        objects: additions.objects.iter().cloned().collect(),
554    })?;
555    let members = closure
556        .objects
557        .iter()
558        .chain(closure.results.iter())
559        .collect::<Vec<_>>();
560    let bytes = match form {
561        ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
562        ExportForm::Directory => {
563            write_directory_export(store, archive, parent, &manifest, &members)?
564        }
565    };
566    Ok(TransferOutcome {
567        actions: actions.len() as u64,
568        objects: closure.objects.len() as u64,
569        bytes,
570    })
571}
572
573/// Write the export as one tar file, published atomically by rename.
574fn write_tar_export(
575    store: &Path,
576    archive: &Path,
577    parent: &Path,
578    manifest: &[u8],
579    members: &[&PathBuf],
580) -> Result<u64> {
581    let temporary = tempfile::Builder::new()
582        .prefix(".mbx-export-")
583        .tempfile_in(parent)?;
584    let mut builder = tar::Builder::new(temporary.reopen()?);
585    append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
586    for path in members {
587        append_file(&mut builder, store, path)?;
588    }
589    builder.finish()?;
590    drop(builder);
591    temporary
592        .persist(archive)
593        .map_err(|error| error.error)
594        .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
595    Ok(std::fs::metadata(archive)?.len())
596}
597
598/// Write the export as a directory, published atomically by rename.
599///
600/// Built beside the destination and moved into place, so an interrupted export
601/// leaves a `.mbx-export-` directory rather than a bundle that looks complete
602/// and is not.
603fn write_directory_export(
604    store: &Path,
605    destination: &Path,
606    parent: &Path,
607    manifest: &[u8],
608    members: &[&PathBuf],
609) -> Result<u64> {
610    let staging = tempfile::Builder::new()
611        .prefix(".mbx-export-")
612        .tempdir_in(parent)?;
613    std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
614    let mut bytes = manifest.len() as u64;
615    for path in members {
616        let relative = path.strip_prefix(store)?;
617        let target = staging.path().join(relative);
618        std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
619        bytes += std::fs::copy(path, &target)?;
620    }
621    let staged = staging.keep();
622    // `rename` will not replace a populated directory, so an existing bundle
623    // has to move out of the way first. Move it aside rather than deleting it:
624    // if publication then fails, deleting first would have left no bundle at
625    // all, which is worse than the stale one the caller started with.
626    let retired = match std::fs::symlink_metadata(destination) {
627        Ok(_) => {
628            let holder = tempfile::Builder::new()
629                .prefix(".mbx-export-retired-")
630                .tempdir_in(parent)?;
631            let moved = holder.path().join("bundle");
632            std::fs::rename(destination, &moved)
633                .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
634            Some((holder, moved))
635        }
636        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
637        Err(error) => {
638            return Err(error)
639                .wrap_err_with(|| format!("failed to replace {}", destination.display()));
640        }
641    };
642    if let Err(error) = std::fs::rename(&staged, destination) {
643        let _ = std::fs::remove_dir_all(&staged);
644        if let Some((holder, moved)) = retired
645            && std::fs::rename(&moved, destination).is_err()
646        {
647            // Putting the old bundle back has failed too, and letting the
648            // holder drop here would delete the only remaining copy of it.
649            // Keep it instead, and say where it went: a stale bundle the
650            // caller has to move back by hand still beats no bundle at all.
651            let kept = holder.keep();
652            log::warn!(
653                "could not restore {} after a failed export; its previous contents are at {}",
654                destination.display(),
655                kept.join("bundle").display()
656            );
657        }
658        return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
659    }
660    // The replaced bundle is only discarded once the new one is in place.
661    drop(retired);
662    Ok(bytes)
663}
664
665/// Validate a cache export in isolation, then publish its objects and actions.
666pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
667    Ok(import_archive_with_attachments(store, archive)?.transfer)
668}
669
670/// Import a cache archive and return its named higher-level CAS roots.
671pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
672    // A directory bundle is read where it lies. Unpacking one into staging
673    // would write every byte a second time, which is the whole cost the
674    // directory form exists to avoid.
675    let staged;
676    let _staging_lock;
677    let root = if archive.is_dir() {
678        _staging_lock = None;
679        validate_directory_bundle(archive)?;
680        archive
681    } else {
682        // Stage inside the store, not the system temp directory. Publication
683        // moves the verified files with `fs::rename`, which only works within
684        // one filesystem; a `$TMPDIR` on another device silently downgrades
685        // every object to a copy and a second hash, and unpacking a
686        // multi-gigabyte export into a container's `/tmp` can run out of room.
687        let staging_root = store.join(IMPORT_STAGING_DIR);
688        std::fs::create_dir_all(&staging_root)?;
689        staged = tempfile::Builder::new()
690            .prefix("import-")
691            .tempdir_in(&staging_root)?;
692        // Held for the rest of the import so a concurrent sweep can tell this
693        // tree from one a killed process abandoned. The gap between creating
694        // the directory and taking the lock needs no closing: a sweep only
695        // considers trees a day old, and this one is new.
696        _staging_lock = Some(lock_import_staging(staged.path())?);
697        unpack_archive(archive, staged.path())?;
698        staged.path()
699    };
700    // Measure a directory bundle now: publication moves its objects into the
701    // store, so by the end there is nothing left to measure.
702    let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
703    let manifest: ExportManifest =
704        serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
705    let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
706    let task_identities = manifest
707        .tasks
708        .iter()
709        .map(|task| task.task.as_str())
710        .collect::<BTreeSet<_>>();
711    if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
712        || (manifest.version == LEGACY_EXPORT_VERSION
713            && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
714        || manifest.tasks.is_empty()
715        || actions.len() != manifest.actions.len()
716        || task_identities.len() != manifest.tasks.len()
717        || manifest.tasks.iter().any(|task| !task.validate())
718        || manifest
719            .actions
720            .iter()
721            .any(|action| action.algorithm != "blake3" || action.validate().is_err())
722        || manifest.tasks.iter().any(|task| {
723            task.predictions
724                .iter()
725                .any(|prediction| !actions.contains(&prediction.action))
726        })
727        || manifest
728            .attachments
729            .keys()
730            .any(|name| !valid_attachment_name(name))
731        || manifest.attachments.values().any(|digest| {
732            digest.algorithm != "blake3"
733                || digest.validate().is_err()
734                || !manifest.objects.contains(digest)
735        })
736        || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
737        || manifest
738            .objects
739            .iter()
740            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
741    {
742        eyre::bail!("unsupported or invalid cache export manifest");
743    }
744    let mut closure =
745        strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
746    let staged_cas = LocalCas::new(root);
747    for digest in &manifest.objects {
748        require_object(&staged_cas, &mut closure, digest)
749            .wrap_err("cache export attachment is incomplete or corrupt")?;
750    }
751    verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
752
753    // Read every action result before anything is published. Publication moves
754    // a directory bundle's objects into the store, and a bundle cannot be
755    // re-imported once that has happened, so every failure that can be moved
756    // ahead of it should be: a malformed result has to fail while the bundle
757    // is still whole. Publication can still fail (for example on store I/O or
758    // a concurrent invalid action write); it does not roll back moved objects.
759    // Intact conflicting results follow the first-writer rule and are kept.
760    let results = closure
761        .results
762        .iter()
763        .map(|path| {
764            let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
765                .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
766            Ok(result)
767        })
768        .collect::<Result<Vec<_>>>()
769        .wrap_err("cache export is incomplete or corrupt")?;
770
771    let cas = LocalCas::new(store);
772    for path in &closure.objects {
773        let relative = path.strip_prefix(root)?;
774        let source = root.join(relative);
775        let digest = addressed_digest(root, &source, false)
776            .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
777        // `strict_closure` verified every path in `objects` against its
778        // content-addressed name. Preserve that proof and move the owned
779        // staging file instead of hashing or copying the bytes again.
780        cas.adopt_verified_file(&digest, &source)?;
781    }
782    let action_cache = mbx_cache_core::LocalActionCache::new(store);
783    let mut kept_existing = 0;
784    for result in &results {
785        if action_cache.store_unless_restorable(result, |existing| {
786            extend_result_closure(
787                &cas,
788                &mut Closure::default(),
789                &mut BTreeSet::new(),
790                existing,
791                false,
792            )
793            .is_ok()
794        })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
795        {
796            kept_existing += 1;
797        }
798    }
799    if kept_existing > 0 {
800        log::debug!(
801            "cache import kept {kept_existing} intact existing results that differed from the bundle"
802        );
803    }
804    for task in manifest.tasks {
805        merge_imported_manifest(store, task)?;
806    }
807    let bytes = match bundle_bytes {
808        Some(bytes) => {
809            // Publication moved the objects out of the bundle, so what is left
810            // is a shell of empty directories. Removing it keeps a restored
811            // bundle out of the job's disk budget for the rest of the run.
812            std::fs::remove_dir_all(archive)
813                .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
814            bytes
815        }
816        None => std::fs::metadata(archive)?.len(),
817    };
818    Ok(ImportOutcome {
819        transfer: TransferOutcome {
820            actions: actions.len() as u64,
821            objects: closure.objects.len() as u64,
822            bytes,
823        },
824        attachments: manifest.attachments,
825    })
826}
827
828fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
829    if additions
830        .attachments
831        .keys()
832        .any(|name| !valid_attachment_name(name))
833        || additions
834            .attachments
835            .values()
836            .any(|digest| !additions.objects.contains(digest))
837        || additions
838            .objects
839            .iter()
840            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
841    {
842        eyre::bail!("invalid cache export attachment");
843    }
844    Ok(())
845}
846
847fn valid_attachment_name(name: &str) -> bool {
848    !name.is_empty()
849        && name.len() <= 128
850        && name
851            .bytes()
852            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
853}
854
855/// Everything one closure walk reached, and the leaves still to be hashed.
856#[derive(Default)]
857struct Closure {
858    objects: BTreeSet<PathBuf>,
859    results: BTreeSet<PathBuf>,
860    pending: Vec<PendingObject>,
861}
862
863/// A leaf object recorded by the walk and awaiting content verification.
864struct PendingObject {
865    digest: CacheDigest,
866    path: PathBuf,
867}
868
869fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
870    let cas = LocalCas::new(store);
871    let action_cache = mbx_cache_core::LocalActionCache::new(store);
872    let mut closure = Closure::default();
873    let mut directories = BTreeSet::new();
874    for action in actions {
875        let result = action_cache
876            .find(action)?
877            .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
878        closure.results.insert(action_cache.path_for(action)?);
879        extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
880    }
881    Ok(closure)
882}
883
884/// Walk one result's dependencies. Export/import validation hashes descriptors;
885/// checking an existing result for repair only needs presence and length checks.
886fn extend_result_closure(
887    cas: &LocalCas,
888    closure: &mut Closure,
889    directories: &mut BTreeSet<CacheDigest>,
890    result: &RemoteActionResult,
891    verify_descriptors: bool,
892) -> Result<()> {
893    let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
894        if verify_descriptors {
895            require_parsed_object(cas, closure, digest)
896        } else {
897            require_object(cas, closure, digest)?;
898            cas.path_for(digest)
899        }
900    };
901    require_object(cas, closure, &result.action)?;
902    if let Some(metadata) = &result.metadata {
903        let path = parsed_object(closure, metadata)?;
904        let captured: CapturedOutput =
905            serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
906        require_object(cas, closure, &captured.stdout)?;
907        require_object(cas, closure, &captured.stderr)?;
908    }
909    if let Some(root) = &result.output_root {
910        let mut nodes = vec![root.clone()];
911        while let Some(digest) = nodes.pop() {
912            if !directories.insert(digest.clone()) {
913                continue;
914            }
915            let path = parsed_object(closure, &digest)?;
916            let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
917                .wrap_err("output directory is invalid")?;
918            for file in directory.files {
919                require_object(cas, closure, &file.digest)?;
920            }
921            for child in directory.directories {
922                nodes.push(child.digest);
923            }
924        }
925    }
926    Ok(())
927}
928
929/// Verify every recorded leaf's contents, spread across the machine.
930///
931/// Callers must run this before publishing anything the walk reached: it is
932/// the step that proves the bytes match their content-addressed names, and
933/// both the exporter and the importer depend on that proof. A closure of a
934/// few thousand compiler outputs is several gigabytes read once, and the
935/// hash, not the read, is what saturates a core.
936fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
937    if pending.is_empty() {
938        return Ok(());
939    }
940    // Longest first. Handing out a half-gigabyte rlib last would leave one
941    // worker hashing it alone after the others have finished, and the lengths
942    // cost nothing: the walk already checked each one against its digest.
943    pending.sort_by(|left, right| {
944        right
945            .digest
946            .size
947            .cmp(&left.digest.size)
948            .then_with(|| left.path.cmp(&right.path))
949    });
950    let workers = std::thread::available_parallelism()
951        .map(usize::from)
952        .unwrap_or(1)
953        .min(pending.len());
954    let next = AtomicUsize::new(0);
955    // Report the lowest failing path rather than whichever worker happened to
956    // lose the race, so a corrupt closure names the same object every run.
957    let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
958    let pending: &[PendingObject] = pending;
959    std::thread::scope(|scope| {
960        for _ in 0..workers {
961            scope.spawn(|| {
962                loop {
963                    let index = next.fetch_add(1, Ordering::Relaxed);
964                    let Some(entry) = pending.get(index) else {
965                        break;
966                    };
967                    let error = match entry.digest.matches_file(&entry.path) {
968                        Ok(true) => continue,
969                        Ok(false) => eyre::eyre!(
970                            "local CAS blob failed digest verification: {}",
971                            entry.path.display()
972                        ),
973                        Err(error) => {
974                            error.wrap_err(format!("failed to verify {}", entry.path.display()))
975                        }
976                    };
977                    let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
978                    if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
979                        *failure = Some((entry.path.clone(), error));
980                    }
981                }
982            });
983        }
984    });
985    match failure
986        .into_inner()
987        .unwrap_or_else(|error| error.into_inner())
988    {
989        Some((_, error)) => Err(error),
990        None => Ok(()),
991    }
992}
993
994#[derive(Deserialize)]
995struct CapturedOutput {
996    stdout: CacheDigest,
997    stderr: CacheDigest,
998}
999
1000fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1001    let identity = imported.task.clone();
1002    let destination_path = task_manifest_path(destination, &identity);
1003    let lock_path = task_manifest_lock_path(destination, &identity);
1004    std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1005    let mut lock = fslock::LockFile::open(&lock_path)?;
1006    lock.lock()?;
1007    if let Ok(bytes) = std::fs::read(&destination_path)
1008        && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1009        && existing.task == identity
1010        && existing.validate()
1011    {
1012        let existing_predictions: BTreeMap<_, _> = existing
1013            .predictions
1014            .iter()
1015            .cloned()
1016            .map(|prediction| (prediction.invocation.clone(), prediction))
1017            .collect();
1018        // The bundle determines recency, but a checkout's current value wins
1019        // when both manifests predict the same invocation.
1020        let updates = imported
1021            .predictions
1022            .into_iter()
1023            .map(|prediction| {
1024                existing_predictions
1025                    .get(&prediction.invocation)
1026                    .cloned()
1027                    .unwrap_or(prediction)
1028            })
1029            .collect();
1030        imported.predictions =
1031            merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1032    }
1033    write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1034}
1035
1036/// Record a leaf object, deferring its content hash to `verify_pending`.
1037///
1038/// Presence and declared length are settled here, while the walk can still say
1039/// cheaply which object is wrong. The contents are left for the parallel pass
1040/// because leaves are where the bytes are: compiler outputs, captured streams,
1041/// and attachments, none of which steer the walk.
1042fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1043    let expected = cas.path_for(digest)?;
1044    if !closure.objects.insert(expected.clone()) {
1045        return Ok(());
1046    }
1047    let metadata = match std::fs::metadata(&expected) {
1048        Ok(metadata) => metadata,
1049        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1050            eyre::bail!("cache object is missing for {}", digest.hash)
1051        }
1052        Err(error) => {
1053            return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1054        }
1055    };
1056    if metadata.len() != digest.size {
1057        eyre::bail!(
1058            "local CAS blob failed digest verification: {}",
1059            expected.display()
1060        );
1061    }
1062    closure.pending.push(PendingObject {
1063        digest: digest.clone(),
1064        path: expected,
1065    });
1066    Ok(())
1067}
1068
1069/// Verify an object the walk is about to deserialize, and return its path.
1070///
1071/// Action metadata and directory nodes decide what the walk visits next, so a
1072/// forged one steers the walk itself and has to be trusted before `serde_json`
1073/// sees it. They are canonical JSON and small, so hashing them in line costs
1074/// little next to the leaves they point at.
1075fn require_parsed_object(
1076    cas: &LocalCas,
1077    closure: &mut Closure,
1078    digest: &CacheDigest,
1079) -> Result<PathBuf> {
1080    let path = cas
1081        .find(digest)?
1082        .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1083    closure.objects.insert(path.clone());
1084    Ok(path)
1085}
1086
1087fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1088    store
1089        .join("task-manifests/v1")
1090        .join(format!("{identity}.json"))
1091}
1092
1093fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1094    store
1095        .join("task-manifests/v1/locks")
1096        .join(format!("{identity}.lock"))
1097}
1098
1099fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1100    let name = path.strip_prefix(root)?;
1101    builder.append_path_with_name(path, name)?;
1102    Ok(())
1103}
1104
1105fn append_bytes(
1106    builder: &mut tar::Builder<std::fs::File>,
1107    name: &Path,
1108    bytes: &[u8],
1109) -> Result<()> {
1110    let mut header = tar::Header::new_gnu();
1111    header.set_size(bytes.len() as u64);
1112    header.set_mode(0o644);
1113    header.set_cksum();
1114    builder.append_data(&mut header, name, bytes)?;
1115    Ok(())
1116}
1117
1118/// Unpack a tar export into an empty staging directory.
1119fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1120    let file = std::fs::File::open(archive)
1121        .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1122    let mut bundle = tar::Archive::new(file);
1123    let mut seen = BTreeSet::new();
1124    for entry in bundle.entries()? {
1125        let mut entry = entry?;
1126        let entry_type = entry.header().entry_type();
1127        if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1128            eyre::bail!("cache export contains a non-file entry");
1129        }
1130        let path = entry.path()?.into_owned();
1131        validate_archive_path(&path)?;
1132        if !seen.insert(path.clone()) {
1133            eyre::bail!("cache export contains duplicate entry {}", path.display());
1134        }
1135        let destination = staging.join(&path);
1136        std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1137        // `unpack` understands GNU sparse maps. A plain stream copy expands
1138        // holes into physical zeroes, which is both slower and much larger for
1139        // Rust artifacts containing sparse sections.
1140        entry.unpack(&destination)?;
1141    }
1142    Ok(())
1143}
1144
1145/// Check a directory bundle against the policy a tar bundle is held to.
1146///
1147/// A tar carries its own entry types, so the importer can refuse a symlink or
1148/// a device node by reading the header. A directory has to be walked for the
1149/// same answer, and `DirEntry::metadata` does not follow symlinks, so anything
1150/// that is not a plain file or a directory is rejected here rather than
1151/// followed out of the bundle.
1152fn validate_directory_bundle(root: &Path) -> Result<()> {
1153    let mut pending = vec![root.to_path_buf()];
1154    while let Some(directory) = pending.pop() {
1155        for entry in std::fs::read_dir(&directory)
1156            .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1157        {
1158            let entry = entry?;
1159            let metadata = entry.metadata()?;
1160            let path = entry.path();
1161            if metadata.is_dir() {
1162                pending.push(path);
1163                continue;
1164            }
1165            if !metadata.is_file() {
1166                eyre::bail!("cache export contains a non-file entry");
1167            }
1168            validate_archive_path(path.strip_prefix(root)?)?;
1169            reject_linked_file(&metadata, &path)?;
1170        }
1171    }
1172    Ok(())
1173}
1174
1175/// Refuse a bundle file that shares its inode with a name outside the bundle.
1176///
1177/// Import adopts objects by moving them into the CAS. A second hard link would
1178/// survive that move and keep write access to a blob the store then treats as
1179/// verified and immutable, which is a way to change a verified object after it
1180/// has been checked.
1181#[cfg(unix)]
1182fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1183    use std::os::unix::fs::MetadataExt as _;
1184
1185    if metadata.nlink() > 1 {
1186        eyre::bail!("cache export contains a hard link at {}", path.display());
1187    }
1188    Ok(())
1189}
1190
1191/// Windows reports a link count only through `MetadataExt::number_of_links`,
1192/// which is unstable, so this check cannot be made there without reaching for
1193/// the platform API directly. The gap is narrow -- it needs an attacker who
1194/// can already write into the job's filesystem before the import runs -- but
1195/// it is a gap, and a Windows directory bundle does not get this protection.
1196#[cfg(not(unix))]
1197fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1198    Ok(())
1199}
1200
1201fn validate_archive_path(path: &Path) -> Result<()> {
1202    if path.as_os_str().is_empty()
1203        || path.is_absolute()
1204        || path
1205            .components()
1206            .any(|component| !matches!(component, Component::Normal(_)))
1207    {
1208        eyre::bail!("cache export contains unsafe path {}", path.display());
1209    }
1210    // Compare components rather than a spelling. A tar entry always writes its
1211    // path with forward slashes, but a directory bundle is walked with the
1212    // platform's separator, so on Windows a prefix match against "cas/v1/"
1213    // would reject every file in the bundle.
1214    let parts = path
1215        .components()
1216        .map(|component| component.as_os_str().to_string_lossy().into_owned())
1217        .collect::<Vec<_>>();
1218    let expected = match parts.as_slice() {
1219        [name] => name == EXPORT_MANIFEST,
1220        [root, version, _rest @ ..] => {
1221            parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1222        }
1223        [] => false,
1224    };
1225    if !expected {
1226        eyre::bail!("cache export contains unexpected path {}", path.display());
1227    }
1228    Ok(())
1229}
1230
1231/// One checkout's claim on the actions a build identity recorded.
1232///
1233/// The target directory is not read back by anything yet; it is recorded
1234/// because it is the other half of what the shim mapped, and a record that
1235/// names only half of it would have to be rewritten to answer where the
1236/// artifacts went.
1237#[derive(Debug, Clone, Serialize, Deserialize)]
1238#[serde(deny_unknown_fields)]
1239struct CheckoutRecord {
1240    version: u8,
1241    workspace_root: PathBuf,
1242    target_dir: PathBuf,
1243    updated_secs: u64,
1244}
1245
1246/// Summarize what the store currently holds.
1247pub fn stats(store: &Path) -> Result<StoreStats> {
1248    let objects = walk_files(&store.join(CAS_DIR))?;
1249    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1250    let checkouts = scan_checkouts(store)?;
1251    Ok(StoreStats {
1252        objects: objects.len() as u64,
1253        object_bytes: objects.iter().map(|entry| entry.size).sum(),
1254        action_results: results.len() as u64,
1255        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1256        live_checkouts: checkouts.live_records,
1257        stale_checkouts: checkouts.stale_records.len() as u64,
1258    })
1259}
1260
1261/// Size the target tree a checkout record names, if it names one.
1262///
1263/// A build with no target directory of its own -- one driven by make or CMake,
1264/// which writes wherever it was told -- records the checkout in that field
1265/// rather than inventing a directory. Walking it would report the whole source
1266/// tree, `.git` included, as though it were build output.
1267fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1268    if record.target_dir == record.workspace_root {
1269        return 0;
1270    }
1271    cached_tree_bytes(sizes, &record.target_dir)
1272}
1273
1274/// Attribute cache and target bytes to each recorded workspace.
1275///
1276/// Shared objects are counted for every workspace that can reach them. That
1277/// makes each row answer "how much keeps this workspace warm" without
1278/// pretending shared storage can be divided exactly between projects.
1279pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1280    project_usage(store, true)
1281}
1282
1283/// The reusable cache bytes of each live workspace, as [`projects`] counts
1284/// them.
1285///
1286/// Sizing target directories is most of what [`projects`] costs on a machine
1287/// with many checkouts, and an estimate of cache sharing never looks at them.
1288pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1289    Ok(project_usage(store, false)?
1290        .into_iter()
1291        .filter(|project| project.live)
1292        .map(|project| project.action_bytes)
1293        .collect())
1294}
1295
1296fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1297    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1298    let mut target_sizes = BTreeMap::new();
1299    let root = store.join(CHECKOUTS_DIR);
1300    for identity_entry in read_dir_or_empty(&root)? {
1301        let identity = identity_entry.file_name().to_string_lossy().into_owned();
1302        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1303            continue;
1304        }
1305        for entry in walk_files(&identity_entry.path())? {
1306            let Some(record) = read_checkout_record(&entry.path) else {
1307                continue;
1308            };
1309            let project = projects.entry(record.workspace_root.clone()).or_default();
1310            if claim_is_live(store, &record) {
1311                project.0.insert(identity.clone());
1312                project.1 = true;
1313            }
1314            if size_targets {
1315                project.2 = project
1316                    .2
1317                    .max(checkout_target_bytes(&mut target_sizes, &record));
1318            }
1319        }
1320    }
1321    let live = projects
1322        .values()
1323        .flat_map(|(identities, _, _)| identities.iter().cloned())
1324        .collect::<BTreeSet<_>>();
1325    let mut reachability = Reachability::new(store);
1326    resolve_identities(&reachability, &live);
1327    let mut usages = Vec::new();
1328    for (workspace_root, (identities, live, target_bytes)) in projects {
1329        let action_bytes = reachability.bytes(&identities);
1330        usages.push(ProjectUsage {
1331            workspace_root,
1332            identities: identities.len() as u64,
1333            action_bytes,
1334            target_bytes,
1335            live,
1336        });
1337    }
1338    usages.sort_by(|left, right| {
1339        right
1340            .action_bytes
1341            .saturating_add(right.target_bytes)
1342            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1343            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1344    });
1345    Ok(usages)
1346}
1347
1348/// Resolve every identity's recorded actions into `reachability`.
1349///
1350/// Manifests are the one large input: a busy checkout's runs to tens of
1351/// megabytes of JSON, and parsing hundreds of them one after another is most
1352/// of what sizing every workspace costs. Resolving what they reach is mostly
1353/// cold file reads. Both are spread over a few threads, each taking whole
1354/// identities from a shared counter.
1355fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1356    const MAX_WORKERS: usize = 8;
1357    let identities = identities.iter().collect::<Vec<_>>();
1358    let workers = std::thread::available_parallelism()
1359        .map(usize::from)
1360        .unwrap_or(1)
1361        .min(MAX_WORKERS)
1362        .min(identities.len());
1363    let next = AtomicUsize::new(0);
1364    std::thread::scope(|scope| {
1365        for _ in 0..workers {
1366            scope.spawn(|| {
1367                while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1368                    // One manifest this build cannot read is not worth
1369                    // abandoning the report over. It reaches nothing, which
1370                    // can only understate sharing.
1371                    let actions = task_manifest_actions(&reachability.store, identity)
1372                        .unwrap_or_else(|error| {
1373                            log::debug!("could not read the manifest for {identity}: {error}");
1374                            Vec::new()
1375                        });
1376                    reachability.record(identity, &actions);
1377                }
1378            });
1379        }
1380    });
1381}
1382
1383/// The store paths each identity can reach, resolved once for every workspace.
1384///
1385/// Workspaces share identities, identities share most of their actions, and
1386/// actions share output trees. Answering each workspace from scratch reread
1387/// the same manifests, results, and trees, and stat-ed the same objects, once
1388/// per workspace, which on a machine with a couple of hundred checkouts was
1389/// most of the half minute `mbx stats` took. Here every file is read and
1390/// stat-ed at most once, and a workspace's total is a union of indices already
1391/// in memory.
1392///
1393/// What is reachable matches [`rooted_objects`] plus the action-result records
1394/// themselves, and every read is as tolerant as it is there.
1395struct Reachability {
1396    store: PathBuf,
1397    cas: LocalCas,
1398    action_cache: mbx_cache_core::LocalActionCache,
1399    /// Every indexed path and its size, zero if it no longer exists.
1400    paths: Mutex<PathIndex>,
1401    identities: Mutex<HashMap<String, ActionPaths>>,
1402    actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1403    /// Each tree blob's child directories and the paths it names directly.
1404    directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1405    /// The workspace number that last counted each path.
1406    counted: Vec<usize>,
1407    workspaces: usize,
1408}
1409
1410#[derive(Default)]
1411struct PathIndex {
1412    indices: HashMap<PathBuf, usize>,
1413    sizes: Vec<u64>,
1414}
1415
1416/// The paths each of an identity's actions reaches.
1417type ActionPaths = Arc<[Arc<[usize]>]>;
1418
1419type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1420
1421/// The caches are only guards around maps; a panicked holder leaves them
1422/// consistent, so poisoning is not worth failing a report over.
1423fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1424    mutex.lock().unwrap_or_else(|error| error.into_inner())
1425}
1426
1427impl Reachability {
1428    fn new(store: &Path) -> Self {
1429        Self {
1430            store: store.to_path_buf(),
1431            cas: LocalCas::new(store),
1432            action_cache: mbx_cache_core::LocalActionCache::new(store),
1433            paths: Mutex::default(),
1434            identities: Mutex::default(),
1435            actions: Mutex::default(),
1436            directories: Mutex::default(),
1437            counted: Vec::new(),
1438            workspaces: 0,
1439        }
1440    }
1441
1442    /// Bytes reachable from any of `identities`, each path counted once.
1443    ///
1444    /// An identity that was never recorded reaches nothing.
1445    fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1446        let reachable = {
1447            let recorded = locked(&self.identities);
1448            identities
1449                .iter()
1450                .filter_map(|identity| recorded.get(identity).cloned())
1451                .collect::<Vec<_>>()
1452        };
1453        let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1454        self.workspaces += 1;
1455        self.counted.resize(sizes.len(), 0);
1456        let mut bytes = 0_u64;
1457        for index in reachable
1458            .iter()
1459            .flat_map(|actions| actions.iter())
1460            .flat_map(|paths| paths.iter())
1461        {
1462            if self.counted[*index] != self.workspaces {
1463                self.counted[*index] = self.workspaces;
1464                bytes = bytes.saturating_add(sizes[*index]);
1465            }
1466        }
1467        locked(&self.paths).sizes = sizes;
1468        bytes
1469    }
1470
1471    /// Resolve what one identity's recorded actions reach.
1472    ///
1473    /// Safe to call from several threads. Two threads may resolve the same
1474    /// action at once; they read the same files and reach the same answer.
1475    fn record(&self, identity: &str, actions: &[CacheDigest]) {
1476        let actions = actions
1477            .iter()
1478            .map(|action| self.action(action))
1479            .collect::<Arc<[_]>>();
1480        locked(&self.identities).insert(identity.to_string(), actions);
1481    }
1482
1483    fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1484        if let Some(paths) = locked(&self.actions).get(action) {
1485            return paths.clone();
1486        }
1487        let mut paths = Vec::new();
1488        if let Ok(path) = self.action_cache.path_for(action) {
1489            paths.push(self.index(path));
1490        }
1491        if let Some(result) = read_action_result(&self.store, action) {
1492            self.push_digest(&mut paths, &result.action);
1493            if let Some(metadata) = &result.metadata {
1494                self.push_digest(&mut paths, metadata);
1495                if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1496                    self.push_digest(&mut paths, &rustc.stdout);
1497                    self.push_digest(&mut paths, &rustc.stderr);
1498                }
1499            }
1500            if let Some(output_root) = &result.output_root {
1501                self.push_digest(&mut paths, output_root);
1502                let mut pending = vec![output_root.clone()];
1503                let mut visited = HashSet::new();
1504                while let Some(digest) = pending.pop() {
1505                    if !visited.insert(digest.clone()) {
1506                        continue;
1507                    }
1508                    let Some((children, named)) = self.directory(&digest) else {
1509                        continue;
1510                    };
1511                    paths.extend(named.iter().copied());
1512                    pending.extend(children.iter().cloned());
1513                }
1514            }
1515        }
1516        paths.sort_unstable();
1517        paths.dedup();
1518        let paths = Arc::<[usize]>::from(paths);
1519        locked(&self.actions).insert(action.clone(), paths.clone());
1520        paths
1521    }
1522
1523    /// The child directories of a tree blob and the paths it names directly.
1524    fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1525        if let Some(entry) = locked(&self.directories).get(digest) {
1526            return entry.clone();
1527        }
1528        let entry = read_directory(&self.cas, digest).map(|directory| {
1529            let mut paths = Vec::new();
1530            for file in &directory.files {
1531                self.push_digest(&mut paths, &file.digest);
1532            }
1533            for child in &directory.directories {
1534                self.push_digest(&mut paths, &child.digest);
1535            }
1536            let children = directory
1537                .directories
1538                .into_iter()
1539                .map(|child| child.digest)
1540                .collect::<Arc<[_]>>();
1541            (children, Arc::<[usize]>::from(paths))
1542        });
1543        locked(&self.directories).insert(digest.clone(), entry.clone());
1544        entry
1545    }
1546
1547    fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1548        if let Ok(path) = self.cas.path_for(digest) {
1549            paths.push(self.index(path));
1550        }
1551    }
1552
1553    fn index(&self, path: PathBuf) -> usize {
1554        if let Some(index) = locked(&self.paths).indices.get(&path) {
1555            return *index;
1556        }
1557        // The stat runs outside the lock. A second thread may stat the same
1558        // path; whichever registers first wins and both get one index.
1559        let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1560        let mut paths = locked(&self.paths);
1561        if let Some(index) = paths.indices.get(&path) {
1562            return *index;
1563        }
1564        let index = paths.sizes.len();
1565        paths.sizes.push(size);
1566        paths.indices.insert(path, index);
1567        index
1568    }
1569}
1570
1571/// Return the largest blobs and action-result records in descending order.
1572pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1573    let mut entries = Vec::new();
1574    for (kind, root, action_result) in [
1575        ("object", store.join(CAS_DIR), false),
1576        ("action result", store.join(ACTION_RESULTS_DIR), true),
1577    ] {
1578        entries.extend(
1579            walk_files(&root)?
1580                .into_iter()
1581                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1582                .map(|entry| LargestEntry {
1583                    kind,
1584                    path: entry.path,
1585                    bytes: entry.size,
1586                }),
1587        );
1588    }
1589    entries.sort_by(|left, right| {
1590        right
1591            .bytes
1592            .cmp(&left.bytes)
1593            .then_with(|| left.path.cmp(&right.path))
1594    });
1595    entries.truncate(limit);
1596    Ok(entries)
1597}
1598
1599/// Verify every addressable CAS object and action-result record.
1600pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1601    let cas = LocalCas::new(store);
1602    let action_cache = mbx_cache_core::LocalActionCache::new(store);
1603    // Verification reports the store as it is on disk and removes nothing, so
1604    // no object is pending removal the way one is mid-sweep.
1605    let virtually_removed = HashSet::new();
1606    let mut outcome = VerifyOutcome::default();
1607    let mut trees = HashMap::new();
1608    for entry in walk_files(&store.join(CAS_DIR))? {
1609        let Some(digest) = addressed_digest(store, &entry.path, false) else {
1610            continue;
1611        };
1612        outcome.checked_objects += 1;
1613        if !matches!(cas.find(&digest), Ok(Some(_))) {
1614            outcome.problems.push(entry.path);
1615        }
1616    }
1617    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1618        let Some(digest) = addressed_digest(store, &entry.path, true) else {
1619            continue;
1620        };
1621        outcome.checked_action_results += 1;
1622        if !matches!(action_cache.find(&digest), Ok(Some(_)))
1623            || action_result_is_dangling(&cas, &entry.path, &virtually_removed, true, &mut trees)?
1624        {
1625            outcome.problems.push(entry.path);
1626        }
1627    }
1628    outcome.problems.sort();
1629    Ok(outcome)
1630}
1631
1632/// Remove checkout claims belonging to exactly one workspace.
1633pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1634    let mut outcome = RemoveProjectOutcome::default();
1635    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1636        if read_checkout_record(&entry.path)
1637            .is_some_and(|record| record.workspace_root == workspace_root)
1638        {
1639            std::fs::remove_file(&entry.path)?;
1640            outcome.removed_checkout_records += 1;
1641            if let Some(parent) = entry.path.parent() {
1642                let _ = std::fs::remove_dir(parent);
1643            }
1644        }
1645    }
1646    match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1647        Ok(()) => {}
1648        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1649        Err(error) => return Err(error.into()),
1650    }
1651    Ok(outcome)
1652}
1653
1654/// Record that `workspace_root` built `identity`.
1655///
1656/// Identities are shared by every checkout of one dependency graph, so this is
1657/// what later tells the collector whether anything still needs what a build
1658/// cached: one file per checkout under the identity it built. Writing a whole
1659/// file per checkout rather than merging a list into one keeps concurrent
1660/// builds out of each other's way -- there is nothing to merge, so there is no
1661/// lock and no lost update.
1662///
1663/// A build with no target directory of its own passes `workspace_root` for
1664/// `target_dir`, which reads as "none" rather than as a tree to measure.
1665pub fn record_checkout(
1666    store: &Path,
1667    identity: &str,
1668    workspace_root: &Path,
1669    target_dir: &Path,
1670) -> Result<()> {
1671    let record = CheckoutRecord {
1672        version: CHECKOUT_RECORD_VERSION,
1673        workspace_root: workspace_root.to_path_buf(),
1674        target_dir: target_dir.to_path_buf(),
1675        updated_secs: SystemTime::now()
1676            .duration_since(UNIX_EPOCH)
1677            .map(|since| since.as_secs())
1678            .unwrap_or_default(),
1679    };
1680    let mut contents = serde_json::to_vec(&record)?;
1681    contents.push(b'\n');
1682    // Refreshed by every build and read back only as a last-used stamp, so a
1683    // power cut costs one build's worth of recency, not a wait on the disk.
1684    write_advisory(
1685        &checkout_record_path(store, identity, workspace_root),
1686        &contents,
1687    )
1688}
1689
1690fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1691    // The path is hashed rather than escaped: it is only ever compared against
1692    // the same hash of the same path, and every filesystem in play disagrees
1693    // about which characters a name may hold.
1694    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1695    store
1696        .join(CHECKOUTS_DIR)
1697        .join(identity)
1698        .join(format!("{key}.json"))
1699}
1700
1701/// Evict objects until the store fits within `max_bytes`.
1702///
1703/// Objects no live checkout can reach are evicted first, so deleting a worktree
1704/// releases what only that worktree needed. Within each class eviction is
1705/// least-recently-used by access time where the filesystem records it, falling
1706/// back to modification time. A restore verifies the blob it serves, which
1707/// means it reads the whole file and the access time is real -- but only where
1708/// the mount records one, so the ordering is an approximation either way.
1709/// Evicting a live object costs a recompile, never correctness.
1710pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1711    gc_with_mode(store, max_bytes, false, HashSet::new())
1712}
1713
1714/// Describe the collection `gc` would perform without changing the store.
1715pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1716    gc_with_mode(store, max_bytes, true, HashSet::new())
1717}
1718
1719#[cfg(test)]
1720type DanglingReadHook = Box<dyn FnMut(&Path)>;
1721
1722#[cfg(test)]
1723thread_local! {
1724    static ACTION_RECORD_CLAIMS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1725    /// Runs after collection finds a record dangling and before it claims it.
1726    static AFTER_DANGLING_READ: std::cell::RefCell<Option<DanglingReadHook>> =
1727        const { std::cell::RefCell::new(None) };
1728}
1729
1730/// `already_removed` names paths an earlier dry-run pass planned to delete;
1731/// they are treated as gone, so the two plans describe one collection.
1732fn gc_with_mode(
1733    store: &Path,
1734    max_bytes: u64,
1735    dry_run: bool,
1736    already_removed: HashSet<PathBuf>,
1737) -> Result<GcOutcome> {
1738    let mut objects = walk_files(&store.join(CAS_DIR))?;
1739    let mut results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1740    objects.retain(|entry| !already_removed.contains(&entry.path));
1741    results.retain(|entry| !already_removed.contains(&entry.path));
1742    let mut live_bytes = objects
1743        .iter()
1744        .chain(results.iter())
1745        .map(|entry| entry.size)
1746        .sum::<u64>();
1747
1748    let mut outcome = GcOutcome::default();
1749
1750    // Prune before deciding what is rooted, so a checkout deleted since the
1751    // last sweep stops protecting its artifacts during this one.
1752    let checkouts = scan_checkouts(store)?;
1753    for path in &checkouts.stale_records {
1754        if dry_run || matches!(remove(path)?, Removal::Removed) {
1755            outcome.removed_checkout_records += 1;
1756        }
1757        // The identity directory is left behind empty otherwise. It may hold
1758        // other checkouts, in which case this fails and that is the answer.
1759        if !dry_run && let Some(parent) = path.parent() {
1760            let _ = std::fs::remove_dir(parent);
1761        }
1762    }
1763
1764    let mut virtually_removed = already_removed;
1765    if live_bytes > max_bytes {
1766        // Reachability costs a read per action result and per output tree, so
1767        // it is computed only when something is actually about to be evicted.
1768        // Under budget a sweep stays a directory walk and nothing more.
1769        let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1770        // A grouped CI export deliberately retains the exact actions from
1771        // every command in the job. Later commands can replace the current
1772        // task-manifest prediction for the same invocation, but the earlier
1773        // receipt still has to remain exportable until the post-job step runs.
1774        // Receipt bookkeeping is not part of the cache budget, so consult it
1775        // only when a sweep is already necessary and root its object closure.
1776        rooted.extend(rooted_action_objects(
1777            store,
1778            grouped_receipt_actions(store)?,
1779        ));
1780        // `false` sorts first, so unrooted objects go before rooted ones and
1781        // each class goes oldest-first. A store with no records at all roots
1782        // nothing and this is exactly the LRU it was before.
1783        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1784        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1785            if dry_run {
1786                virtually_removed.insert(path.to_path_buf());
1787                Ok(Removal::Removed)
1788            } else {
1789                remove(path)
1790            }
1791        })?;
1792        live_bytes = evicted.remaining_bytes;
1793        outcome.removed_objects += evicted.removed_objects;
1794        outcome.removed_bytes += evicted.removed_bytes;
1795    }
1796
1797    // An action result whose objects are gone can only produce a miss, so drop
1798    // it rather than leave the index pointing at nothing. This runs whether or
1799    // not this call evicted anything, since another process may have, and a
1800    // store that is over budget on results alone still needs the sweep.
1801    //
1802    // Walking each result's output tree costs a read per tree blob and a stat
1803    // per file, so it is done only when this sweep evicted something. That is
1804    // the moment a tree can have lost a file while its own object survived.
1805    //
1806    // Only this call's own evictions count, in a dry run too: objects a stale
1807    // pass planned to drop are accounted for by that pass, and a real run has
1808    // already deleted them, so neither makes this sweep walk trees.
1809    let walk_trees = outcome.removed_objects > 0;
1810    let cas = LocalCas::new(store);
1811    let mut trees = HashMap::new();
1812    for entry in &results {
1813        match action_result_is_dangling(
1814            &cas,
1815            &entry.path,
1816            &virtually_removed,
1817            walk_trees,
1818            &mut trees,
1819        ) {
1820            Ok(false) => continue,
1821            Ok(true) => {}
1822            // Windows readers can encounter another repair's mandatory lock.
1823            Err(error)
1824                if error
1825                    .chain()
1826                    .filter_map(|cause| cause.downcast_ref::<std::io::Error>())
1827                    .any(held_by_another_process) =>
1828            {
1829                log::debug!(
1830                    "could not inspect {} for eviction: {error}",
1831                    entry.path.display()
1832                );
1833                continue;
1834            }
1835            Err(error) => return Err(error),
1836        }
1837        let removal = if dry_run {
1838            Removal::Removed
1839        } else {
1840            #[cfg(test)]
1841            AFTER_DANGLING_READ.with(|hook| {
1842                if let Some(hook) = hook.borrow_mut().as_mut() {
1843                    hook(&entry.path);
1844                }
1845            });
1846            #[cfg(test)]
1847            ACTION_RECORD_CLAIMS.with(|count| count.set(count.get() + 1));
1848            match ActionSlot::try_claim(&entry.path) {
1849                Err(error) if held_by_another_process(&error) => {
1850                    log::debug!(
1851                        "could not claim {} for eviction: {error}",
1852                        entry.path.display()
1853                    );
1854                    Removal::Blocked
1855                }
1856                Err(error) => return Err(error.into()),
1857                Ok(None) => Removal::Blocked,
1858                Ok(Some(slot)) => match slot.read()? {
1859                    None => Removal::Missing,
1860                    Some(bytes) => {
1861                        // A repair may have restored a missing blob since the filter
1862                        // ran, so do not reuse its cached tree verdicts.
1863                        if !action_result_bytes_are_dangling(
1864                            &cas,
1865                            &bytes,
1866                            &virtually_removed,
1867                            walk_trees,
1868                            &mut HashMap::new(),
1869                        )? {
1870                            continue;
1871                        }
1872                        match slot.remove() {
1873                            Ok(true) => Removal::Removed,
1874                            Ok(false) => Removal::Missing,
1875                            // Open files may prevent deletion on Windows, just
1876                            // as they do in the other eviction paths.
1877                            Err(error) if held_by_another_process(&error) => {
1878                                log::debug!("could not evict {}: {error}", entry.path.display());
1879                                Removal::Blocked
1880                            }
1881                            Err(error) => {
1882                                return Err(error).wrap_err_with(|| {
1883                                    format!("failed to evict {}", entry.path.display())
1884                                });
1885                            }
1886                        }
1887                    }
1888                },
1889            }
1890        };
1891        match removal {
1892            Removal::Removed => {
1893                live_bytes = live_bytes.saturating_sub(entry.size);
1894                outcome.removed_action_results += 1;
1895                outcome.removed_bytes += entry.size;
1896            }
1897            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1898            Removal::Blocked => {}
1899        }
1900    }
1901
1902    let sessions = prune_sessions(store, dry_run)?;
1903    outcome.removed_session_streams += sessions.removed_streams;
1904    outcome.removed_bytes += sessions.removed_bytes;
1905    prune_import_staging(store, dry_run);
1906
1907    outcome.remaining_bytes = live_bytes;
1908    Ok(outcome)
1909}
1910
1911/// Drop cached compilations for Rust compiler versions that have gone unused.
1912///
1913/// A new compiler never reuses what an older one cached, so after an upgrade
1914/// the old version's results only occupy disk. Each action result is
1915/// attributed to the compiler version recorded in its action descriptor. A
1916/// version counts as in use while any of its results was used within
1917/// `max_age`; the rest are removed together with the objects only they
1918/// reach. The most recently used version is always kept, so a store that has
1919/// sat idle for longer than `max_age` is not emptied wholesale.
1920///
1921/// Results that cannot be attributed (other languages' actions, unreadable
1922/// descriptors) are never touched. Use is read from the result file's access
1923/// time where the filesystem records it, else its modification time.
1924pub fn gc_stale_compilers(store: &Path, max_age: Duration, dry_run: bool) -> Result<GcOutcome> {
1925    stale_compilers_pass(store, max_age, dry_run).map(|(outcome, _)| outcome)
1926}
1927
1928/// Describe what `gc_stale_compilers` followed by `gc_dry_run` would remove,
1929/// counting nothing twice and measuring the budget after the stale pass.
1930pub fn gc_dry_run_after_stale(
1931    store: &Path,
1932    max_bytes: u64,
1933    max_age: Duration,
1934) -> Result<GcOutcome> {
1935    let (mut stale, planned) = stale_compilers_pass(store, max_age, true)?;
1936    let budget = gc_with_mode(store, max_bytes, true, planned)?;
1937    stale.removed_objects += budget.removed_objects;
1938    stale.removed_action_results += budget.removed_action_results;
1939    stale.removed_checkout_records += budget.removed_checkout_records;
1940    stale.removed_session_streams += budget.removed_session_streams;
1941    stale.removed_bytes += budget.removed_bytes;
1942    stale.remaining_bytes = budget.remaining_bytes;
1943    Ok(stale)
1944}
1945
1946/// The paths a pass removed, or in a dry run would remove.
1947fn stale_compilers_pass(
1948    store: &Path,
1949    max_age: Duration,
1950    dry_run: bool,
1951) -> Result<(GcOutcome, HashSet<PathBuf>)> {
1952    struct Attributed {
1953        entry: Entry,
1954        action: CacheDigest,
1955    }
1956
1957    let cas = LocalCas::new(store);
1958    let mut by_version: BTreeMap<String, Vec<Attributed>> = BTreeMap::new();
1959    let mut unattributed = Vec::new();
1960    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1961        let result = read_result_untouched(&entry.path)
1962            .ok()
1963            .and_then(|bytes| serde_json::from_slice::<RemoteActionResult>(&bytes).ok());
1964        let Some(result) = result else {
1965            continue;
1966        };
1967        match rustc_version_of(&cas, &result.action) {
1968            Some(version) => by_version.entry(version).or_default().push(Attributed {
1969                entry,
1970                action: result.action,
1971            }),
1972            None => unattributed.push(result.action),
1973        }
1974    }
1975
1976    let last_used = |results: &[Attributed]| results.iter().map(|r| r.entry.used).max();
1977    let newest = by_version
1978        .iter()
1979        .max_by_key(|(_, results)| last_used(results))
1980        .map(|(version, _)| version.clone());
1981    let cutoff = SystemTime::now()
1982        .checked_sub(max_age)
1983        .unwrap_or(SystemTime::UNIX_EPOCH);
1984    let mut outcome = GcOutcome::default();
1985    let mut stale = Vec::new();
1986    let mut kept_actions = unattributed;
1987    for (version, results) in by_version {
1988        let idle = last_used(&results).is_some_and(|used| used < cutoff);
1989        if idle && Some(&version) != newest.as_ref() {
1990            stale.extend(results);
1991        } else {
1992            kept_actions.extend(results.into_iter().map(|r| r.action));
1993        }
1994    }
1995    // A grouped CI export deliberately retains its actions until the post-job
1996    // step runs, whatever compiler made them. Those are never stale.
1997    let pending = grouped_receipt_actions(store)?;
1998    let (pinned, stale): (Vec<_>, Vec<_>) =
1999        stale.into_iter().partition(|r| pending.contains(&r.action));
2000    kept_actions.extend(pinned.into_iter().map(|r| r.action));
2001    let mut planned = HashSet::new();
2002    if stale.is_empty() {
2003        return Ok((outcome, planned));
2004    }
2005
2006    let doomed = rooted_action_objects(store, stale.iter().map(|r| r.action.clone()));
2007    let kept = rooted_action_objects(store, kept_actions);
2008    for result in &stale {
2009        if dry_run || matches!(remove(&result.entry.path)?, Removal::Removed) {
2010            outcome.removed_action_results += 1;
2011            outcome.removed_bytes += result.entry.size;
2012            planned.insert(result.entry.path.clone());
2013        }
2014    }
2015    for path in doomed.difference(&kept) {
2016        let Ok(metadata) = std::fs::metadata(path) else {
2017            continue;
2018        };
2019        if dry_run || matches!(remove(path)?, Removal::Removed) {
2020            outcome.removed_objects += 1;
2021            outcome.removed_bytes += metadata.len();
2022            planned.insert(path.clone());
2023        }
2024    }
2025    Ok((outcome, planned))
2026}
2027
2028/// Read a cache file without advancing the access time sweeps treat as use.
2029///
2030/// Maintenance passes read every result. Where the filesystem records access
2031/// times, that would make each sweep look like a use and an idle compiler
2032/// version would never age out. On Linux the file is opened with `O_NOATIME`,
2033/// which leaves the time alone without touching it afterwards, so a build's
2034/// concurrent hit is never overwritten. Elsewhere, or when the kernel refuses
2035/// the flag for a file the caller does not own, the time seen before the read
2036/// is put back; that can lose a hit that lands inside the read.
2037fn read_result_untouched(path: &Path) -> std::io::Result<Vec<u8>> {
2038    #[cfg(target_os = "linux")]
2039    {
2040        use std::io::Read as _;
2041        use std::os::unix::fs::OpenOptionsExt as _;
2042        if let Ok(mut file) = std::fs::OpenOptions::new()
2043            .read(true)
2044            .custom_flags(libc::O_NOATIME)
2045            .open(path)
2046        {
2047            let mut bytes = Vec::new();
2048            file.read_to_end(&mut bytes)?;
2049            return Ok(bytes);
2050        }
2051    }
2052    let before = std::fs::metadata(path)?;
2053    let bytes = std::fs::read(path)?;
2054    let _ = filetime::set_file_atime(path, filetime::FileTime::from_last_access_time(&before));
2055    Ok(bytes)
2056}
2057
2058/// The compiler version a rustc action descriptor was keyed under.
2059fn rustc_version_of(cas: &LocalCas, action: &CacheDigest) -> Option<String> {
2060    let bytes = read_result_untouched(&cas.path_for(action).ok()?).ok()?;
2061    let descriptor: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2062    if descriptor.get("kind")?.as_str()? != "rustc" {
2063        return None;
2064    }
2065    Some(
2066        descriptor
2067            .pointer("/compiler/rustc_version")?
2068            .as_str()?
2069            .to_owned(),
2070    )
2071}
2072
2073/// Discard import staging trees that a killed process left behind.
2074///
2075/// A finished import removes its own tree, and a failed one unwinds through
2076/// `TempDir`, so only a kill leaks. What leaks is the whole expanded export,
2077/// and it sits outside `cas/v1` and `action-results/v1`, so the sweep's own
2078/// accounting cannot see it and nothing else would ever reclaim it.
2079///
2080/// Age is what makes this safe to do without a lock: a concurrent import
2081/// holds no claim this could consult, and one that has been extracting for a
2082/// day has worse problems than a stale directory. Failures are logged rather
2083/// than returned, because losing a staging tree must not fail a sweep.
2084fn prune_import_staging(store: &Path, dry_run: bool) {
2085    if dry_run {
2086        return;
2087    }
2088    let root = store.join(IMPORT_STAGING_DIR);
2089    let Ok(entries) = read_dir_or_empty(&root) else {
2090        return;
2091    };
2092    for entry in entries {
2093        let Ok(metadata) = entry.metadata() else {
2094            continue;
2095        };
2096        let abandoned = metadata
2097            .modified()
2098            .ok()
2099            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
2100            .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
2101        if !abandoned {
2102            continue;
2103        }
2104        let path = entry.path();
2105        if !metadata.is_dir() {
2106            // A claim whose tree is already gone: reclaim it so claims cannot
2107            // pile up one per import that was killed after its tree went.
2108            if path
2109                .extension()
2110                .is_some_and(|extension| extension == "lock")
2111                && !claimed_staging_path(&path).exists()
2112            {
2113                let _ = std::fs::remove_file(&path);
2114            }
2115            continue;
2116        }
2117        // Age alone would be a guess. An import that outlives the retention
2118        // window -- a very large bundle on slow storage -- still holds its
2119        // lock, and taking its tree out from under it would fail a running
2120        // restore. A lock nobody holds is one the kernel released for a
2121        // process that is gone.
2122        let lock_path = import_staging_lock_path(&path);
2123        let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
2124            continue;
2125        };
2126        if !matches!(lock.try_lock(), Ok(true)) {
2127            continue;
2128        }
2129        if let Err(error) = std::fs::remove_dir_all(&path) {
2130            log::debug!(
2131                "could not remove abandoned import staging {}: {error}",
2132                path.display()
2133            );
2134            continue;
2135        }
2136        drop(lock);
2137        let _ = std::fs::remove_file(&lock_path);
2138    }
2139}
2140
2141/// The staging tree a claim describes.
2142fn claimed_staging_path(lock: &Path) -> PathBuf {
2143    lock.with_extension("")
2144}
2145
2146/// Where the liveness lock for one staging tree lives.
2147///
2148/// A sibling of the tree rather than a file inside it, so that removing the
2149/// tree does not remove the lock the remover is holding.
2150fn import_staging_lock_path(staging: &Path) -> PathBuf {
2151    let mut name = staging.as_os_str().to_os_string();
2152    name.push(".lock");
2153    PathBuf::from(name)
2154}
2155
2156/// One import's claim on its staging tree, released when the import ends.
2157///
2158/// The lock file is removed with the claim. `TempDir` takes the tree itself,
2159/// and a claim left behind would otherwise outlive every tree it described:
2160/// the sweep walks directories, so nothing would ever collect it.
2161struct StagingClaim {
2162    lock: Option<fslock::LockFile>,
2163    path: PathBuf,
2164}
2165
2166impl Drop for StagingClaim {
2167    fn drop(&mut self) {
2168        // Release before unlinking, so a sweep that already opened this file
2169        // sees an unlocked claim rather than waiting on one that is going away.
2170        drop(self.lock.take());
2171        let _ = std::fs::remove_file(&self.path);
2172    }
2173}
2174
2175/// Claim a staging tree for the lifetime of one import.
2176fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
2177    let path = import_staging_lock_path(staging);
2178    let mut lock = fslock::LockFile::open(&path)
2179        .wrap_err_with(|| format!("failed to open {}", path.display()))?;
2180    lock.lock()?;
2181    Ok(StagingClaim {
2182        lock: Some(lock),
2183        path,
2184    })
2185}
2186
2187#[derive(Debug, Default, PartialEq, Eq)]
2188struct SessionPrune {
2189    removed_streams: u64,
2190    removed_bytes: u64,
2191}
2192
2193fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
2194    let mut prune = SessionPrune::default();
2195    let ids = events::session_ids(store);
2196    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
2197    for (index, id) in ids.iter().enumerate() {
2198        let paths = events::session_paths(store, id);
2199        let metadata = match std::fs::metadata(&paths.events) {
2200            Ok(metadata) => metadata,
2201            Err(_) => continue,
2202        };
2203        let stale = metadata
2204            .modified()
2205            .ok()
2206            .and_then(|at| SystemTime::now().duration_since(at).ok())
2207            .is_some_and(|age| age > SESSION_RETENTION);
2208        if index >= surplus && !stale {
2209            continue;
2210        }
2211        if events::session_is_live(store, id) {
2212            continue;
2213        }
2214        if dry_run {
2215            prune.removed_streams += 1;
2216            prune.removed_bytes += metadata.len();
2217            continue;
2218        }
2219        if matches!(remove(&paths.events)?, Removal::Removed) {
2220            prune.removed_streams += 1;
2221            prune.removed_bytes += metadata.len();
2222            let _ = std::fs::remove_file(&paths.lock);
2223        }
2224    }
2225    if !dry_run {
2226        for lock in events::orphaned_locks(store) {
2227            let _ = std::fs::remove_file(lock);
2228        }
2229    }
2230    Ok(prune)
2231}
2232
2233/// Sweep the store if `interval` has passed since the last attempt.
2234///
2235/// This convenience API claims the interval before running GC, so an
2236/// interrupted attempt waits a full interval. The CLI uses its collector lock
2237/// and [`record_sweep`] after completion instead: collectors killed when a
2238/// sandboxed build exits must leave the next build free to retry.
2239pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
2240    if !claim_sweep(store, interval)? {
2241        return Ok(None);
2242    }
2243    gc(store, max_bytes).map(Some)
2244}
2245
2246/// Claim a due sweep before doing work, including interrupted attempts.
2247///
2248/// Callers that hold a collector lock and need to retry killed collectors can
2249/// instead check [`sweep_is_due`] under that lock and [`record_sweep`] afterwards.
2250///
2251/// The sweep lock serializes claims, so updating the existing marker in place
2252/// is enough. In particular, it does not need a temporary file and rename:
2253/// claiming a sweep must still work when the disk has no room for one.
2254pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
2255    let lock_path = store.join(SWEEP_LOCK);
2256    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
2257    let mut lock = fslock::LockFile::open(&lock_path)?;
2258    lock.lock()?;
2259
2260    let stamp = store.join(SWEEP_STAMP);
2261    if sweep_stamp_is_fresh(&stamp, interval) {
2262        return Ok(false);
2263    }
2264    record_sweep(store)?;
2265    Ok(true)
2266}
2267
2268/// Record a finished sweep at the current time.
2269///
2270/// The caller must serialize collectors and re-check [`sweep_is_due`] under
2271/// its lock. Update the marker in place, without truncation or a temporary
2272/// file, so an existing marker can still be refreshed on a full disk.
2273pub fn record_sweep(store: &Path) -> Result<()> {
2274    let path = store.join(SWEEP_STAMP);
2275    std::fs::create_dir_all(path.parent().expect("the sweep stamp has a parent"))?;
2276    let stamp = std::fs::OpenOptions::new()
2277        .create(true)
2278        .write(true)
2279        .truncate(false)
2280        .open(path)?;
2281    stamp.set_modified(SystemTime::now())?;
2282    Ok(())
2283}
2284
2285/// Whether the interval since the last recorded sweep has passed.
2286///
2287/// A read of the stamp, no lock and no write: this answers a build that only
2288/// wants to know whether to start a collector, and may be answered yes to two
2289/// builds at once. A collector must re-check this under its collector lock
2290/// and record completion with [`record_sweep`] before releasing the lock.
2291pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2292    !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2293}
2294
2295fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2296    std::fs::metadata(stamp)
2297        .and_then(|metadata| metadata.modified())
2298        .ok()
2299        .and_then(|modified| modified.elapsed().ok())
2300        .is_some_and(|since| since < interval)
2301}
2302
2303/// What the checkout registry says about the identities in this store.
2304struct CheckoutScan {
2305    live_identities: BTreeSet<String>,
2306    live_records: u64,
2307    stale_records: Vec<PathBuf>,
2308}
2309
2310fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2311    let root = store.join(CHECKOUTS_DIR);
2312    let mut scan = CheckoutScan {
2313        live_identities: BTreeSet::new(),
2314        live_records: 0,
2315        stale_records: Vec::new(),
2316    };
2317    let listing = match std::fs::read_dir(&root) {
2318        Ok(listing) => listing,
2319        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2320        Err(error) => {
2321            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2322        }
2323    };
2324    for entry in listing {
2325        let entry = entry?;
2326        let name = entry.file_name().to_string_lossy().into_owned();
2327        // Anything that is not a directory named for an identity was not written
2328        // by mbx. Both halves matter: `walk_files` tolerates only a missing
2329        // directory, so a plain file whose name happens to look like an identity
2330        // would fail the whole scan -- and that scan runs inside every sweep,
2331        // including the automatic one after a build.
2332        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2333            continue;
2334        }
2335        for record in walk_files(&entry.path())? {
2336            match read_checkout_record(&record.path) {
2337                Some(checkout) if claim_is_live(store, &checkout) => {
2338                    scan.live_identities.insert(name.clone());
2339                    scan.live_records += 1;
2340                }
2341                Some(_) => scan.stale_records.push(record.path),
2342                // A record this build cannot read claims nothing, but it is not
2343                // evidence the checkout is gone either, so leave it alone.
2344                None => {}
2345            }
2346        }
2347    }
2348    Ok(scan)
2349}
2350
2351fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2352    let bytes = std::fs::read(path).ok()?;
2353    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2354    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2355}
2356
2357/// Actions whose successful grouped export has not retired its receipts yet.
2358fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2359    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2360    Ok(walk_files(&root)?
2361        .into_iter()
2362        .filter_map(|entry| read_build_receipt(&entry.path))
2363        .flat_map(|receipt| receipt.predictions.into_iter())
2364        .map(|prediction| prediction.action)
2365        .collect())
2366}
2367
2368/// Whether a recorded claim still speaks for a checkout that is using this
2369/// store.
2370fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2371    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2372}
2373
2374fn claim_has_expired(record: &CheckoutRecord) -> bool {
2375    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2376        return false;
2377    };
2378    now.as_secs()
2379        .saturating_sub(record.updated_secs)
2380        .gt(&CHECKOUT_RETENTION.as_secs())
2381}
2382
2383/// Whether the checkout a record names is still on disk.
2384///
2385/// Absence is believed only when the checkout is definitely absent and its
2386/// nearest existing ancestor is on the same filesystem as the store. Walking
2387/// all the way to that ancestor matters for worktree managers and temporary
2388/// directories, which commonly remove a checkout together with one or more of
2389/// its otherwise-empty parents.
2390///
2391/// A different filesystem can be a mount whose contents are temporarily
2392/// unavailable, so uncertainty there remains live. Errors are treated the same
2393/// way: being wrong in that direction only delays collection; the other way
2394/// round throws away a warm cache someone is still using.
2395pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2396    if !matches!(workspace_root.try_exists(), Ok(false)) {
2397        return true;
2398    }
2399
2400    let Ok(store_metadata) = std::fs::metadata(store) else {
2401        return true;
2402    };
2403    for ancestor in workspace_root.ancestors().skip(1) {
2404        match std::fs::metadata(ancestor) {
2405            Ok(ancestor_metadata) => {
2406                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2407            }
2408            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2409            Err(_) => return true,
2410        }
2411    }
2412    true
2413}
2414
2415/// Whether a checkout is still on disk, corroborating absence through its
2416/// immediate parent.
2417///
2418/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
2419/// root is available to distinguish deletion from an unavailable filesystem.
2420pub fn checkout_is_live(workspace_root: &Path) -> bool {
2421    let Some(parent) = workspace_root.parent() else {
2422        return true;
2423    };
2424    checkout_is_live_on(parent, workspace_root)
2425}
2426
2427/// Whether two existing paths sit on the same filesystem, so a rename can
2428/// carry a directory from one to the other.
2429///
2430/// A path that cannot be inspected answers `false`: a move that cannot be
2431/// checked is not one to attempt.
2432pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2433    let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2434        return false;
2435    };
2436    same_filesystem(a, &a_metadata, b, &b_metadata)
2437}
2438
2439#[cfg(unix)]
2440fn same_filesystem(
2441    _a_path: &Path,
2442    a: &std::fs::Metadata,
2443    _b_path: &Path,
2444    b: &std::fs::Metadata,
2445) -> bool {
2446    use std::os::unix::fs::MetadataExt as _;
2447    a.dev() == b.dev()
2448}
2449
2450#[cfg(windows)]
2451fn same_filesystem(
2452    a_path: &Path,
2453    _a: &std::fs::Metadata,
2454    b_path: &Path,
2455    _b: &std::fs::Metadata,
2456) -> bool {
2457    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2458}
2459
2460#[cfg(windows)]
2461fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2462    use std::os::windows::ffi::OsStrExt as _;
2463    use windows_sys::Win32::Storage::FileSystem::{
2464        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2465    };
2466
2467    let path = path
2468        .as_os_str()
2469        .encode_wide()
2470        .chain(Some(0))
2471        .collect::<Vec<_>>();
2472    // Both APIs document MAX_PATH-sized output buffers for volume paths and
2473    // names. Failure is uncertainty, which deliberately keeps the checkout
2474    // live.
2475    let mut mount = vec![0_u16; 261];
2476    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
2477    // number of UTF-16 code units.
2478    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2479        return None;
2480    }
2481    let mut volume = vec![0_u16; 261];
2482    // SAFETY: the successful call above left `mount` nul-terminated, and
2483    // `volume` is writable for the stated number of UTF-16 code units.
2484    if unsafe {
2485        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2486    } == 0
2487    {
2488        return None;
2489    }
2490    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2491    Some(volume)
2492}
2493
2494#[cfg(not(any(unix, windows)))]
2495fn same_filesystem(
2496    _a_path: &Path,
2497    _a: &std::fs::Metadata,
2498    _b_path: &Path,
2499    _b: &std::fs::Metadata,
2500) -> bool {
2501    false
2502}
2503
2504/// CAS paths reachable from the builds that live checkouts still depend on.
2505///
2506/// Recursing into output trees is the point: the descriptor blobs are tiny and
2507/// the leaf artifacts are where the bytes are, so rooting that stopped at the
2508/// top level would protect nothing worth protecting. Every read here is
2509/// tolerant -- a blob that has already been evicted, or that no longer parses,
2510/// simply roots less, and rooting less can only mean collecting sooner.
2511fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2512    let mut actions = BTreeSet::new();
2513    for identity in identities {
2514        // One manifest this build cannot read is not worth abandoning a sweep
2515        // over. It roots nothing, which can only mean collecting sooner.
2516        let manifest_actions = match task_manifest_actions(store, identity) {
2517            Ok(actions) => actions,
2518            Err(error) => {
2519                log::debug!("could not read the manifest for {identity}: {error}");
2520                continue;
2521            }
2522        };
2523        actions.extend(manifest_actions);
2524    }
2525    Ok(rooted_action_objects(store, actions))
2526}
2527
2528/// Return every local CAS path needed to restore the supplied actions.
2529fn rooted_action_objects(
2530    store: &Path,
2531    actions: impl IntoIterator<Item = CacheDigest>,
2532) -> HashSet<PathBuf> {
2533    let cas = LocalCas::new(store);
2534    let mut rooted = HashSet::new();
2535    let mut visited = BTreeSet::new();
2536    for action in actions {
2537        let Some(result) = read_action_result(store, &action) else {
2538            continue;
2539        };
2540        root_digest(&cas, &mut rooted, &result.action);
2541        if let Some(metadata) = &result.metadata {
2542            root_digest(&cas, &mut rooted, metadata);
2543            // The metadata blob is a descriptor too: it names the captured
2544            // stdout and stderr, and a restore reads both. Stopping at the
2545            // descriptor would leave the diagnostics of every action
2546            // unrooted -- including the one empty blob that every silent
2547            // compilation shares, which is enough on its own to turn a
2548            // rooted hit back into a miss.
2549            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2550                root_digest(&cas, &mut rooted, &rustc.stdout);
2551                root_digest(&cas, &mut rooted, &rustc.stderr);
2552            }
2553        }
2554        if let Some(output_root) = &result.output_root {
2555            root_digest(&cas, &mut rooted, output_root);
2556            root_tree(&cas, output_root, &mut rooted, &mut visited);
2557        }
2558    }
2559    rooted
2560}
2561
2562fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2563    let path = mbx_cache_core::LocalActionCache::new(store)
2564        .path_for(action)
2565        .ok()?;
2566    let bytes = read_result_untouched(&path).ok()?;
2567    serde_json::from_slice(&bytes).ok()
2568}
2569
2570fn root_tree(
2571    cas: &LocalCas,
2572    output_root: &CacheDigest,
2573    rooted: &mut HashSet<PathBuf>,
2574    visited: &mut BTreeSet<CacheDigest>,
2575) {
2576    let mut pending = vec![output_root.clone()];
2577    while let Some(digest) = pending.pop() {
2578        if !visited.insert(digest.clone()) {
2579            continue;
2580        }
2581        let Some(directory) = read_directory(cas, &digest) else {
2582            continue;
2583        };
2584        for file in &directory.files {
2585            root_digest(cas, rooted, &file.digest);
2586        }
2587        for child in &directory.directories {
2588            root_digest(cas, rooted, &child.digest);
2589            pending.push(child.digest.clone());
2590        }
2591    }
2592}
2593
2594fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2595    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2596    serde_json::from_slice(&bytes).ok()
2597}
2598
2599fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2600    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2601    serde_json::from_slice(&bytes).ok()
2602}
2603
2604fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2605    if let Ok(path) = cas.path_for(digest) {
2606        rooted.insert(path);
2607    }
2608}
2609
2610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2611enum Removal {
2612    Removed,
2613    Missing,
2614    Blocked,
2615}
2616
2617fn remove(path: &Path) -> Result<Removal> {
2618    match std::fs::remove_file(path) {
2619        Ok(()) => Ok(Removal::Removed),
2620        // A concurrent build may have evicted or replaced it already.
2621        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2622        // Windows refuses to unlink a file another process holds open, which is
2623        // what a concurrent build reading this blob looks like. Skipping it
2624        // leaves the store over budget until the next sweep; failing would
2625        // abandon the sweep and leave it over budget for longer.
2626        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2627            log::debug!("could not evict {}: {error}", path.display());
2628            Ok(Removal::Blocked)
2629        }
2630        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2631    }
2632}
2633
2634#[derive(Debug, Default, PartialEq, Eq)]
2635struct ObjectEvictions {
2636    removed_objects: u64,
2637    removed_bytes: u64,
2638    remaining_bytes: u64,
2639}
2640
2641/// Evict sorted objects without crossing the checkout-protection boundary
2642/// when an unrooted object cannot be removed.
2643fn evict_objects(
2644    objects: &[Entry],
2645    rooted: &HashSet<PathBuf>,
2646    mut live_bytes: u64,
2647    max_bytes: u64,
2648    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2649) -> Result<ObjectEvictions> {
2650    let mut outcome = ObjectEvictions::default();
2651    let mut blocked_unrooted = false;
2652    for entry in objects {
2653        if live_bytes <= max_bytes {
2654            break;
2655        }
2656        let is_rooted = rooted.contains(&entry.path);
2657        if is_rooted && blocked_unrooted {
2658            // A locked unrooted blob is temporarily part of the irreducible
2659            // store. Deleting a live checkout's artifacts in its place would
2660            // invert the protection ordering this collector promises.
2661            break;
2662        }
2663        match remove_file(&entry.path)? {
2664            Removal::Removed => {
2665                live_bytes = live_bytes.saturating_sub(entry.size);
2666                outcome.removed_objects += 1;
2667                outcome.removed_bytes += entry.size;
2668            }
2669            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2670            Removal::Blocked if !is_rooted => blocked_unrooted = true,
2671            Removal::Blocked => {}
2672        }
2673    }
2674    outcome.remaining_bytes = live_bytes;
2675    Ok(outcome)
2676}
2677
2678/// Whether an action result references an object the store no longer has.
2679///
2680/// The digests checked here are exactly the ones `LocalActionCache::store`
2681/// requires. If the sweep checked fewer, eviction could leave an entry that
2682/// cannot be republished -- `store` would reject the identical result for a
2683/// missing blob while the index still claimed to hold it.
2684///
2685/// Presence is all that is checked, not content. Verifying would re-hash a
2686/// large fraction of the store on every sweep, which a chore could afford and
2687/// an automatic sweep cannot; it would also refresh the access time of every
2688/// descriptor blob while leaving the artifacts alone, biasing the very
2689/// ordering this module depends on. A blob that is present but corrupt now
2690/// keeps its result and turns into a miss on restore, and both CAS write paths
2691/// republish over a corrupt blob rather than trusting it.
2692///
2693/// With `walk_tree`, every directory and file the output tree lists is checked
2694/// too. Eviction removes individual objects, so it can take a file out of a tree
2695/// whose own object survives; the result would then stay listed and fail its
2696/// next restore with a missing blob until a rebuild replaced it. Without it
2697/// only the top-level objects are checked, which keeps a sweep that evicted
2698/// nothing to a directory walk and one read per result.
2699fn action_result_is_dangling(
2700    cas: &LocalCas,
2701    path: &Path,
2702    virtually_removed: &HashSet<PathBuf>,
2703    walk_tree: bool,
2704    trees: &mut HashMap<CacheDigest, bool>,
2705) -> Result<bool> {
2706    let bytes = match read_result_untouched(path) {
2707        Ok(bytes) => bytes,
2708        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2709        Err(error) => {
2710            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2711        }
2712    };
2713    action_result_bytes_are_dangling(cas, &bytes, virtually_removed, walk_tree, trees)
2714}
2715
2716/// Whether an I/O error means another process holds the file.
2717///
2718/// Windows reports a read of a range another process has locked as
2719/// ERROR_LOCK_VIOLATION (33), and an open the other handle's sharing mode
2720/// refuses as ERROR_SHARING_VIOLATION (32); neither maps to a portable error
2721/// kind. Elsewhere this is a permission error.
2722fn held_by_another_process(error: &std::io::Error) -> bool {
2723    error.kind() == std::io::ErrorKind::PermissionDenied
2724        || (cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)))
2725}
2726
2727fn action_result_bytes_are_dangling(
2728    cas: &LocalCas,
2729    bytes: &[u8],
2730    virtually_removed: &HashSet<PathBuf>,
2731    walk_tree: bool,
2732    trees: &mut HashMap<CacheDigest, bool>,
2733) -> Result<bool> {
2734    // An unparseable result is already useless; the cache rejects it on read.
2735    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(bytes) else {
2736        return Ok(false);
2737    };
2738    let missing = |digest: &CacheDigest| match cas.path_for(digest) {
2739        // A path that cannot be inspected is not known to be gone.
2740        Ok(path) => virtually_removed.contains(&path) || matches!(path.try_exists(), Ok(false)),
2741        // A digest the CAS cannot even address is not one it can hold.
2742        Err(_) => true,
2743    };
2744    for digest in [
2745        Some(&result.action),
2746        result.metadata.as_ref(),
2747        result.output_root.as_ref(),
2748    ]
2749    .into_iter()
2750    .flatten()
2751    {
2752        if missing(digest) {
2753            return Ok(true);
2754        }
2755    }
2756    if walk_tree && let Some(output_root) = &result.output_root {
2757        return Ok(!tree_is_complete(cas, output_root, &missing, trees));
2758    }
2759    Ok(false)
2760}
2761
2762/// Whether every file and directory under `digest` is present, remembering the
2763/// verdict in `trees` so a subtree shared by many results is read once per sweep.
2764fn tree_is_complete(
2765    cas: &LocalCas,
2766    digest: &CacheDigest,
2767    missing: &impl Fn(&CacheDigest) -> bool,
2768    trees: &mut HashMap<CacheDigest, bool>,
2769) -> bool {
2770    if let Some(&complete) = trees.get(digest) {
2771        return complete;
2772    }
2773    // Content addressing rules out cycles; this only keeps a malformed tree
2774    // from recursing forever.
2775    trees.insert(digest.clone(), true);
2776    // Present but unreadable is left to the restore, as for any blob.
2777    let complete = read_directory(cas, digest).is_none_or(|directory| {
2778        directory.files.iter().all(|file| !missing(&file.digest))
2779            && directory.directories.iter().all(|child| {
2780                !missing(&child.digest) && tree_is_complete(cas, &child.digest, missing, trees)
2781            })
2782    });
2783    trees.insert(digest.clone(), complete);
2784    complete
2785}
2786
2787struct Entry {
2788    path: PathBuf,
2789    size: u64,
2790    used: SystemTime,
2791}
2792
2793fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2794    let mut entries = Vec::new();
2795    let mut pending = vec![root.to_path_buf()];
2796    while let Some(directory) = pending.pop() {
2797        let listing = match std::fs::read_dir(&directory) {
2798            Ok(listing) => listing,
2799            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2800            Err(error) => {
2801                return Err(error)
2802                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
2803            }
2804        };
2805        for entry in listing {
2806            let entry = entry?;
2807            let metadata = match entry.metadata() {
2808                Ok(metadata) => metadata,
2809                // A concurrent build may be publishing into the store.
2810                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2811                Err(error) => return Err(error.into()),
2812            };
2813            if metadata.is_dir() {
2814                pending.push(entry.path());
2815            } else if metadata.is_file() {
2816                let used = metadata
2817                    .accessed()
2818                    .or_else(|_| metadata.modified())
2819                    .unwrap_or(SystemTime::UNIX_EPOCH);
2820                entries.push(Entry {
2821                    path: entry.path(),
2822                    size: metadata.len(),
2823                    used,
2824                });
2825            }
2826        }
2827    }
2828    Ok(entries)
2829}
2830
2831fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2832    match std::fs::read_dir(root) {
2833        Ok(entries) => entries
2834            .collect::<std::io::Result<Vec<_>>>()
2835            .map_err(Into::into),
2836        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2837        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2838    }
2839}
2840
2841fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2842    let name = path.file_name()?.to_str()?;
2843    let name = if action_result {
2844        name.strip_suffix(".json")?
2845    } else {
2846        name
2847    };
2848    let (hash, size) = name.rsplit_once('-')?;
2849    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2850    let digest = CacheDigest {
2851        algorithm,
2852        hash: hash.to_string(),
2853        size: size.parse().ok()?,
2854    };
2855    digest.validate().ok()?;
2856    Some(digest)
2857}
2858
2859fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2860    let digest = digest_from_path(path, action_result)?;
2861    let canonical = if action_result {
2862        mbx_cache_core::LocalActionCache::new(store)
2863            .path_for(&digest)
2864            .ok()?
2865    } else {
2866        LocalCas::new(store).path_for(&digest).ok()?
2867    };
2868    (canonical == path).then_some(digest)
2869}
2870
2871fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2872    *cache
2873        .entry(root.to_path_buf())
2874        .or_insert_with(|| tree_bytes(root))
2875}
2876
2877fn tree_bytes(root: &Path) -> u64 {
2878    let Ok(root_metadata) = std::fs::metadata(root) else {
2879        return 0;
2880    };
2881    if root_metadata.is_file() {
2882        return root_metadata.len();
2883    }
2884    let Ok(entries) = std::fs::read_dir(root) else {
2885        return 0;
2886    };
2887    let mut bytes = 0_u64;
2888    let mut pending = entries
2889        .filter_map(std::result::Result::ok)
2890        .map(|entry| entry.path())
2891        .collect::<Vec<_>>();
2892    while let Some(path) = pending.pop() {
2893        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2894            continue;
2895        };
2896        if metadata.is_file() {
2897            bytes = bytes.saturating_add(metadata.len());
2898        } else if metadata.is_dir()
2899            && let Ok(entries) = std::fs::read_dir(path)
2900        {
2901            pending.extend(
2902                entries
2903                    .filter_map(std::result::Result::ok)
2904                    .map(|entry| entry.path()),
2905            );
2906        }
2907    }
2908    bytes
2909}
2910
2911#[cfg(test)]
2912#[path = "store_tests.rs"]
2913mod tests;
2914
2915/// [`write_atomic`] without the sync: for records every build rewrites and
2916/// that a torn or missing file only makes one build older.
2917fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2918    let parent = path
2919        .parent()
2920        .filter(|parent| !parent.as_os_str().is_empty())
2921        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2922    std::fs::create_dir_all(parent)
2923        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2924    let mut temporary = tempfile::Builder::new()
2925        .prefix(".mbx-")
2926        .tempfile_in(parent)?;
2927    use std::io::Write as _;
2928    temporary.write_all(contents)?;
2929    temporary
2930        .persist(path)
2931        .map_err(|error| error.error)
2932        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2933    Ok(())
2934}
2935
2936fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2937    let parent = path
2938        .parent()
2939        .filter(|parent| !parent.as_os_str().is_empty())
2940        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2941    std::fs::create_dir_all(parent)
2942        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2943    let mut temporary = tempfile::Builder::new()
2944        .prefix(".mbx-")
2945        .tempfile_in(parent)?;
2946    use std::io::Write as _;
2947    temporary.write_all(contents)?;
2948    temporary.as_file_mut().sync_all()?;
2949    temporary
2950        .persist(path)
2951        .map_err(|error| error.error)
2952        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2953    Ok(())
2954}