Skip to main content

mbx_cache_store/
lib.rs

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