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