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, merge_task_action_predictions, 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(|left, right| {
403        left.completed_nanos
404            .cmp(&right.completed_nanos)
405            .then_with(|| {
406                // Concurrent commands may complete on the same clock tick. Use
407                // their persisted predictions to break ties, not directory order.
408                fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
409                    (
410                        &prediction.invocation,
411                        &prediction.action,
412                        &prediction.adapter,
413                        &prediction.payload,
414                    )
415                }
416                left.predictions
417                    .iter()
418                    .map(key)
419                    .cmp(right.predictions.iter().map(key))
420            })
421    });
422    let mut actions = BTreeSet::new();
423    let mut tasks = BTreeMap::new();
424    for receipt in receipts {
425        actions.extend(
426            receipt
427                .predictions
428                .iter()
429                .map(|prediction| prediction.action.clone()),
430        );
431        // Keep predictions from every command in receipt order. Deduplication
432        // below makes the most recently completed command win an overlap.
433        tasks
434            .entry(receipt.identity)
435            .or_insert_with(Vec::new)
436            .extend(receipt.predictions);
437    }
438    let tasks = tasks
439        .into_iter()
440        .map(|(task, predictions)| {
441            let mut invocations = BTreeSet::new();
442            let mut predictions = predictions
443                .into_iter()
444                .rev()
445                .filter(|prediction| invocations.insert(prediction.invocation.clone()))
446                .collect::<Vec<_>>();
447            predictions.reverse();
448            TaskActionManifest {
449                version: 1,
450                task,
451                predictions,
452            }
453        })
454        .collect::<Vec<_>>();
455    if tasks.iter().any(|task| !task.validate()) {
456        eyre::bail!("combined export predictions exceed task manifest limits");
457    }
458    validate_export_additions(&additions)?;
459    let (mut objects, result_paths) = strict_closure(store, &actions)?;
460    let cas = LocalCas::new(store);
461    for digest in &additions.objects {
462        require_object(&cas, &mut objects, digest)?;
463    }
464    let parent = archive
465        .parent()
466        .filter(|parent| !parent.as_os_str().is_empty())
467        .unwrap_or_else(|| Path::new("."));
468    std::fs::create_dir_all(parent)?;
469    let temporary = tempfile::Builder::new()
470        .prefix(".mbx-export-")
471        .tempfile_in(parent)?;
472    let mut builder = tar::Builder::new(temporary.reopen()?);
473    append_bytes(
474        &mut builder,
475        Path::new(EXPORT_MANIFEST),
476        &serde_json::to_vec(&ExportManifest {
477            version: if additions.attachments.is_empty() && additions.objects.is_empty() {
478                LEGACY_EXPORT_VERSION
479            } else {
480                EXPORT_VERSION
481            },
482            tasks,
483            actions: actions.iter().cloned().collect(),
484            attachments: additions.attachments.clone(),
485            objects: additions.objects.iter().cloned().collect(),
486        })?,
487    )?;
488    for path in objects.iter().chain(result_paths.iter()) {
489        append_file(&mut builder, store, path)?;
490    }
491    builder.finish()?;
492    drop(builder);
493    temporary
494        .persist(archive)
495        .map_err(|error| error.error)
496        .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
497    let bytes = std::fs::metadata(archive)?.len();
498    Ok(TransferOutcome {
499        actions: actions.len() as u64,
500        objects: objects.len() as u64,
501        bytes,
502    })
503}
504
505/// Validate a cache export in isolation, then publish its objects and actions.
506pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
507    Ok(import_archive_with_attachments(store, archive)?.transfer)
508}
509
510/// Import a cache archive and return its named higher-level CAS roots.
511pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
512    let staging = tempfile::tempdir()?;
513    let file = std::fs::File::open(archive)
514        .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
515    let mut bundle = tar::Archive::new(file);
516    let mut seen = BTreeSet::new();
517    for entry in bundle.entries()? {
518        let mut entry = entry?;
519        let entry_type = entry.header().entry_type();
520        if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
521            eyre::bail!("cache export contains a non-file entry");
522        }
523        let path = entry.path()?.into_owned();
524        validate_archive_path(&path)?;
525        if !seen.insert(path.clone()) {
526            eyre::bail!("cache export contains duplicate entry {}", path.display());
527        }
528        let destination = staging.path().join(&path);
529        std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
530        // `unpack` understands GNU sparse maps. A plain stream copy expands
531        // holes into physical zeroes, which is both slower and much larger for
532        // Rust artifacts containing sparse sections.
533        entry.unpack(&destination)?;
534    }
535    let manifest: ExportManifest =
536        serde_json::from_slice(&std::fs::read(staging.path().join(EXPORT_MANIFEST))?)?;
537    let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
538    let task_identities = manifest
539        .tasks
540        .iter()
541        .map(|task| task.task.as_str())
542        .collect::<BTreeSet<_>>();
543    if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
544        || (manifest.version == LEGACY_EXPORT_VERSION
545            && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
546        || manifest.tasks.is_empty()
547        || actions.len() != manifest.actions.len()
548        || task_identities.len() != manifest.tasks.len()
549        || manifest.tasks.iter().any(|task| !task.validate())
550        || manifest
551            .actions
552            .iter()
553            .any(|action| action.algorithm != "blake3" || action.validate().is_err())
554        || manifest.tasks.iter().any(|task| {
555            task.predictions
556                .iter()
557                .any(|prediction| !actions.contains(&prediction.action))
558        })
559        || manifest
560            .attachments
561            .keys()
562            .any(|name| !valid_attachment_name(name))
563        || manifest.attachments.values().any(|digest| {
564            digest.algorithm != "blake3"
565                || digest.validate().is_err()
566                || !manifest.objects.contains(digest)
567        })
568        || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
569        || manifest
570            .objects
571            .iter()
572            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
573    {
574        eyre::bail!("unsupported or invalid cache export manifest");
575    }
576    let (mut objects, result_paths) = strict_closure(staging.path(), &actions)
577        .wrap_err("cache export is incomplete or corrupt")?;
578    let staged_cas = LocalCas::new(staging.path());
579    for digest in &manifest.objects {
580        require_object(&staged_cas, &mut objects, digest)
581            .wrap_err("cache export attachment is incomplete or corrupt")?;
582    }
583
584    let cas = LocalCas::new(store);
585    for path in &objects {
586        let relative = path.strip_prefix(staging.path())?;
587        let source = staging.path().join(relative);
588        let digest = addressed_digest(staging.path(), &source, false)
589            .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
590        // `strict_closure` verified every path in `objects` against its
591        // content-addressed name. Preserve that proof and move the owned
592        // staging file instead of hashing or copying the bytes again.
593        cas.adopt_verified_file(&digest, &source)?;
594    }
595    let action_cache = mbx_cache_core::LocalActionCache::new(store);
596    for path in &result_paths {
597        let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)?;
598        action_cache.store(&result)?;
599    }
600    for task in manifest.tasks {
601        merge_imported_manifest(store, task)?;
602    }
603    Ok(ImportOutcome {
604        transfer: TransferOutcome {
605            actions: actions.len() as u64,
606            objects: objects.len() as u64,
607            bytes: std::fs::metadata(archive)?.len(),
608        },
609        attachments: manifest.attachments,
610    })
611}
612
613fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
614    if additions
615        .attachments
616        .keys()
617        .any(|name| !valid_attachment_name(name))
618        || additions
619            .attachments
620            .values()
621            .any(|digest| !additions.objects.contains(digest))
622        || additions
623            .objects
624            .iter()
625            .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
626    {
627        eyre::bail!("invalid cache export attachment");
628    }
629    Ok(())
630}
631
632fn valid_attachment_name(name: &str) -> bool {
633    !name.is_empty()
634        && name.len() <= 128
635        && name
636            .bytes()
637            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
638}
639
640fn strict_closure(
641    store: &Path,
642    actions: &BTreeSet<CacheDigest>,
643) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
644    let cas = LocalCas::new(store);
645    let action_cache = mbx_cache_core::LocalActionCache::new(store);
646    let mut objects = BTreeSet::new();
647    let mut results = BTreeSet::new();
648    let mut directories = BTreeSet::new();
649    for action in actions {
650        let result = action_cache
651            .find(action)?
652            .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
653        results.insert(action_cache.path_for(action)?);
654        require_object(&cas, &mut objects, &result.action)?;
655        if let Some(metadata) = &result.metadata {
656            require_object(&cas, &mut objects, metadata)?;
657            let captured: CapturedOutput =
658                serde_json::from_slice(&std::fs::read(cas.path_for(metadata)?)?)
659                    .wrap_err("action metadata is invalid")?;
660            require_object(&cas, &mut objects, &captured.stdout)?;
661            require_object(&cas, &mut objects, &captured.stderr)?;
662        }
663        if let Some(root) = &result.output_root {
664            require_object(&cas, &mut objects, root)?;
665            let mut pending = vec![root.clone()];
666            while let Some(digest) = pending.pop() {
667                if !directories.insert(digest.clone()) {
668                    continue;
669                }
670                let directory: CacheDirectory =
671                    serde_json::from_slice(&std::fs::read(cas.path_for(&digest)?)?)
672                        .wrap_err("output directory is invalid")?;
673                for file in directory.files {
674                    require_object(&cas, &mut objects, &file.digest)?;
675                }
676                for child in directory.directories {
677                    require_object(&cas, &mut objects, &child.digest)?;
678                    pending.push(child.digest);
679                }
680            }
681        }
682    }
683    Ok((objects, results))
684}
685
686#[derive(Deserialize)]
687struct CapturedOutput {
688    stdout: CacheDigest,
689    stderr: CacheDigest,
690}
691
692fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
693    let identity = imported.task.clone();
694    let destination_path = task_manifest_path(destination, &identity);
695    let lock_path = task_manifest_lock_path(destination, &identity);
696    std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
697    let mut lock = fslock::LockFile::open(&lock_path)?;
698    lock.lock()?;
699    if let Ok(bytes) = std::fs::read(&destination_path)
700        && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
701        && existing.task == identity
702        && existing.validate()
703    {
704        let existing_predictions: BTreeMap<_, _> = existing
705            .predictions
706            .iter()
707            .cloned()
708            .map(|prediction| (prediction.invocation.clone(), prediction))
709            .collect();
710        // The bundle determines recency, but a checkout's current value wins
711        // when both manifests predict the same invocation.
712        let updates = imported
713            .predictions
714            .into_iter()
715            .map(|prediction| {
716                existing_predictions
717                    .get(&prediction.invocation)
718                    .cloned()
719                    .unwrap_or(prediction)
720            })
721            .collect();
722        imported.predictions =
723            merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
724    }
725    write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
726}
727
728fn require_object(
729    cas: &LocalCas,
730    objects: &mut BTreeSet<PathBuf>,
731    digest: &CacheDigest,
732) -> Result<()> {
733    let expected = cas.path_for(digest)?;
734    if objects.contains(&expected) {
735        return Ok(());
736    }
737    let path = cas
738        .find(digest)?
739        .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
740    objects.insert(path);
741    Ok(())
742}
743
744fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
745    store
746        .join("task-manifests/v1")
747        .join(format!("{identity}.json"))
748}
749
750fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
751    store
752        .join("task-manifests/v1/locks")
753        .join(format!("{identity}.lock"))
754}
755
756fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
757    let name = path.strip_prefix(root)?;
758    builder.append_path_with_name(path, name)?;
759    Ok(())
760}
761
762fn append_bytes(
763    builder: &mut tar::Builder<std::fs::File>,
764    name: &Path,
765    bytes: &[u8],
766) -> Result<()> {
767    let mut header = tar::Header::new_gnu();
768    header.set_size(bytes.len() as u64);
769    header.set_mode(0o644);
770    header.set_cksum();
771    builder.append_data(&mut header, name, bytes)?;
772    Ok(())
773}
774
775fn validate_archive_path(path: &Path) -> Result<()> {
776    if path.as_os_str().is_empty()
777        || path.is_absolute()
778        || path
779            .components()
780            .any(|component| !matches!(component, Component::Normal(_)))
781    {
782        eyre::bail!("cache export contains unsafe path {}", path.display());
783    }
784    let text = path.to_string_lossy();
785    if text != EXPORT_MANIFEST
786        && !text.starts_with("cas/v1/")
787        && !text.starts_with("action-results/v1/")
788    {
789        eyre::bail!("cache export contains unexpected path {}", path.display());
790    }
791    Ok(())
792}
793
794/// One checkout's claim on the actions a build identity recorded.
795///
796/// The target directory is not read back by anything yet; it is recorded
797/// because it is the other half of what the shim mapped, and a record that
798/// names only half of it would have to be rewritten to answer where the
799/// artifacts went.
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(deny_unknown_fields)]
802struct CheckoutRecord {
803    version: u8,
804    workspace_root: PathBuf,
805    target_dir: PathBuf,
806    updated_secs: u64,
807}
808
809/// Summarize what the store currently holds.
810pub fn stats(store: &Path) -> Result<StoreStats> {
811    let objects = walk_files(&store.join(CAS_DIR))?;
812    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
813    let checkouts = scan_checkouts(store)?;
814    Ok(StoreStats {
815        objects: objects.len() as u64,
816        object_bytes: objects.iter().map(|entry| entry.size).sum(),
817        action_results: results.len() as u64,
818        action_result_bytes: results.iter().map(|entry| entry.size).sum(),
819        live_checkouts: checkouts.live_records,
820        stale_checkouts: checkouts.stale_records.len() as u64,
821    })
822}
823
824/// Size the target tree a checkout record names, if it names one.
825///
826/// A build with no target directory of its own -- one driven by make or CMake,
827/// which writes wherever it was told -- records the checkout in that field
828/// rather than inventing a directory. Walking it would report the whole source
829/// tree, `.git` included, as though it were build output.
830fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
831    if record.target_dir == record.workspace_root {
832        return 0;
833    }
834    cached_tree_bytes(sizes, &record.target_dir)
835}
836
837/// Attribute cache and target bytes to each recorded workspace.
838///
839/// Shared objects are counted for every workspace that can reach them. That
840/// makes each row answer "how much keeps this workspace warm" without
841/// pretending shared storage can be divided exactly between projects.
842pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
843    let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
844    let mut target_sizes = BTreeMap::new();
845    let root = store.join(CHECKOUTS_DIR);
846    for identity_entry in read_dir_or_empty(&root)? {
847        let identity = identity_entry.file_name().to_string_lossy().into_owned();
848        if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
849            continue;
850        }
851        for entry in walk_files(&identity_entry.path())? {
852            let Some(record) = read_checkout_record(&entry.path) else {
853                continue;
854            };
855            let project = projects.entry(record.workspace_root.clone()).or_default();
856            if claim_is_live(store, &record) {
857                project.0.insert(identity.clone());
858                project.1 = true;
859            }
860            project.2 = project
861                .2
862                .max(checkout_target_bytes(&mut target_sizes, &record));
863        }
864    }
865    let action_cache = mbx_cache_core::LocalActionCache::new(store);
866    let mut usages = Vec::new();
867    for (workspace_root, (identities, live, target_bytes)) in projects {
868        let mut paths = rooted_objects(store, &identities)?;
869        for identity in &identities {
870            for action in task_manifest_actions(store, identity).unwrap_or_default() {
871                if let Ok(path) = action_cache.path_for(&action) {
872                    paths.insert(path);
873                }
874            }
875        }
876        let action_bytes = paths
877            .iter()
878            .filter_map(|path| std::fs::metadata(path).ok())
879            .map(|metadata| metadata.len())
880            .sum();
881        usages.push(ProjectUsage {
882            workspace_root,
883            identities: identities.len() as u64,
884            action_bytes,
885            target_bytes,
886            live,
887        });
888    }
889    usages.sort_by(|left, right| {
890        right
891            .action_bytes
892            .saturating_add(right.target_bytes)
893            .cmp(&left.action_bytes.saturating_add(left.target_bytes))
894            .then_with(|| left.workspace_root.cmp(&right.workspace_root))
895    });
896    Ok(usages)
897}
898
899/// Return the largest blobs and action-result records in descending order.
900pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
901    let mut entries = Vec::new();
902    for (kind, root, action_result) in [
903        ("object", store.join(CAS_DIR), false),
904        ("action result", store.join(ACTION_RESULTS_DIR), true),
905    ] {
906        entries.extend(
907            walk_files(&root)?
908                .into_iter()
909                .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
910                .map(|entry| LargestEntry {
911                    kind,
912                    path: entry.path,
913                    bytes: entry.size,
914                }),
915        );
916    }
917    entries.sort_by(|left, right| {
918        right
919            .bytes
920            .cmp(&left.bytes)
921            .then_with(|| left.path.cmp(&right.path))
922    });
923    entries.truncate(limit);
924    Ok(entries)
925}
926
927/// Verify every addressable CAS object and action-result record.
928pub fn verify(store: &Path) -> Result<VerifyOutcome> {
929    let cas = LocalCas::new(store);
930    let action_cache = mbx_cache_core::LocalActionCache::new(store);
931    // Verification reports the store as it is on disk and removes nothing, so
932    // no object is pending removal the way one is mid-sweep.
933    let virtually_removed = HashSet::new();
934    let mut outcome = VerifyOutcome::default();
935    for entry in walk_files(&store.join(CAS_DIR))? {
936        let Some(digest) = addressed_digest(store, &entry.path, false) else {
937            continue;
938        };
939        outcome.checked_objects += 1;
940        if !matches!(cas.find(&digest), Ok(Some(_))) {
941            outcome.problems.push(entry.path);
942        }
943    }
944    for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
945        let Some(digest) = addressed_digest(store, &entry.path, true) else {
946            continue;
947        };
948        outcome.checked_action_results += 1;
949        if !matches!(action_cache.find(&digest), Ok(Some(_)))
950            || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
951        {
952            outcome.problems.push(entry.path);
953        }
954    }
955    outcome.problems.sort();
956    Ok(outcome)
957}
958
959/// Remove checkout claims belonging to exactly one workspace.
960pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
961    let mut outcome = RemoveProjectOutcome::default();
962    for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
963        if read_checkout_record(&entry.path)
964            .is_some_and(|record| record.workspace_root == workspace_root)
965        {
966            std::fs::remove_file(&entry.path)?;
967            outcome.removed_checkout_records += 1;
968            if let Some(parent) = entry.path.parent() {
969                let _ = std::fs::remove_dir(parent);
970            }
971        }
972    }
973    match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
974        Ok(()) => {}
975        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
976        Err(error) => return Err(error.into()),
977    }
978    Ok(outcome)
979}
980
981/// Record that `workspace_root` built `identity`.
982///
983/// Identities are shared by every checkout of one dependency graph, so this is
984/// what later tells the collector whether anything still needs what a build
985/// cached: one file per checkout under the identity it built. Writing a whole
986/// file per checkout rather than merging a list into one keeps concurrent
987/// builds out of each other's way -- there is nothing to merge, so there is no
988/// lock and no lost update.
989///
990/// A build with no target directory of its own passes `workspace_root` for
991/// `target_dir`, which reads as "none" rather than as a tree to measure.
992pub fn record_checkout(
993    store: &Path,
994    identity: &str,
995    workspace_root: &Path,
996    target_dir: &Path,
997) -> Result<()> {
998    let record = CheckoutRecord {
999        version: CHECKOUT_RECORD_VERSION,
1000        workspace_root: workspace_root.to_path_buf(),
1001        target_dir: target_dir.to_path_buf(),
1002        updated_secs: SystemTime::now()
1003            .duration_since(UNIX_EPOCH)
1004            .map(|since| since.as_secs())
1005            .unwrap_or_default(),
1006    };
1007    let mut contents = serde_json::to_vec(&record)?;
1008    contents.push(b'\n');
1009    // Refreshed by every build and read back only as a last-used stamp, so a
1010    // power cut costs one build's worth of recency, not a wait on the disk.
1011    write_advisory(
1012        &checkout_record_path(store, identity, workspace_root),
1013        &contents,
1014    )
1015}
1016
1017fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1018    // The path is hashed rather than escaped: it is only ever compared against
1019    // the same hash of the same path, and every filesystem in play disagrees
1020    // about which characters a name may hold.
1021    let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1022    store
1023        .join(CHECKOUTS_DIR)
1024        .join(identity)
1025        .join(format!("{key}.json"))
1026}
1027
1028/// Evict objects until the store fits within `max_bytes`.
1029///
1030/// Objects no live checkout can reach are evicted first, so deleting a worktree
1031/// releases what only that worktree needed. Within each class eviction is
1032/// least-recently-used by access time where the filesystem records it, falling
1033/// back to modification time. A restore verifies the blob it serves, which
1034/// means it reads the whole file and the access time is real -- but only where
1035/// the mount records one, so the ordering is an approximation either way.
1036/// Evicting a live object costs a recompile, never correctness.
1037pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1038    gc_with_mode(store, max_bytes, false)
1039}
1040
1041/// Describe the collection `gc` would perform without changing the store.
1042pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1043    gc_with_mode(store, max_bytes, true)
1044}
1045
1046fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1047    let mut objects = walk_files(&store.join(CAS_DIR))?;
1048    let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1049    let mut live_bytes = objects
1050        .iter()
1051        .chain(results.iter())
1052        .map(|entry| entry.size)
1053        .sum::<u64>();
1054
1055    let mut outcome = GcOutcome::default();
1056
1057    // Prune before deciding what is rooted, so a checkout deleted since the
1058    // last sweep stops protecting its artifacts during this one.
1059    let checkouts = scan_checkouts(store)?;
1060    for path in &checkouts.stale_records {
1061        if dry_run || matches!(remove(path)?, Removal::Removed) {
1062            outcome.removed_checkout_records += 1;
1063        }
1064        // The identity directory is left behind empty otherwise. It may hold
1065        // other checkouts, in which case this fails and that is the answer.
1066        if !dry_run && let Some(parent) = path.parent() {
1067            let _ = std::fs::remove_dir(parent);
1068        }
1069    }
1070
1071    let mut virtually_removed = HashSet::new();
1072    if live_bytes > max_bytes {
1073        // Reachability costs a read per action result and per output tree, so
1074        // it is computed only when something is actually about to be evicted.
1075        // Under budget a sweep stays a directory walk and nothing more.
1076        let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1077        // A grouped CI export deliberately retains the exact actions from
1078        // every command in the job. Later commands can replace the current
1079        // task-manifest prediction for the same invocation, but the earlier
1080        // receipt still has to remain exportable until the post-job step runs.
1081        // Receipt bookkeeping is not part of the cache budget, so consult it
1082        // only when a sweep is already necessary and root its object closure.
1083        rooted.extend(rooted_action_objects(
1084            store,
1085            grouped_receipt_actions(store)?,
1086        ));
1087        // `false` sorts first, so unrooted objects go before rooted ones and
1088        // each class goes oldest-first. A store with no records at all roots
1089        // nothing and this is exactly the LRU it was before.
1090        objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1091        let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1092            if dry_run {
1093                virtually_removed.insert(path.to_path_buf());
1094                Ok(Removal::Removed)
1095            } else {
1096                remove(path)
1097            }
1098        })?;
1099        live_bytes = evicted.remaining_bytes;
1100        outcome.removed_objects += evicted.removed_objects;
1101        outcome.removed_bytes += evicted.removed_bytes;
1102    }
1103
1104    // An action result whose objects are gone can only produce a miss, so drop
1105    // it rather than leave the index pointing at nothing. This runs whether or
1106    // not this call evicted anything, since another process may have, and a
1107    // store that is over budget on results alone still needs the sweep.
1108    let cas = LocalCas::new(store);
1109    for entry in &results {
1110        if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1111            match if dry_run {
1112                Removal::Removed
1113            } else {
1114                remove(&entry.path)?
1115            } {
1116                Removal::Removed => {
1117                    live_bytes = live_bytes.saturating_sub(entry.size);
1118                    outcome.removed_action_results += 1;
1119                    outcome.removed_bytes += entry.size;
1120                }
1121                Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1122                Removal::Blocked => {}
1123            }
1124        }
1125    }
1126
1127    let sessions = prune_sessions(store, dry_run)?;
1128    outcome.removed_session_streams += sessions.removed_streams;
1129    outcome.removed_bytes += sessions.removed_bytes;
1130
1131    outcome.remaining_bytes = live_bytes;
1132    Ok(outcome)
1133}
1134
1135#[derive(Debug, Default, PartialEq, Eq)]
1136struct SessionPrune {
1137    removed_streams: u64,
1138    removed_bytes: u64,
1139}
1140
1141fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1142    let mut prune = SessionPrune::default();
1143    let ids = events::session_ids(store);
1144    let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1145    for (index, id) in ids.iter().enumerate() {
1146        let paths = events::session_paths(store, id);
1147        let metadata = match std::fs::metadata(&paths.events) {
1148            Ok(metadata) => metadata,
1149            Err(_) => continue,
1150        };
1151        let stale = metadata
1152            .modified()
1153            .ok()
1154            .and_then(|at| SystemTime::now().duration_since(at).ok())
1155            .is_some_and(|age| age > SESSION_RETENTION);
1156        if index >= surplus && !stale {
1157            continue;
1158        }
1159        if events::session_is_live(store, id) {
1160            continue;
1161        }
1162        if dry_run {
1163            prune.removed_streams += 1;
1164            prune.removed_bytes += metadata.len();
1165            continue;
1166        }
1167        if matches!(remove(&paths.events)?, Removal::Removed) {
1168            prune.removed_streams += 1;
1169            prune.removed_bytes += metadata.len();
1170            let _ = std::fs::remove_file(&paths.lock);
1171        }
1172    }
1173    if !dry_run {
1174        for lock in events::orphaned_locks(store) {
1175            let _ = std::fs::remove_file(lock);
1176        }
1177    }
1178    Ok(prune)
1179}
1180
1181/// Sweep the store if `interval` has passed since the last attempt.
1182///
1183/// The stamp is written before the sweep, not after. Two builds finishing
1184/// together should cost one sweep between them, and a sweep that dies partway
1185/// should wait its turn like any other rather than retry on every build.
1186pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1187    if !claim_sweep(store, interval)? {
1188        return Ok(None);
1189    }
1190    gc(store, max_bytes).map(Some)
1191}
1192
1193/// Atomically stamp a due sweep before its callers perform coordinated GC.
1194pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1195    let lock_path = store.join(SWEEP_LOCK);
1196    std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1197    let mut lock = fslock::LockFile::open(&lock_path)?;
1198    lock.lock()?;
1199
1200    let stamp = store.join(SWEEP_STAMP);
1201    if let Ok(metadata) = std::fs::metadata(&stamp)
1202        && let Ok(modified) = metadata.modified()
1203        && let Ok(since) = modified.elapsed()
1204        && since < interval
1205    {
1206        return Ok(false);
1207    }
1208    write_atomic(&stamp, b"")?;
1209    Ok(true)
1210}
1211
1212/// What the checkout registry says about the identities in this store.
1213struct CheckoutScan {
1214    live_identities: BTreeSet<String>,
1215    live_records: u64,
1216    stale_records: Vec<PathBuf>,
1217}
1218
1219fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1220    let root = store.join(CHECKOUTS_DIR);
1221    let mut scan = CheckoutScan {
1222        live_identities: BTreeSet::new(),
1223        live_records: 0,
1224        stale_records: Vec::new(),
1225    };
1226    let listing = match std::fs::read_dir(&root) {
1227        Ok(listing) => listing,
1228        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1229        Err(error) => {
1230            return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
1231        }
1232    };
1233    for entry in listing {
1234        let entry = entry?;
1235        let name = entry.file_name().to_string_lossy().into_owned();
1236        // Anything that is not a directory named for an identity was not written
1237        // by mbx. Both halves matter: `walk_files` tolerates only a missing
1238        // directory, so a plain file whose name happens to look like an identity
1239        // would fail the whole scan -- and that scan runs inside every sweep,
1240        // including the automatic one after a build.
1241        if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
1242            continue;
1243        }
1244        for record in walk_files(&entry.path())? {
1245            match read_checkout_record(&record.path) {
1246                Some(checkout) if claim_is_live(store, &checkout) => {
1247                    scan.live_identities.insert(name.clone());
1248                    scan.live_records += 1;
1249                }
1250                Some(_) => scan.stale_records.push(record.path),
1251                // A record this build cannot read claims nothing, but it is not
1252                // evidence the checkout is gone either, so leave it alone.
1253                None => {}
1254            }
1255        }
1256    }
1257    Ok(scan)
1258}
1259
1260fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
1261    let bytes = std::fs::read(path).ok()?;
1262    let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
1263    (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
1264}
1265
1266/// Actions whose successful grouped export has not retired its receipts yet.
1267fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
1268    let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
1269    Ok(walk_files(&root)?
1270        .into_iter()
1271        .filter_map(|entry| read_build_receipt(&entry.path))
1272        .flat_map(|receipt| receipt.predictions.into_iter())
1273        .map(|prediction| prediction.action)
1274        .collect())
1275}
1276
1277/// Whether a recorded claim still speaks for a checkout that is using this
1278/// store.
1279fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
1280    checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
1281}
1282
1283fn claim_has_expired(record: &CheckoutRecord) -> bool {
1284    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1285        return false;
1286    };
1287    now.as_secs()
1288        .saturating_sub(record.updated_secs)
1289        .gt(&CHECKOUT_RETENTION.as_secs())
1290}
1291
1292/// Whether the checkout a record names is still on disk.
1293///
1294/// Absence is believed only when the checkout is definitely absent and its
1295/// nearest existing ancestor is on the same filesystem as the store. Walking
1296/// all the way to that ancestor matters for worktree managers and temporary
1297/// directories, which commonly remove a checkout together with one or more of
1298/// its otherwise-empty parents.
1299///
1300/// A different filesystem can be a mount whose contents are temporarily
1301/// unavailable, so uncertainty there remains live. Errors are treated the same
1302/// way: being wrong in that direction only delays collection; the other way
1303/// round throws away a warm cache someone is still using.
1304pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
1305    if !matches!(workspace_root.try_exists(), Ok(false)) {
1306        return true;
1307    }
1308
1309    let Ok(store_metadata) = std::fs::metadata(store) else {
1310        return true;
1311    };
1312    for ancestor in workspace_root.ancestors().skip(1) {
1313        match std::fs::metadata(ancestor) {
1314            Ok(ancestor_metadata) => {
1315                return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
1316            }
1317            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1318            Err(_) => return true,
1319        }
1320    }
1321    true
1322}
1323
1324/// Whether a checkout is still on disk, corroborating absence through its
1325/// immediate parent.
1326///
1327/// Prefer [`checkout_is_live_on`] when an existing store or managed-target
1328/// root is available to distinguish deletion from an unavailable filesystem.
1329pub fn checkout_is_live(workspace_root: &Path) -> bool {
1330    let Some(parent) = workspace_root.parent() else {
1331        return true;
1332    };
1333    checkout_is_live_on(parent, workspace_root)
1334}
1335
1336#[cfg(unix)]
1337fn same_filesystem(
1338    _a_path: &Path,
1339    a: &std::fs::Metadata,
1340    _b_path: &Path,
1341    b: &std::fs::Metadata,
1342) -> bool {
1343    use std::os::unix::fs::MetadataExt as _;
1344    a.dev() == b.dev()
1345}
1346
1347#[cfg(windows)]
1348fn same_filesystem(
1349    a_path: &Path,
1350    _a: &std::fs::Metadata,
1351    b_path: &Path,
1352    _b: &std::fs::Metadata,
1353) -> bool {
1354    windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
1355}
1356
1357#[cfg(windows)]
1358fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
1359    use std::os::windows::ffi::OsStrExt as _;
1360    use windows_sys::Win32::Storage::FileSystem::{
1361        GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
1362    };
1363
1364    let path = path
1365        .as_os_str()
1366        .encode_wide()
1367        .chain(Some(0))
1368        .collect::<Vec<_>>();
1369    // Both APIs document MAX_PATH-sized output buffers for volume paths and
1370    // names. Failure is uncertainty, which deliberately keeps the checkout
1371    // live.
1372    let mut mount = vec![0_u16; 261];
1373    // SAFETY: `path` is nul-terminated and `mount` is writable for the stated
1374    // number of UTF-16 code units.
1375    if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
1376        return None;
1377    }
1378    let mut volume = vec![0_u16; 261];
1379    // SAFETY: the successful call above left `mount` nul-terminated, and
1380    // `volume` is writable for the stated number of UTF-16 code units.
1381    if unsafe {
1382        GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
1383    } == 0
1384    {
1385        return None;
1386    }
1387    volume.truncate(volume.iter().position(|unit| *unit == 0)?);
1388    Some(volume)
1389}
1390
1391#[cfg(not(any(unix, windows)))]
1392fn same_filesystem(
1393    _a_path: &Path,
1394    _a: &std::fs::Metadata,
1395    _b_path: &Path,
1396    _b: &std::fs::Metadata,
1397) -> bool {
1398    false
1399}
1400
1401/// CAS paths reachable from the builds that live checkouts still depend on.
1402///
1403/// Recursing into output trees is the point: the descriptor blobs are tiny and
1404/// the leaf artifacts are where the bytes are, so rooting that stopped at the
1405/// top level would protect nothing worth protecting. Every read here is
1406/// tolerant -- a blob that has already been evicted, or that no longer parses,
1407/// simply roots less, and rooting less can only mean collecting sooner.
1408fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
1409    let mut actions = BTreeSet::new();
1410    for identity in identities {
1411        // One manifest this build cannot read is not worth abandoning a sweep
1412        // over. It roots nothing, which can only mean collecting sooner.
1413        let manifest_actions = match task_manifest_actions(store, identity) {
1414            Ok(actions) => actions,
1415            Err(error) => {
1416                log::debug!("could not read the manifest for {identity}: {error}");
1417                continue;
1418            }
1419        };
1420        actions.extend(manifest_actions);
1421    }
1422    Ok(rooted_action_objects(store, actions))
1423}
1424
1425/// Return every local CAS path needed to restore the supplied actions.
1426fn rooted_action_objects(
1427    store: &Path,
1428    actions: impl IntoIterator<Item = CacheDigest>,
1429) -> HashSet<PathBuf> {
1430    let cas = LocalCas::new(store);
1431    let mut rooted = HashSet::new();
1432    let mut visited = BTreeSet::new();
1433    for action in actions {
1434        let Some(result) = read_action_result(store, &action) else {
1435            continue;
1436        };
1437        root_digest(&cas, &mut rooted, &result.action);
1438        if let Some(metadata) = &result.metadata {
1439            root_digest(&cas, &mut rooted, metadata);
1440            // The metadata blob is a descriptor too: it names the captured
1441            // stdout and stderr, and a restore reads both. Stopping at the
1442            // descriptor would leave the diagnostics of every action
1443            // unrooted -- including the one empty blob that every silent
1444            // compilation shares, which is enough on its own to turn a
1445            // rooted hit back into a miss.
1446            if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
1447                root_digest(&cas, &mut rooted, &rustc.stdout);
1448                root_digest(&cas, &mut rooted, &rustc.stderr);
1449            }
1450        }
1451        if let Some(output_root) = &result.output_root {
1452            root_digest(&cas, &mut rooted, output_root);
1453            root_tree(&cas, output_root, &mut rooted, &mut visited);
1454        }
1455    }
1456    rooted
1457}
1458
1459fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
1460    let path = mbx_cache_core::LocalActionCache::new(store)
1461        .path_for(action)
1462        .ok()?;
1463    let bytes = std::fs::read(path).ok()?;
1464    serde_json::from_slice(&bytes).ok()
1465}
1466
1467fn root_tree(
1468    cas: &LocalCas,
1469    output_root: &CacheDigest,
1470    rooted: &mut HashSet<PathBuf>,
1471    visited: &mut BTreeSet<CacheDigest>,
1472) {
1473    let mut pending = vec![output_root.clone()];
1474    while let Some(digest) = pending.pop() {
1475        if !visited.insert(digest.clone()) {
1476            continue;
1477        }
1478        let Some(directory) = read_directory(cas, &digest) else {
1479            continue;
1480        };
1481        for file in &directory.files {
1482            root_digest(cas, rooted, &file.digest);
1483        }
1484        for child in &directory.directories {
1485            root_digest(cas, rooted, &child.digest);
1486            pending.push(child.digest.clone());
1487        }
1488    }
1489}
1490
1491fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
1492    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1493    serde_json::from_slice(&bytes).ok()
1494}
1495
1496fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
1497    let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1498    serde_json::from_slice(&bytes).ok()
1499}
1500
1501fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
1502    if let Ok(path) = cas.path_for(digest) {
1503        rooted.insert(path);
1504    }
1505}
1506
1507#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1508enum Removal {
1509    Removed,
1510    Missing,
1511    Blocked,
1512}
1513
1514fn remove(path: &Path) -> Result<Removal> {
1515    match std::fs::remove_file(path) {
1516        Ok(()) => Ok(Removal::Removed),
1517        // A concurrent build may have evicted or replaced it already.
1518        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
1519        // Windows refuses to unlink a file another process holds open, which is
1520        // what a concurrent build reading this blob looks like. Skipping it
1521        // leaves the store over budget until the next sweep; failing would
1522        // abandon the sweep and leave it over budget for longer.
1523        Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1524            log::debug!("could not evict {}: {error}", path.display());
1525            Ok(Removal::Blocked)
1526        }
1527        Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
1528    }
1529}
1530
1531#[derive(Debug, Default, PartialEq, Eq)]
1532struct ObjectEvictions {
1533    removed_objects: u64,
1534    removed_bytes: u64,
1535    remaining_bytes: u64,
1536}
1537
1538/// Evict sorted objects without crossing the checkout-protection boundary
1539/// when an unrooted object cannot be removed.
1540fn evict_objects(
1541    objects: &[Entry],
1542    rooted: &HashSet<PathBuf>,
1543    mut live_bytes: u64,
1544    max_bytes: u64,
1545    mut remove_file: impl FnMut(&Path) -> Result<Removal>,
1546) -> Result<ObjectEvictions> {
1547    let mut outcome = ObjectEvictions::default();
1548    let mut blocked_unrooted = false;
1549    for entry in objects {
1550        if live_bytes <= max_bytes {
1551            break;
1552        }
1553        let is_rooted = rooted.contains(&entry.path);
1554        if is_rooted && blocked_unrooted {
1555            // A locked unrooted blob is temporarily part of the irreducible
1556            // store. Deleting a live checkout's artifacts in its place would
1557            // invert the protection ordering this collector promises.
1558            break;
1559        }
1560        match remove_file(&entry.path)? {
1561            Removal::Removed => {
1562                live_bytes = live_bytes.saturating_sub(entry.size);
1563                outcome.removed_objects += 1;
1564                outcome.removed_bytes += entry.size;
1565            }
1566            Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1567            Removal::Blocked if !is_rooted => blocked_unrooted = true,
1568            Removal::Blocked => {}
1569        }
1570    }
1571    outcome.remaining_bytes = live_bytes;
1572    Ok(outcome)
1573}
1574
1575/// Whether an action result references an object the store no longer has.
1576///
1577/// The digests checked here are exactly the ones `LocalActionCache::store`
1578/// requires. If the sweep checked fewer, eviction could leave an entry that
1579/// cannot be republished -- `store` would reject the identical result for a
1580/// missing blob while the index still claimed to hold it.
1581///
1582/// Presence is all that is checked, not content. Verifying would re-hash a
1583/// large fraction of the store on every sweep, which a chore could afford and
1584/// an automatic sweep cannot; it would also refresh the access time of every
1585/// descriptor blob while leaving the artifacts alone, biasing the very
1586/// ordering this module depends on. A blob that is present but corrupt now
1587/// keeps its result and turns into a miss on restore, and both CAS write paths
1588/// republish over a corrupt blob rather than trusting it.
1589///
1590/// Only the top-level objects are checked; a result whose output tree lost a
1591/// nested object still restores as a miss, which is safe.
1592fn action_result_is_dangling(
1593    cas: &LocalCas,
1594    path: &Path,
1595    virtually_removed: &HashSet<PathBuf>,
1596) -> Result<bool> {
1597    let bytes = match std::fs::read(path) {
1598        Ok(bytes) => bytes,
1599        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1600        Err(error) => {
1601            return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
1602        }
1603    };
1604    // An unparseable result is already useless; the cache rejects it on read.
1605    let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
1606        return Ok(false);
1607    };
1608    for digest in [
1609        Some(&result.action),
1610        result.metadata.as_ref(),
1611        result.output_root.as_ref(),
1612    ]
1613    .into_iter()
1614    .flatten()
1615    {
1616        match cas.path_for(digest) {
1617            Ok(path) => {
1618                if virtually_removed.contains(&path) || !path.exists() {
1619                    return Ok(true);
1620                }
1621            }
1622            // A digest the CAS cannot even address is not one it can hold.
1623            Err(_) => return Ok(true),
1624        }
1625    }
1626    Ok(false)
1627}
1628
1629struct Entry {
1630    path: PathBuf,
1631    size: u64,
1632    used: SystemTime,
1633}
1634
1635fn walk_files(root: &Path) -> Result<Vec<Entry>> {
1636    let mut entries = Vec::new();
1637    let mut pending = vec![root.to_path_buf()];
1638    while let Some(directory) = pending.pop() {
1639        let listing = match std::fs::read_dir(&directory) {
1640            Ok(listing) => listing,
1641            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1642            Err(error) => {
1643                return Err(error)
1644                    .wrap_err_with(|| format!("failed to read {}", directory.display()));
1645            }
1646        };
1647        for entry in listing {
1648            let entry = entry?;
1649            let metadata = match entry.metadata() {
1650                Ok(metadata) => metadata,
1651                // A concurrent build may be publishing into the store.
1652                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1653                Err(error) => return Err(error.into()),
1654            };
1655            if metadata.is_dir() {
1656                pending.push(entry.path());
1657            } else if metadata.is_file() {
1658                let used = metadata
1659                    .accessed()
1660                    .or_else(|_| metadata.modified())
1661                    .unwrap_or(SystemTime::UNIX_EPOCH);
1662                entries.push(Entry {
1663                    path: entry.path(),
1664                    size: metadata.len(),
1665                    used,
1666                });
1667            }
1668        }
1669    }
1670    Ok(entries)
1671}
1672
1673fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
1674    match std::fs::read_dir(root) {
1675        Ok(entries) => entries
1676            .collect::<std::io::Result<Vec<_>>>()
1677            .map_err(Into::into),
1678        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
1679        Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
1680    }
1681}
1682
1683fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
1684    let name = path.file_name()?.to_str()?;
1685    let name = if action_result {
1686        name.strip_suffix(".json")?
1687    } else {
1688        name
1689    };
1690    let (hash, size) = name.rsplit_once('-')?;
1691    let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
1692    let digest = CacheDigest {
1693        algorithm,
1694        hash: hash.to_string(),
1695        size: size.parse().ok()?,
1696    };
1697    digest.validate().ok()?;
1698    Some(digest)
1699}
1700
1701fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
1702    let digest = digest_from_path(path, action_result)?;
1703    let canonical = if action_result {
1704        mbx_cache_core::LocalActionCache::new(store)
1705            .path_for(&digest)
1706            .ok()?
1707    } else {
1708        LocalCas::new(store).path_for(&digest).ok()?
1709    };
1710    (canonical == path).then_some(digest)
1711}
1712
1713fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
1714    *cache
1715        .entry(root.to_path_buf())
1716        .or_insert_with(|| tree_bytes(root))
1717}
1718
1719fn tree_bytes(root: &Path) -> u64 {
1720    let Ok(root_metadata) = std::fs::metadata(root) else {
1721        return 0;
1722    };
1723    if root_metadata.is_file() {
1724        return root_metadata.len();
1725    }
1726    let Ok(entries) = std::fs::read_dir(root) else {
1727        return 0;
1728    };
1729    let mut bytes = 0_u64;
1730    let mut pending = entries
1731        .filter_map(std::result::Result::ok)
1732        .map(|entry| entry.path())
1733        .collect::<Vec<_>>();
1734    while let Some(path) = pending.pop() {
1735        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1736            continue;
1737        };
1738        if metadata.is_file() {
1739            bytes = bytes.saturating_add(metadata.len());
1740        } else if metadata.is_dir()
1741            && let Ok(entries) = std::fs::read_dir(path)
1742        {
1743            pending.extend(
1744                entries
1745                    .filter_map(std::result::Result::ok)
1746                    .map(|entry| entry.path()),
1747            );
1748        }
1749    }
1750    bytes
1751}
1752
1753#[cfg(test)]
1754#[path = "store_tests.rs"]
1755mod tests;
1756
1757/// [`write_atomic`] without the sync: for records every build rewrites and
1758/// that a torn or missing file only makes one build older.
1759fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
1760    let parent = path
1761        .parent()
1762        .filter(|parent| !parent.as_os_str().is_empty())
1763        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1764    std::fs::create_dir_all(parent)
1765        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1766    let mut temporary = tempfile::Builder::new()
1767        .prefix(".mbx-")
1768        .tempfile_in(parent)?;
1769    use std::io::Write as _;
1770    temporary.write_all(contents)?;
1771    temporary
1772        .persist(path)
1773        .map_err(|error| error.error)
1774        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1775    Ok(())
1776}
1777
1778fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
1779    let parent = path
1780        .parent()
1781        .filter(|parent| !parent.as_os_str().is_empty())
1782        .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1783    std::fs::create_dir_all(parent)
1784        .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1785    let mut temporary = tempfile::Builder::new()
1786        .prefix(".mbx-")
1787        .tempfile_in(parent)?;
1788    use std::io::Write as _;
1789    temporary.write_all(contents)?;
1790    temporary.as_file_mut().sync_all()?;
1791    temporary
1792        .persist(path)
1793        .map_err(|error| error.error)
1794        .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1795    Ok(())
1796}