1mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16 ActionPrediction, ActionSlot, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult,
17 RustcMetadata, TaskActionManifest, is_task_identity, merge_task_action_predictions,
18 task_manifest_actions,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
22use std::path::{Component, Path, PathBuf};
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27const CAS_DIR: &str = "cas/v1";
28const ACTION_RESULTS_DIR: &str = "action-results/v1";
29const CHECKOUTS_DIR: &str = "checkouts/v1";
30const SWEEP_STAMP: &str = "gc/v1/last-sweep";
31const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
32const CHECKOUT_RECORD_VERSION: u8 = 1;
33const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
34const BUILD_RECEIPT_VERSION: u8 = 1;
35const IMPORT_STAGING_DIR: &str = "import-staging";
36const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
37const EXPORT_VERSION: u8 = 2;
38const LEGACY_EXPORT_VERSION: u8 = 1;
39
40const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
41const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
42const MAX_SESSIONS: usize = 256;
43
44const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
54
55#[derive(Debug, Default, PartialEq, Eq)]
56pub struct StoreStats {
57 pub objects: u64,
58 pub object_bytes: u64,
59 pub action_results: u64,
60 pub action_result_bytes: u64,
61 pub live_checkouts: u64,
62 pub stale_checkouts: u64,
67}
68
69impl StoreStats {
70 pub fn total_bytes(&self) -> u64 {
71 self.object_bytes.saturating_add(self.action_result_bytes)
72 }
73}
74
75#[derive(Debug, Default, PartialEq, Eq)]
76pub struct GcOutcome {
77 pub removed_objects: u64,
78 pub removed_action_results: u64,
79 pub removed_checkout_records: u64,
80 pub removed_session_streams: u64,
83 pub removed_bytes: u64,
84 pub remaining_bytes: u64,
85}
86
87#[derive(Debug, PartialEq, Eq)]
88pub struct ProjectUsage {
89 pub workspace_root: PathBuf,
90 pub identities: u64,
91 pub action_bytes: u64,
92 pub target_bytes: u64,
93 pub live: bool,
94}
95
96#[derive(Debug, PartialEq, Eq)]
97pub struct LargestEntry {
98 pub kind: &'static str,
99 pub path: PathBuf,
100 pub bytes: u64,
101}
102
103#[derive(Debug, Default, PartialEq, Eq)]
104pub struct VerifyOutcome {
105 pub checked_objects: u64,
106 pub checked_action_results: u64,
107 pub problems: Vec<PathBuf>,
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
111pub struct RemoveProjectOutcome {
112 pub removed_checkout_records: u64,
113}
114
115#[derive(Debug, Default, PartialEq, Eq)]
116pub struct TransferOutcome {
117 pub actions: u64,
118 pub objects: u64,
119 pub bytes: u64,
120}
121
122#[derive(Debug)]
124#[non_exhaustive]
125pub struct ImportOutcome {
126 pub transfer: TransferOutcome,
127 pub attachments: BTreeMap<String, CacheDigest>,
128}
129
130#[derive(Debug, Default)]
132pub struct ExportAdditions {
133 pub attachments: BTreeMap<String, CacheDigest>,
135 pub objects: BTreeSet<CacheDigest>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
141pub struct WorkspaceTarget {
142 pub workspace_root: PathBuf,
143 pub target_dir: PathBuf,
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct ExportManifest {
149 version: u8,
150 tasks: Vec<TaskActionManifest>,
151 actions: Vec<CacheDigest>,
152 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153 attachments: BTreeMap<String, CacheDigest>,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
155 objects: Vec<CacheDigest>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct BuildReceipt {
162 version: u8,
163 workspace_root: PathBuf,
164 identity: String,
165 completed_nanos: u64,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 group: Option<String>,
168 predictions: Vec<ActionPrediction>,
169}
170
171pub fn record_build_receipt(
173 store: &Path,
174 run: &str,
175 identity: &str,
176 workspace_root: &Path,
177 group: Option<&str>,
178 predictions: Vec<ActionPrediction>,
179) -> Result<()> {
180 if !is_task_identity(run) || !is_task_identity(identity) {
181 eyre::bail!("invalid build receipt identity");
182 }
183 if !(TaskActionManifest {
184 version: 1,
185 task: identity.to_owned(),
186 predictions: predictions.clone(),
187 })
188 .validate()
189 {
190 eyre::bail!("invalid build receipt prediction");
191 }
192 if let Some(group) = group {
193 validate_export_group(group)?;
194 }
195 if group.is_none()
200 && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
201 latest.identity == identity
202 && latest.workspace_root == workspace_root
203 && latest.group.is_none()
204 && latest.predictions == predictions
205 })
206 {
207 return Ok(());
208 }
209 let key = workspace_key(workspace_root);
210 let lock_path = store
211 .join(BUILD_RECEIPTS_DIR)
212 .join("locks")
213 .join(format!("{key}.lock"));
214 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
215 let mut lock = fslock::LockFile::open(&lock_path)?;
216 lock.lock()?;
217 let completed_nanos = SystemTime::now()
218 .duration_since(UNIX_EPOCH)
219 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
220 .unwrap_or_default();
221 let receipt = BuildReceipt {
222 version: BUILD_RECEIPT_VERSION,
223 workspace_root: workspace_root.to_path_buf(),
224 identity: identity.to_owned(),
225 completed_nanos,
226 group: group.map(str::to_owned),
227 predictions,
228 };
229 let bytes = serde_json::to_vec(&receipt)?;
230 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
231 if let Some(group) = group {
232 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
233 }
234 Ok(())
235}
236
237fn workspace_key(workspace_root: &Path) -> String {
238 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
239}
240
241fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
242 store
243 .join(BUILD_RECEIPTS_DIR)
244 .join("checkouts")
245 .join(format!("{}.json", workspace_key(workspace_root)))
246}
247
248fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
249 store
250 .join(BUILD_RECEIPTS_DIR)
251 .join("groups")
252 .join(group_key(group))
253 .join(format!("{run}.json"))
254}
255
256fn group_key(group: &str) -> String {
257 CacheDigest::blake3(group.as_bytes()).hash
258}
259
260fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
261 let bytes = std::fs::read(path).ok()?;
262 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
263 let valid = TaskActionManifest {
264 version: 1,
265 task: receipt.identity.clone(),
266 predictions: receipt.predictions.clone(),
267 }
268 .validate();
269 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
270}
271
272fn validate_export_group(group: &str) -> Result<()> {
273 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
274 eyre::bail!("invalid build export group");
275 }
276 Ok(())
277}
278
279#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
287pub enum ExportForm {
288 #[default]
290 Tar,
291 Directory,
293}
294
295impl std::str::FromStr for ExportForm {
296 type Err = eyre::Report;
297
298 fn from_str(text: &str) -> Result<Self> {
299 match text {
300 "tar" => Ok(Self::Tar),
301 "directory" => Ok(Self::Directory),
302 other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
303 }
304 }
305}
306
307pub fn export_checkout(
309 store: &Path,
310 workspace_root: &Path,
311 archive: &Path,
312) -> Result<TransferOutcome> {
313 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
314 .filter(|receipt| receipt.workspace_root == workspace_root)
315 .ok_or_else(|| {
316 eyre::eyre!(
317 "no completed mbx build is recorded for {}",
318 workspace_root.display()
319 )
320 })?;
321 export_receipts(
322 store,
323 vec![receipt],
324 archive,
325 ExportAdditions::default(),
326 ExportForm::Tar,
327 )
328}
329
330pub fn checkout_workspace_target(
332 store: &Path,
333 workspace_root: &Path,
334) -> Result<Option<WorkspaceTarget>> {
335 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
336 .filter(|receipt| receipt.workspace_root == workspace_root)
337 else {
338 return Ok(None);
339 };
340 Ok(workspace_target_for_receipt(store, &receipt))
341}
342
343pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
345 validate_export_group(group)?;
346 let root = store
347 .join(BUILD_RECEIPTS_DIR)
348 .join("groups")
349 .join(group_key(group));
350 let targets = walk_files(&root)?
351 .into_iter()
352 .filter_map(|entry| read_build_receipt(&entry.path))
353 .filter(|receipt| receipt.group.as_deref() == Some(group))
354 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
355 .collect::<BTreeSet<_>>();
356 Ok(targets.into_iter().collect())
357}
358
359fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
360 let record = read_checkout_record(&checkout_record_path(
361 store,
362 &receipt.identity,
363 &receipt.workspace_root,
364 ))?;
365 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
366 .then_some(WorkspaceTarget {
367 workspace_root: record.workspace_root,
368 target_dir: record.target_dir,
369 })
370}
371
372pub fn export_checkout_with(
374 store: &Path,
375 workspace_root: &Path,
376 archive: &Path,
377 additions: ExportAdditions,
378) -> Result<TransferOutcome> {
379 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
380 .filter(|receipt| receipt.workspace_root == workspace_root)
381 .ok_or_else(|| {
382 eyre::eyre!(
383 "no completed mbx build is recorded for {}",
384 workspace_root.display()
385 )
386 })?;
387 export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
388}
389
390pub fn export_checkout_as(
392 store: &Path,
393 workspace_root: &Path,
394 destination: &Path,
395 additions: ExportAdditions,
396 form: ExportForm,
397) -> Result<TransferOutcome> {
398 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
399 .filter(|receipt| receipt.workspace_root == workspace_root)
400 .ok_or_else(|| {
401 eyre::eyre!(
402 "no completed mbx build is recorded for {}",
403 workspace_root.display()
404 )
405 })?;
406 export_receipts(store, vec![receipt], destination, additions, form)
407}
408
409pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
411 export_group_with(store, group, archive, ExportAdditions::default())
412}
413
414pub fn export_group_with(
416 store: &Path,
417 group: &str,
418 archive: &Path,
419 additions: ExportAdditions,
420) -> Result<TransferOutcome> {
421 export_group_as(store, group, archive, additions, ExportForm::Tar)
422}
423
424pub fn export_group_as(
426 store: &Path,
427 group: &str,
428 destination: &Path,
429 additions: ExportAdditions,
430 form: ExportForm,
431) -> Result<TransferOutcome> {
432 validate_export_group(group)?;
433 let root = store
434 .join(BUILD_RECEIPTS_DIR)
435 .join("groups")
436 .join(group_key(group));
437 let receipts = walk_files(&root)?
438 .into_iter()
439 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
440 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
441 .collect::<Vec<_>>();
442 if receipts.is_empty() {
443 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
444 }
445 let outcome = export_receipts(
446 store,
447 receipts
448 .iter()
449 .map(|(_, receipt)| receipt.clone())
450 .collect(),
451 destination,
452 additions,
453 form,
454 )?;
455 for (path, _) in receipts {
460 std::fs::remove_file(path)?;
461 }
462 let _ = std::fs::remove_dir(root);
463 Ok(outcome)
464}
465
466fn export_receipts(
467 store: &Path,
468 mut receipts: Vec<BuildReceipt>,
469 archive: &Path,
470 additions: ExportAdditions,
471 form: ExportForm,
472) -> Result<TransferOutcome> {
473 receipts.sort_by(|left, right| {
474 left.completed_nanos
475 .cmp(&right.completed_nanos)
476 .then_with(|| {
477 fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
480 (
481 &prediction.invocation,
482 &prediction.action,
483 &prediction.adapter,
484 &prediction.payload,
485 )
486 }
487 left.predictions
488 .iter()
489 .map(key)
490 .cmp(right.predictions.iter().map(key))
491 })
492 });
493 let mut actions = BTreeSet::new();
494 let mut tasks = BTreeMap::new();
495 for receipt in receipts {
496 actions.extend(
497 receipt
498 .predictions
499 .iter()
500 .map(|prediction| prediction.action.clone()),
501 );
502 tasks
505 .entry(receipt.identity)
506 .or_insert_with(Vec::new)
507 .extend(receipt.predictions);
508 }
509 let tasks = tasks
510 .into_iter()
511 .map(|(task, predictions)| {
512 let mut invocations = BTreeSet::new();
513 let mut predictions = predictions
514 .into_iter()
515 .rev()
516 .filter(|prediction| invocations.insert(prediction.invocation.clone()))
517 .collect::<Vec<_>>();
518 predictions.reverse();
519 TaskActionManifest {
520 version: 1,
521 task,
522 predictions,
523 }
524 })
525 .collect::<Vec<_>>();
526 if tasks.iter().any(|task| !task.validate()) {
527 eyre::bail!("combined export predictions exceed task manifest limits");
528 }
529 validate_export_additions(&additions)?;
530 let mut closure = strict_closure(store, &actions)?;
531 let cas = LocalCas::new(store);
532 for digest in &additions.objects {
533 require_object(&cas, &mut closure, digest)?;
534 }
535 verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
539 let parent = archive
540 .parent()
541 .filter(|parent| !parent.as_os_str().is_empty())
542 .unwrap_or_else(|| Path::new("."));
543 std::fs::create_dir_all(parent)?;
544 let manifest = serde_json::to_vec(&ExportManifest {
545 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
546 LEGACY_EXPORT_VERSION
547 } else {
548 EXPORT_VERSION
549 },
550 tasks,
551 actions: actions.iter().cloned().collect(),
552 attachments: additions.attachments.clone(),
553 objects: additions.objects.iter().cloned().collect(),
554 })?;
555 let members = closure
556 .objects
557 .iter()
558 .chain(closure.results.iter())
559 .collect::<Vec<_>>();
560 let bytes = match form {
561 ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
562 ExportForm::Directory => {
563 write_directory_export(store, archive, parent, &manifest, &members)?
564 }
565 };
566 Ok(TransferOutcome {
567 actions: actions.len() as u64,
568 objects: closure.objects.len() as u64,
569 bytes,
570 })
571}
572
573fn write_tar_export(
575 store: &Path,
576 archive: &Path,
577 parent: &Path,
578 manifest: &[u8],
579 members: &[&PathBuf],
580) -> Result<u64> {
581 let temporary = tempfile::Builder::new()
582 .prefix(".mbx-export-")
583 .tempfile_in(parent)?;
584 let mut builder = tar::Builder::new(temporary.reopen()?);
585 append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
586 for path in members {
587 append_file(&mut builder, store, path)?;
588 }
589 builder.finish()?;
590 drop(builder);
591 temporary
592 .persist(archive)
593 .map_err(|error| error.error)
594 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
595 Ok(std::fs::metadata(archive)?.len())
596}
597
598fn write_directory_export(
604 store: &Path,
605 destination: &Path,
606 parent: &Path,
607 manifest: &[u8],
608 members: &[&PathBuf],
609) -> Result<u64> {
610 let staging = tempfile::Builder::new()
611 .prefix(".mbx-export-")
612 .tempdir_in(parent)?;
613 std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
614 let mut bytes = manifest.len() as u64;
615 for path in members {
616 let relative = path.strip_prefix(store)?;
617 let target = staging.path().join(relative);
618 std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
619 bytes += std::fs::copy(path, &target)?;
620 }
621 let staged = staging.keep();
622 let retired = match std::fs::symlink_metadata(destination) {
627 Ok(_) => {
628 let holder = tempfile::Builder::new()
629 .prefix(".mbx-export-retired-")
630 .tempdir_in(parent)?;
631 let moved = holder.path().join("bundle");
632 std::fs::rename(destination, &moved)
633 .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
634 Some((holder, moved))
635 }
636 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
637 Err(error) => {
638 return Err(error)
639 .wrap_err_with(|| format!("failed to replace {}", destination.display()));
640 }
641 };
642 if let Err(error) = std::fs::rename(&staged, destination) {
643 let _ = std::fs::remove_dir_all(&staged);
644 if let Some((holder, moved)) = retired
645 && std::fs::rename(&moved, destination).is_err()
646 {
647 let kept = holder.keep();
652 log::warn!(
653 "could not restore {} after a failed export; its previous contents are at {}",
654 destination.display(),
655 kept.join("bundle").display()
656 );
657 }
658 return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
659 }
660 drop(retired);
662 Ok(bytes)
663}
664
665pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
667 Ok(import_archive_with_attachments(store, archive)?.transfer)
668}
669
670pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
672 let staged;
676 let _staging_lock;
677 let root = if archive.is_dir() {
678 _staging_lock = None;
679 validate_directory_bundle(archive)?;
680 archive
681 } else {
682 let staging_root = store.join(IMPORT_STAGING_DIR);
688 std::fs::create_dir_all(&staging_root)?;
689 staged = tempfile::Builder::new()
690 .prefix("import-")
691 .tempdir_in(&staging_root)?;
692 _staging_lock = Some(lock_import_staging(staged.path())?);
697 unpack_archive(archive, staged.path())?;
698 staged.path()
699 };
700 let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
703 let manifest: ExportManifest =
704 serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
705 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
706 let task_identities = manifest
707 .tasks
708 .iter()
709 .map(|task| task.task.as_str())
710 .collect::<BTreeSet<_>>();
711 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
712 || (manifest.version == LEGACY_EXPORT_VERSION
713 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
714 || manifest.tasks.is_empty()
715 || actions.len() != manifest.actions.len()
716 || task_identities.len() != manifest.tasks.len()
717 || manifest.tasks.iter().any(|task| !task.validate())
718 || manifest
719 .actions
720 .iter()
721 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
722 || manifest.tasks.iter().any(|task| {
723 task.predictions
724 .iter()
725 .any(|prediction| !actions.contains(&prediction.action))
726 })
727 || manifest
728 .attachments
729 .keys()
730 .any(|name| !valid_attachment_name(name))
731 || manifest.attachments.values().any(|digest| {
732 digest.algorithm != "blake3"
733 || digest.validate().is_err()
734 || !manifest.objects.contains(digest)
735 })
736 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
737 || manifest
738 .objects
739 .iter()
740 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
741 {
742 eyre::bail!("unsupported or invalid cache export manifest");
743 }
744 let mut closure =
745 strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
746 let staged_cas = LocalCas::new(root);
747 for digest in &manifest.objects {
748 require_object(&staged_cas, &mut closure, digest)
749 .wrap_err("cache export attachment is incomplete or corrupt")?;
750 }
751 verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
752
753 let results = closure
761 .results
762 .iter()
763 .map(|path| {
764 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
765 .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
766 Ok(result)
767 })
768 .collect::<Result<Vec<_>>>()
769 .wrap_err("cache export is incomplete or corrupt")?;
770
771 let cas = LocalCas::new(store);
772 for path in &closure.objects {
773 let relative = path.strip_prefix(root)?;
774 let source = root.join(relative);
775 let digest = addressed_digest(root, &source, false)
776 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
777 cas.adopt_verified_file(&digest, &source)?;
781 }
782 let action_cache = mbx_cache_core::LocalActionCache::new(store);
783 let mut kept_existing = 0;
784 for result in &results {
785 if action_cache.store_unless_restorable(result, |existing| {
786 extend_result_closure(
787 &cas,
788 &mut Closure::default(),
789 &mut BTreeSet::new(),
790 existing,
791 false,
792 )
793 .is_ok()
794 })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
795 {
796 kept_existing += 1;
797 }
798 }
799 if kept_existing > 0 {
800 log::debug!(
801 "cache import kept {kept_existing} intact existing results that differed from the bundle"
802 );
803 }
804 for task in manifest.tasks {
805 merge_imported_manifest(store, task)?;
806 }
807 let bytes = match bundle_bytes {
808 Some(bytes) => {
809 std::fs::remove_dir_all(archive)
813 .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
814 bytes
815 }
816 None => std::fs::metadata(archive)?.len(),
817 };
818 Ok(ImportOutcome {
819 transfer: TransferOutcome {
820 actions: actions.len() as u64,
821 objects: closure.objects.len() as u64,
822 bytes,
823 },
824 attachments: manifest.attachments,
825 })
826}
827
828fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
829 if additions
830 .attachments
831 .keys()
832 .any(|name| !valid_attachment_name(name))
833 || additions
834 .attachments
835 .values()
836 .any(|digest| !additions.objects.contains(digest))
837 || additions
838 .objects
839 .iter()
840 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
841 {
842 eyre::bail!("invalid cache export attachment");
843 }
844 Ok(())
845}
846
847fn valid_attachment_name(name: &str) -> bool {
848 !name.is_empty()
849 && name.len() <= 128
850 && name
851 .bytes()
852 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
853}
854
855#[derive(Default)]
857struct Closure {
858 objects: BTreeSet<PathBuf>,
859 results: BTreeSet<PathBuf>,
860 pending: Vec<PendingObject>,
861}
862
863struct PendingObject {
865 digest: CacheDigest,
866 path: PathBuf,
867}
868
869fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
870 let cas = LocalCas::new(store);
871 let action_cache = mbx_cache_core::LocalActionCache::new(store);
872 let mut closure = Closure::default();
873 let mut directories = BTreeSet::new();
874 for action in actions {
875 let result = action_cache
876 .find(action)?
877 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
878 closure.results.insert(action_cache.path_for(action)?);
879 extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
880 }
881 Ok(closure)
882}
883
884fn extend_result_closure(
887 cas: &LocalCas,
888 closure: &mut Closure,
889 directories: &mut BTreeSet<CacheDigest>,
890 result: &RemoteActionResult,
891 verify_descriptors: bool,
892) -> Result<()> {
893 let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
894 if verify_descriptors {
895 require_parsed_object(cas, closure, digest)
896 } else {
897 require_object(cas, closure, digest)?;
898 cas.path_for(digest)
899 }
900 };
901 require_object(cas, closure, &result.action)?;
902 if let Some(metadata) = &result.metadata {
903 let path = parsed_object(closure, metadata)?;
904 let captured: CapturedOutput =
905 serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
906 require_object(cas, closure, &captured.stdout)?;
907 require_object(cas, closure, &captured.stderr)?;
908 }
909 if let Some(root) = &result.output_root {
910 let mut nodes = vec![root.clone()];
911 while let Some(digest) = nodes.pop() {
912 if !directories.insert(digest.clone()) {
913 continue;
914 }
915 let path = parsed_object(closure, &digest)?;
916 let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
917 .wrap_err("output directory is invalid")?;
918 for file in directory.files {
919 require_object(cas, closure, &file.digest)?;
920 }
921 for child in directory.directories {
922 nodes.push(child.digest);
923 }
924 }
925 }
926 Ok(())
927}
928
929fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
937 if pending.is_empty() {
938 return Ok(());
939 }
940 pending.sort_by(|left, right| {
944 right
945 .digest
946 .size
947 .cmp(&left.digest.size)
948 .then_with(|| left.path.cmp(&right.path))
949 });
950 let workers = std::thread::available_parallelism()
951 .map(usize::from)
952 .unwrap_or(1)
953 .min(pending.len());
954 let next = AtomicUsize::new(0);
955 let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
958 let pending: &[PendingObject] = pending;
959 std::thread::scope(|scope| {
960 for _ in 0..workers {
961 scope.spawn(|| {
962 loop {
963 let index = next.fetch_add(1, Ordering::Relaxed);
964 let Some(entry) = pending.get(index) else {
965 break;
966 };
967 let error = match entry.digest.matches_file(&entry.path) {
968 Ok(true) => continue,
969 Ok(false) => eyre::eyre!(
970 "local CAS blob failed digest verification: {}",
971 entry.path.display()
972 ),
973 Err(error) => {
974 error.wrap_err(format!("failed to verify {}", entry.path.display()))
975 }
976 };
977 let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
978 if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
979 *failure = Some((entry.path.clone(), error));
980 }
981 }
982 });
983 }
984 });
985 match failure
986 .into_inner()
987 .unwrap_or_else(|error| error.into_inner())
988 {
989 Some((_, error)) => Err(error),
990 None => Ok(()),
991 }
992}
993
994#[derive(Deserialize)]
995struct CapturedOutput {
996 stdout: CacheDigest,
997 stderr: CacheDigest,
998}
999
1000fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1001 let identity = imported.task.clone();
1002 let destination_path = task_manifest_path(destination, &identity);
1003 let lock_path = task_manifest_lock_path(destination, &identity);
1004 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1005 let mut lock = fslock::LockFile::open(&lock_path)?;
1006 lock.lock()?;
1007 if let Ok(bytes) = std::fs::read(&destination_path)
1008 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1009 && existing.task == identity
1010 && existing.validate()
1011 {
1012 let existing_predictions: BTreeMap<_, _> = existing
1013 .predictions
1014 .iter()
1015 .cloned()
1016 .map(|prediction| (prediction.invocation.clone(), prediction))
1017 .collect();
1018 let updates = imported
1021 .predictions
1022 .into_iter()
1023 .map(|prediction| {
1024 existing_predictions
1025 .get(&prediction.invocation)
1026 .cloned()
1027 .unwrap_or(prediction)
1028 })
1029 .collect();
1030 imported.predictions =
1031 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1032 }
1033 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1034}
1035
1036fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1043 let expected = cas.path_for(digest)?;
1044 if !closure.objects.insert(expected.clone()) {
1045 return Ok(());
1046 }
1047 let metadata = match std::fs::metadata(&expected) {
1048 Ok(metadata) => metadata,
1049 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1050 eyre::bail!("cache object is missing for {}", digest.hash)
1051 }
1052 Err(error) => {
1053 return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1054 }
1055 };
1056 if metadata.len() != digest.size {
1057 eyre::bail!(
1058 "local CAS blob failed digest verification: {}",
1059 expected.display()
1060 );
1061 }
1062 closure.pending.push(PendingObject {
1063 digest: digest.clone(),
1064 path: expected,
1065 });
1066 Ok(())
1067}
1068
1069fn require_parsed_object(
1076 cas: &LocalCas,
1077 closure: &mut Closure,
1078 digest: &CacheDigest,
1079) -> Result<PathBuf> {
1080 let path = cas
1081 .find(digest)?
1082 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1083 closure.objects.insert(path.clone());
1084 Ok(path)
1085}
1086
1087fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1088 store
1089 .join("task-manifests/v1")
1090 .join(format!("{identity}.json"))
1091}
1092
1093fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1094 store
1095 .join("task-manifests/v1/locks")
1096 .join(format!("{identity}.lock"))
1097}
1098
1099fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1100 let name = path.strip_prefix(root)?;
1101 builder.append_path_with_name(path, name)?;
1102 Ok(())
1103}
1104
1105fn append_bytes(
1106 builder: &mut tar::Builder<std::fs::File>,
1107 name: &Path,
1108 bytes: &[u8],
1109) -> Result<()> {
1110 let mut header = tar::Header::new_gnu();
1111 header.set_size(bytes.len() as u64);
1112 header.set_mode(0o644);
1113 header.set_cksum();
1114 builder.append_data(&mut header, name, bytes)?;
1115 Ok(())
1116}
1117
1118fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1120 let file = std::fs::File::open(archive)
1121 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1122 let mut bundle = tar::Archive::new(file);
1123 let mut seen = BTreeSet::new();
1124 for entry in bundle.entries()? {
1125 let mut entry = entry?;
1126 let entry_type = entry.header().entry_type();
1127 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1128 eyre::bail!("cache export contains a non-file entry");
1129 }
1130 let path = entry.path()?.into_owned();
1131 validate_archive_path(&path)?;
1132 if !seen.insert(path.clone()) {
1133 eyre::bail!("cache export contains duplicate entry {}", path.display());
1134 }
1135 let destination = staging.join(&path);
1136 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1137 entry.unpack(&destination)?;
1141 }
1142 Ok(())
1143}
1144
1145fn validate_directory_bundle(root: &Path) -> Result<()> {
1153 let mut pending = vec![root.to_path_buf()];
1154 while let Some(directory) = pending.pop() {
1155 for entry in std::fs::read_dir(&directory)
1156 .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1157 {
1158 let entry = entry?;
1159 let metadata = entry.metadata()?;
1160 let path = entry.path();
1161 if metadata.is_dir() {
1162 pending.push(path);
1163 continue;
1164 }
1165 if !metadata.is_file() {
1166 eyre::bail!("cache export contains a non-file entry");
1167 }
1168 validate_archive_path(path.strip_prefix(root)?)?;
1169 reject_linked_file(&metadata, &path)?;
1170 }
1171 }
1172 Ok(())
1173}
1174
1175#[cfg(unix)]
1182fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1183 use std::os::unix::fs::MetadataExt as _;
1184
1185 if metadata.nlink() > 1 {
1186 eyre::bail!("cache export contains a hard link at {}", path.display());
1187 }
1188 Ok(())
1189}
1190
1191#[cfg(not(unix))]
1197fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1198 Ok(())
1199}
1200
1201fn validate_archive_path(path: &Path) -> Result<()> {
1202 if path.as_os_str().is_empty()
1203 || path.is_absolute()
1204 || path
1205 .components()
1206 .any(|component| !matches!(component, Component::Normal(_)))
1207 {
1208 eyre::bail!("cache export contains unsafe path {}", path.display());
1209 }
1210 let parts = path
1215 .components()
1216 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1217 .collect::<Vec<_>>();
1218 let expected = match parts.as_slice() {
1219 [name] => name == EXPORT_MANIFEST,
1220 [root, version, _rest @ ..] => {
1221 parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1222 }
1223 [] => false,
1224 };
1225 if !expected {
1226 eyre::bail!("cache export contains unexpected path {}", path.display());
1227 }
1228 Ok(())
1229}
1230
1231#[derive(Debug, Clone, Serialize, Deserialize)]
1238#[serde(deny_unknown_fields)]
1239struct CheckoutRecord {
1240 version: u8,
1241 workspace_root: PathBuf,
1242 target_dir: PathBuf,
1243 updated_secs: u64,
1244}
1245
1246pub fn stats(store: &Path) -> Result<StoreStats> {
1248 let objects = walk_files(&store.join(CAS_DIR))?;
1249 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1250 let checkouts = scan_checkouts(store)?;
1251 Ok(StoreStats {
1252 objects: objects.len() as u64,
1253 object_bytes: objects.iter().map(|entry| entry.size).sum(),
1254 action_results: results.len() as u64,
1255 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1256 live_checkouts: checkouts.live_records,
1257 stale_checkouts: checkouts.stale_records.len() as u64,
1258 })
1259}
1260
1261fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1268 if record.target_dir == record.workspace_root {
1269 return 0;
1270 }
1271 cached_tree_bytes(sizes, &record.target_dir)
1272}
1273
1274pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1280 project_usage(store, true)
1281}
1282
1283pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1289 Ok(project_usage(store, false)?
1290 .into_iter()
1291 .filter(|project| project.live)
1292 .map(|project| project.action_bytes)
1293 .collect())
1294}
1295
1296fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1297 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1298 let mut target_sizes = BTreeMap::new();
1299 let root = store.join(CHECKOUTS_DIR);
1300 for identity_entry in read_dir_or_empty(&root)? {
1301 let identity = identity_entry.file_name().to_string_lossy().into_owned();
1302 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1303 continue;
1304 }
1305 for entry in walk_files(&identity_entry.path())? {
1306 let Some(record) = read_checkout_record(&entry.path) else {
1307 continue;
1308 };
1309 let project = projects.entry(record.workspace_root.clone()).or_default();
1310 if claim_is_live(store, &record) {
1311 project.0.insert(identity.clone());
1312 project.1 = true;
1313 }
1314 if size_targets {
1315 project.2 = project
1316 .2
1317 .max(checkout_target_bytes(&mut target_sizes, &record));
1318 }
1319 }
1320 }
1321 let live = projects
1322 .values()
1323 .flat_map(|(identities, _, _)| identities.iter().cloned())
1324 .collect::<BTreeSet<_>>();
1325 let mut reachability = Reachability::new(store);
1326 resolve_identities(&reachability, &live);
1327 let mut usages = Vec::new();
1328 for (workspace_root, (identities, live, target_bytes)) in projects {
1329 let action_bytes = reachability.bytes(&identities);
1330 usages.push(ProjectUsage {
1331 workspace_root,
1332 identities: identities.len() as u64,
1333 action_bytes,
1334 target_bytes,
1335 live,
1336 });
1337 }
1338 usages.sort_by(|left, right| {
1339 right
1340 .action_bytes
1341 .saturating_add(right.target_bytes)
1342 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1343 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1344 });
1345 Ok(usages)
1346}
1347
1348fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1356 const MAX_WORKERS: usize = 8;
1357 let identities = identities.iter().collect::<Vec<_>>();
1358 let workers = std::thread::available_parallelism()
1359 .map(usize::from)
1360 .unwrap_or(1)
1361 .min(MAX_WORKERS)
1362 .min(identities.len());
1363 let next = AtomicUsize::new(0);
1364 std::thread::scope(|scope| {
1365 for _ in 0..workers {
1366 scope.spawn(|| {
1367 while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1368 let actions = task_manifest_actions(&reachability.store, identity)
1372 .unwrap_or_else(|error| {
1373 log::debug!("could not read the manifest for {identity}: {error}");
1374 Vec::new()
1375 });
1376 reachability.record(identity, &actions);
1377 }
1378 });
1379 }
1380 });
1381}
1382
1383struct Reachability {
1396 store: PathBuf,
1397 cas: LocalCas,
1398 action_cache: mbx_cache_core::LocalActionCache,
1399 paths: Mutex<PathIndex>,
1401 identities: Mutex<HashMap<String, ActionPaths>>,
1402 actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1403 directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1405 counted: Vec<usize>,
1407 workspaces: usize,
1408}
1409
1410#[derive(Default)]
1411struct PathIndex {
1412 indices: HashMap<PathBuf, usize>,
1413 sizes: Vec<u64>,
1414}
1415
1416type ActionPaths = Arc<[Arc<[usize]>]>;
1418
1419type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1420
1421fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1424 mutex.lock().unwrap_or_else(|error| error.into_inner())
1425}
1426
1427impl Reachability {
1428 fn new(store: &Path) -> Self {
1429 Self {
1430 store: store.to_path_buf(),
1431 cas: LocalCas::new(store),
1432 action_cache: mbx_cache_core::LocalActionCache::new(store),
1433 paths: Mutex::default(),
1434 identities: Mutex::default(),
1435 actions: Mutex::default(),
1436 directories: Mutex::default(),
1437 counted: Vec::new(),
1438 workspaces: 0,
1439 }
1440 }
1441
1442 fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1446 let reachable = {
1447 let recorded = locked(&self.identities);
1448 identities
1449 .iter()
1450 .filter_map(|identity| recorded.get(identity).cloned())
1451 .collect::<Vec<_>>()
1452 };
1453 let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1454 self.workspaces += 1;
1455 self.counted.resize(sizes.len(), 0);
1456 let mut bytes = 0_u64;
1457 for index in reachable
1458 .iter()
1459 .flat_map(|actions| actions.iter())
1460 .flat_map(|paths| paths.iter())
1461 {
1462 if self.counted[*index] != self.workspaces {
1463 self.counted[*index] = self.workspaces;
1464 bytes = bytes.saturating_add(sizes[*index]);
1465 }
1466 }
1467 locked(&self.paths).sizes = sizes;
1468 bytes
1469 }
1470
1471 fn record(&self, identity: &str, actions: &[CacheDigest]) {
1476 let actions = actions
1477 .iter()
1478 .map(|action| self.action(action))
1479 .collect::<Arc<[_]>>();
1480 locked(&self.identities).insert(identity.to_string(), actions);
1481 }
1482
1483 fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1484 if let Some(paths) = locked(&self.actions).get(action) {
1485 return paths.clone();
1486 }
1487 let mut paths = Vec::new();
1488 if let Ok(path) = self.action_cache.path_for(action) {
1489 paths.push(self.index(path));
1490 }
1491 if let Some(result) = read_action_result(&self.store, action) {
1492 self.push_digest(&mut paths, &result.action);
1493 if let Some(metadata) = &result.metadata {
1494 self.push_digest(&mut paths, metadata);
1495 if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1496 self.push_digest(&mut paths, &rustc.stdout);
1497 self.push_digest(&mut paths, &rustc.stderr);
1498 }
1499 }
1500 if let Some(output_root) = &result.output_root {
1501 self.push_digest(&mut paths, output_root);
1502 let mut pending = vec![output_root.clone()];
1503 let mut visited = HashSet::new();
1504 while let Some(digest) = pending.pop() {
1505 if !visited.insert(digest.clone()) {
1506 continue;
1507 }
1508 let Some((children, named)) = self.directory(&digest) else {
1509 continue;
1510 };
1511 paths.extend(named.iter().copied());
1512 pending.extend(children.iter().cloned());
1513 }
1514 }
1515 }
1516 paths.sort_unstable();
1517 paths.dedup();
1518 let paths = Arc::<[usize]>::from(paths);
1519 locked(&self.actions).insert(action.clone(), paths.clone());
1520 paths
1521 }
1522
1523 fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1525 if let Some(entry) = locked(&self.directories).get(digest) {
1526 return entry.clone();
1527 }
1528 let entry = read_directory(&self.cas, digest).map(|directory| {
1529 let mut paths = Vec::new();
1530 for file in &directory.files {
1531 self.push_digest(&mut paths, &file.digest);
1532 }
1533 for child in &directory.directories {
1534 self.push_digest(&mut paths, &child.digest);
1535 }
1536 let children = directory
1537 .directories
1538 .into_iter()
1539 .map(|child| child.digest)
1540 .collect::<Arc<[_]>>();
1541 (children, Arc::<[usize]>::from(paths))
1542 });
1543 locked(&self.directories).insert(digest.clone(), entry.clone());
1544 entry
1545 }
1546
1547 fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1548 if let Ok(path) = self.cas.path_for(digest) {
1549 paths.push(self.index(path));
1550 }
1551 }
1552
1553 fn index(&self, path: PathBuf) -> usize {
1554 if let Some(index) = locked(&self.paths).indices.get(&path) {
1555 return *index;
1556 }
1557 let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1560 let mut paths = locked(&self.paths);
1561 if let Some(index) = paths.indices.get(&path) {
1562 return *index;
1563 }
1564 let index = paths.sizes.len();
1565 paths.sizes.push(size);
1566 paths.indices.insert(path, index);
1567 index
1568 }
1569}
1570
1571pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1573 let mut entries = Vec::new();
1574 for (kind, root, action_result) in [
1575 ("object", store.join(CAS_DIR), false),
1576 ("action result", store.join(ACTION_RESULTS_DIR), true),
1577 ] {
1578 entries.extend(
1579 walk_files(&root)?
1580 .into_iter()
1581 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1582 .map(|entry| LargestEntry {
1583 kind,
1584 path: entry.path,
1585 bytes: entry.size,
1586 }),
1587 );
1588 }
1589 entries.sort_by(|left, right| {
1590 right
1591 .bytes
1592 .cmp(&left.bytes)
1593 .then_with(|| left.path.cmp(&right.path))
1594 });
1595 entries.truncate(limit);
1596 Ok(entries)
1597}
1598
1599pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1601 let cas = LocalCas::new(store);
1602 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1603 let virtually_removed = HashSet::new();
1606 let mut outcome = VerifyOutcome::default();
1607 let mut trees = HashMap::new();
1608 for entry in walk_files(&store.join(CAS_DIR))? {
1609 let Some(digest) = addressed_digest(store, &entry.path, false) else {
1610 continue;
1611 };
1612 outcome.checked_objects += 1;
1613 if !matches!(cas.find(&digest), Ok(Some(_))) {
1614 outcome.problems.push(entry.path);
1615 }
1616 }
1617 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1618 let Some(digest) = addressed_digest(store, &entry.path, true) else {
1619 continue;
1620 };
1621 outcome.checked_action_results += 1;
1622 if !matches!(action_cache.find(&digest), Ok(Some(_)))
1623 || action_result_is_dangling(&cas, &entry.path, &virtually_removed, true, &mut trees)?
1624 {
1625 outcome.problems.push(entry.path);
1626 }
1627 }
1628 outcome.problems.sort();
1629 Ok(outcome)
1630}
1631
1632pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1634 let mut outcome = RemoveProjectOutcome::default();
1635 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1636 if read_checkout_record(&entry.path)
1637 .is_some_and(|record| record.workspace_root == workspace_root)
1638 {
1639 std::fs::remove_file(&entry.path)?;
1640 outcome.removed_checkout_records += 1;
1641 if let Some(parent) = entry.path.parent() {
1642 let _ = std::fs::remove_dir(parent);
1643 }
1644 }
1645 }
1646 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1647 Ok(()) => {}
1648 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1649 Err(error) => return Err(error.into()),
1650 }
1651 Ok(outcome)
1652}
1653
1654pub fn record_checkout(
1666 store: &Path,
1667 identity: &str,
1668 workspace_root: &Path,
1669 target_dir: &Path,
1670) -> Result<()> {
1671 let record = CheckoutRecord {
1672 version: CHECKOUT_RECORD_VERSION,
1673 workspace_root: workspace_root.to_path_buf(),
1674 target_dir: target_dir.to_path_buf(),
1675 updated_secs: SystemTime::now()
1676 .duration_since(UNIX_EPOCH)
1677 .map(|since| since.as_secs())
1678 .unwrap_or_default(),
1679 };
1680 let mut contents = serde_json::to_vec(&record)?;
1681 contents.push(b'\n');
1682 write_advisory(
1685 &checkout_record_path(store, identity, workspace_root),
1686 &contents,
1687 )
1688}
1689
1690fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1691 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1695 store
1696 .join(CHECKOUTS_DIR)
1697 .join(identity)
1698 .join(format!("{key}.json"))
1699}
1700
1701pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1711 gc_with_mode(store, max_bytes, false, HashSet::new())
1712}
1713
1714pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1716 gc_with_mode(store, max_bytes, true, HashSet::new())
1717}
1718
1719#[cfg(test)]
1720type DanglingReadHook = Box<dyn FnMut(&Path)>;
1721
1722#[cfg(test)]
1723thread_local! {
1724 static ACTION_RECORD_CLAIMS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1725 static AFTER_DANGLING_READ: std::cell::RefCell<Option<DanglingReadHook>> =
1727 const { std::cell::RefCell::new(None) };
1728}
1729
1730fn gc_with_mode(
1733 store: &Path,
1734 max_bytes: u64,
1735 dry_run: bool,
1736 already_removed: HashSet<PathBuf>,
1737) -> Result<GcOutcome> {
1738 let mut objects = walk_files(&store.join(CAS_DIR))?;
1739 let mut results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1740 objects.retain(|entry| !already_removed.contains(&entry.path));
1741 results.retain(|entry| !already_removed.contains(&entry.path));
1742 let mut live_bytes = objects
1743 .iter()
1744 .chain(results.iter())
1745 .map(|entry| entry.size)
1746 .sum::<u64>();
1747
1748 let mut outcome = GcOutcome::default();
1749
1750 let checkouts = scan_checkouts(store)?;
1753 for path in &checkouts.stale_records {
1754 if dry_run || matches!(remove(path)?, Removal::Removed) {
1755 outcome.removed_checkout_records += 1;
1756 }
1757 if !dry_run && let Some(parent) = path.parent() {
1760 let _ = std::fs::remove_dir(parent);
1761 }
1762 }
1763
1764 let mut virtually_removed = already_removed;
1765 if live_bytes > max_bytes {
1766 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1770 rooted.extend(rooted_action_objects(
1777 store,
1778 grouped_receipt_actions(store)?,
1779 ));
1780 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1784 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1785 if dry_run {
1786 virtually_removed.insert(path.to_path_buf());
1787 Ok(Removal::Removed)
1788 } else {
1789 remove(path)
1790 }
1791 })?;
1792 live_bytes = evicted.remaining_bytes;
1793 outcome.removed_objects += evicted.removed_objects;
1794 outcome.removed_bytes += evicted.removed_bytes;
1795 }
1796
1797 let walk_trees = outcome.removed_objects > 0;
1810 let cas = LocalCas::new(store);
1811 let mut trees = HashMap::new();
1812 for entry in &results {
1813 match action_result_is_dangling(
1814 &cas,
1815 &entry.path,
1816 &virtually_removed,
1817 walk_trees,
1818 &mut trees,
1819 ) {
1820 Ok(false) => continue,
1821 Ok(true) => {}
1822 Err(error)
1824 if error
1825 .chain()
1826 .filter_map(|cause| cause.downcast_ref::<std::io::Error>())
1827 .any(held_by_another_process) =>
1828 {
1829 log::debug!(
1830 "could not inspect {} for eviction: {error}",
1831 entry.path.display()
1832 );
1833 continue;
1834 }
1835 Err(error) => return Err(error),
1836 }
1837 let removal = if dry_run {
1838 Removal::Removed
1839 } else {
1840 #[cfg(test)]
1841 AFTER_DANGLING_READ.with(|hook| {
1842 if let Some(hook) = hook.borrow_mut().as_mut() {
1843 hook(&entry.path);
1844 }
1845 });
1846 #[cfg(test)]
1847 ACTION_RECORD_CLAIMS.with(|count| count.set(count.get() + 1));
1848 match ActionSlot::try_claim(&entry.path) {
1849 Err(error) if held_by_another_process(&error) => {
1850 log::debug!(
1851 "could not claim {} for eviction: {error}",
1852 entry.path.display()
1853 );
1854 Removal::Blocked
1855 }
1856 Err(error) => return Err(error.into()),
1857 Ok(None) => Removal::Blocked,
1858 Ok(Some(slot)) => match slot.read()? {
1859 None => Removal::Missing,
1860 Some(bytes) => {
1861 if !action_result_bytes_are_dangling(
1864 &cas,
1865 &bytes,
1866 &virtually_removed,
1867 walk_trees,
1868 &mut HashMap::new(),
1869 )? {
1870 continue;
1871 }
1872 match slot.remove() {
1873 Ok(true) => Removal::Removed,
1874 Ok(false) => Removal::Missing,
1875 Err(error) if held_by_another_process(&error) => {
1878 log::debug!("could not evict {}: {error}", entry.path.display());
1879 Removal::Blocked
1880 }
1881 Err(error) => {
1882 return Err(error).wrap_err_with(|| {
1883 format!("failed to evict {}", entry.path.display())
1884 });
1885 }
1886 }
1887 }
1888 },
1889 }
1890 };
1891 match removal {
1892 Removal::Removed => {
1893 live_bytes = live_bytes.saturating_sub(entry.size);
1894 outcome.removed_action_results += 1;
1895 outcome.removed_bytes += entry.size;
1896 }
1897 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1898 Removal::Blocked => {}
1899 }
1900 }
1901
1902 let sessions = prune_sessions(store, dry_run)?;
1903 outcome.removed_session_streams += sessions.removed_streams;
1904 outcome.removed_bytes += sessions.removed_bytes;
1905 prune_import_staging(store, dry_run);
1906
1907 outcome.remaining_bytes = live_bytes;
1908 Ok(outcome)
1909}
1910
1911pub fn gc_stale_compilers(store: &Path, max_age: Duration, dry_run: bool) -> Result<GcOutcome> {
1925 stale_compilers_pass(store, max_age, dry_run).map(|(outcome, _)| outcome)
1926}
1927
1928pub fn gc_dry_run_after_stale(
1931 store: &Path,
1932 max_bytes: u64,
1933 max_age: Duration,
1934) -> Result<GcOutcome> {
1935 let (mut stale, planned) = stale_compilers_pass(store, max_age, true)?;
1936 let budget = gc_with_mode(store, max_bytes, true, planned)?;
1937 stale.removed_objects += budget.removed_objects;
1938 stale.removed_action_results += budget.removed_action_results;
1939 stale.removed_checkout_records += budget.removed_checkout_records;
1940 stale.removed_session_streams += budget.removed_session_streams;
1941 stale.removed_bytes += budget.removed_bytes;
1942 stale.remaining_bytes = budget.remaining_bytes;
1943 Ok(stale)
1944}
1945
1946fn stale_compilers_pass(
1948 store: &Path,
1949 max_age: Duration,
1950 dry_run: bool,
1951) -> Result<(GcOutcome, HashSet<PathBuf>)> {
1952 struct Attributed {
1953 entry: Entry,
1954 action: CacheDigest,
1955 }
1956
1957 let cas = LocalCas::new(store);
1958 let mut by_version: BTreeMap<String, Vec<Attributed>> = BTreeMap::new();
1959 let mut unattributed = Vec::new();
1960 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1961 let result = read_result_untouched(&entry.path)
1962 .ok()
1963 .and_then(|bytes| serde_json::from_slice::<RemoteActionResult>(&bytes).ok());
1964 let Some(result) = result else {
1965 continue;
1966 };
1967 match rustc_version_of(&cas, &result.action) {
1968 Some(version) => by_version.entry(version).or_default().push(Attributed {
1969 entry,
1970 action: result.action,
1971 }),
1972 None => unattributed.push(result.action),
1973 }
1974 }
1975
1976 let last_used = |results: &[Attributed]| results.iter().map(|r| r.entry.used).max();
1977 let newest = by_version
1978 .iter()
1979 .max_by_key(|(_, results)| last_used(results))
1980 .map(|(version, _)| version.clone());
1981 let cutoff = SystemTime::now()
1982 .checked_sub(max_age)
1983 .unwrap_or(SystemTime::UNIX_EPOCH);
1984 let mut outcome = GcOutcome::default();
1985 let mut stale = Vec::new();
1986 let mut kept_actions = unattributed;
1987 for (version, results) in by_version {
1988 let idle = last_used(&results).is_some_and(|used| used < cutoff);
1989 if idle && Some(&version) != newest.as_ref() {
1990 stale.extend(results);
1991 } else {
1992 kept_actions.extend(results.into_iter().map(|r| r.action));
1993 }
1994 }
1995 let pending = grouped_receipt_actions(store)?;
1998 let (pinned, stale): (Vec<_>, Vec<_>) =
1999 stale.into_iter().partition(|r| pending.contains(&r.action));
2000 kept_actions.extend(pinned.into_iter().map(|r| r.action));
2001 let mut planned = HashSet::new();
2002 if stale.is_empty() {
2003 return Ok((outcome, planned));
2004 }
2005
2006 let doomed = rooted_action_objects(store, stale.iter().map(|r| r.action.clone()));
2007 let kept = rooted_action_objects(store, kept_actions);
2008 for result in &stale {
2009 if dry_run || matches!(remove(&result.entry.path)?, Removal::Removed) {
2010 outcome.removed_action_results += 1;
2011 outcome.removed_bytes += result.entry.size;
2012 planned.insert(result.entry.path.clone());
2013 }
2014 }
2015 for path in doomed.difference(&kept) {
2016 let Ok(metadata) = std::fs::metadata(path) else {
2017 continue;
2018 };
2019 if dry_run || matches!(remove(path)?, Removal::Removed) {
2020 outcome.removed_objects += 1;
2021 outcome.removed_bytes += metadata.len();
2022 planned.insert(path.clone());
2023 }
2024 }
2025 Ok((outcome, planned))
2026}
2027
2028fn read_result_untouched(path: &Path) -> std::io::Result<Vec<u8>> {
2038 #[cfg(target_os = "linux")]
2039 {
2040 use std::io::Read as _;
2041 use std::os::unix::fs::OpenOptionsExt as _;
2042 if let Ok(mut file) = std::fs::OpenOptions::new()
2043 .read(true)
2044 .custom_flags(libc::O_NOATIME)
2045 .open(path)
2046 {
2047 let mut bytes = Vec::new();
2048 file.read_to_end(&mut bytes)?;
2049 return Ok(bytes);
2050 }
2051 }
2052 let before = std::fs::metadata(path)?;
2053 let bytes = std::fs::read(path)?;
2054 let _ = filetime::set_file_atime(path, filetime::FileTime::from_last_access_time(&before));
2055 Ok(bytes)
2056}
2057
2058fn rustc_version_of(cas: &LocalCas, action: &CacheDigest) -> Option<String> {
2060 let bytes = read_result_untouched(&cas.path_for(action).ok()?).ok()?;
2061 let descriptor: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2062 if descriptor.get("kind")?.as_str()? != "rustc" {
2063 return None;
2064 }
2065 Some(
2066 descriptor
2067 .pointer("/compiler/rustc_version")?
2068 .as_str()?
2069 .to_owned(),
2070 )
2071}
2072
2073fn prune_import_staging(store: &Path, dry_run: bool) {
2085 if dry_run {
2086 return;
2087 }
2088 let root = store.join(IMPORT_STAGING_DIR);
2089 let Ok(entries) = read_dir_or_empty(&root) else {
2090 return;
2091 };
2092 for entry in entries {
2093 let Ok(metadata) = entry.metadata() else {
2094 continue;
2095 };
2096 let abandoned = metadata
2097 .modified()
2098 .ok()
2099 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
2100 .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
2101 if !abandoned {
2102 continue;
2103 }
2104 let path = entry.path();
2105 if !metadata.is_dir() {
2106 if path
2109 .extension()
2110 .is_some_and(|extension| extension == "lock")
2111 && !claimed_staging_path(&path).exists()
2112 {
2113 let _ = std::fs::remove_file(&path);
2114 }
2115 continue;
2116 }
2117 let lock_path = import_staging_lock_path(&path);
2123 let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
2124 continue;
2125 };
2126 if !matches!(lock.try_lock(), Ok(true)) {
2127 continue;
2128 }
2129 if let Err(error) = std::fs::remove_dir_all(&path) {
2130 log::debug!(
2131 "could not remove abandoned import staging {}: {error}",
2132 path.display()
2133 );
2134 continue;
2135 }
2136 drop(lock);
2137 let _ = std::fs::remove_file(&lock_path);
2138 }
2139}
2140
2141fn claimed_staging_path(lock: &Path) -> PathBuf {
2143 lock.with_extension("")
2144}
2145
2146fn import_staging_lock_path(staging: &Path) -> PathBuf {
2151 let mut name = staging.as_os_str().to_os_string();
2152 name.push(".lock");
2153 PathBuf::from(name)
2154}
2155
2156struct StagingClaim {
2162 lock: Option<fslock::LockFile>,
2163 path: PathBuf,
2164}
2165
2166impl Drop for StagingClaim {
2167 fn drop(&mut self) {
2168 drop(self.lock.take());
2171 let _ = std::fs::remove_file(&self.path);
2172 }
2173}
2174
2175fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
2177 let path = import_staging_lock_path(staging);
2178 let mut lock = fslock::LockFile::open(&path)
2179 .wrap_err_with(|| format!("failed to open {}", path.display()))?;
2180 lock.lock()?;
2181 Ok(StagingClaim {
2182 lock: Some(lock),
2183 path,
2184 })
2185}
2186
2187#[derive(Debug, Default, PartialEq, Eq)]
2188struct SessionPrune {
2189 removed_streams: u64,
2190 removed_bytes: u64,
2191}
2192
2193fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
2194 let mut prune = SessionPrune::default();
2195 let ids = events::session_ids(store);
2196 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
2197 for (index, id) in ids.iter().enumerate() {
2198 let paths = events::session_paths(store, id);
2199 let metadata = match std::fs::metadata(&paths.events) {
2200 Ok(metadata) => metadata,
2201 Err(_) => continue,
2202 };
2203 let stale = metadata
2204 .modified()
2205 .ok()
2206 .and_then(|at| SystemTime::now().duration_since(at).ok())
2207 .is_some_and(|age| age > SESSION_RETENTION);
2208 if index >= surplus && !stale {
2209 continue;
2210 }
2211 if events::session_is_live(store, id) {
2212 continue;
2213 }
2214 if dry_run {
2215 prune.removed_streams += 1;
2216 prune.removed_bytes += metadata.len();
2217 continue;
2218 }
2219 if matches!(remove(&paths.events)?, Removal::Removed) {
2220 prune.removed_streams += 1;
2221 prune.removed_bytes += metadata.len();
2222 let _ = std::fs::remove_file(&paths.lock);
2223 }
2224 }
2225 if !dry_run {
2226 for lock in events::orphaned_locks(store) {
2227 let _ = std::fs::remove_file(lock);
2228 }
2229 }
2230 Ok(prune)
2231}
2232
2233pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
2240 if !claim_sweep(store, interval)? {
2241 return Ok(None);
2242 }
2243 gc(store, max_bytes).map(Some)
2244}
2245
2246pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
2255 let lock_path = store.join(SWEEP_LOCK);
2256 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
2257 let mut lock = fslock::LockFile::open(&lock_path)?;
2258 lock.lock()?;
2259
2260 let stamp = store.join(SWEEP_STAMP);
2261 if sweep_stamp_is_fresh(&stamp, interval) {
2262 return Ok(false);
2263 }
2264 record_sweep(store)?;
2265 Ok(true)
2266}
2267
2268pub fn record_sweep(store: &Path) -> Result<()> {
2274 let path = store.join(SWEEP_STAMP);
2275 std::fs::create_dir_all(path.parent().expect("the sweep stamp has a parent"))?;
2276 let stamp = std::fs::OpenOptions::new()
2277 .create(true)
2278 .write(true)
2279 .truncate(false)
2280 .open(path)?;
2281 stamp.set_modified(SystemTime::now())?;
2282 Ok(())
2283}
2284
2285pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2292 !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2293}
2294
2295fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2296 std::fs::metadata(stamp)
2297 .and_then(|metadata| metadata.modified())
2298 .ok()
2299 .and_then(|modified| modified.elapsed().ok())
2300 .is_some_and(|since| since < interval)
2301}
2302
2303struct CheckoutScan {
2305 live_identities: BTreeSet<String>,
2306 live_records: u64,
2307 stale_records: Vec<PathBuf>,
2308}
2309
2310fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2311 let root = store.join(CHECKOUTS_DIR);
2312 let mut scan = CheckoutScan {
2313 live_identities: BTreeSet::new(),
2314 live_records: 0,
2315 stale_records: Vec::new(),
2316 };
2317 let listing = match std::fs::read_dir(&root) {
2318 Ok(listing) => listing,
2319 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2320 Err(error) => {
2321 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2322 }
2323 };
2324 for entry in listing {
2325 let entry = entry?;
2326 let name = entry.file_name().to_string_lossy().into_owned();
2327 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2333 continue;
2334 }
2335 for record in walk_files(&entry.path())? {
2336 match read_checkout_record(&record.path) {
2337 Some(checkout) if claim_is_live(store, &checkout) => {
2338 scan.live_identities.insert(name.clone());
2339 scan.live_records += 1;
2340 }
2341 Some(_) => scan.stale_records.push(record.path),
2342 None => {}
2345 }
2346 }
2347 }
2348 Ok(scan)
2349}
2350
2351fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2352 let bytes = std::fs::read(path).ok()?;
2353 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2354 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2355}
2356
2357fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2359 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2360 Ok(walk_files(&root)?
2361 .into_iter()
2362 .filter_map(|entry| read_build_receipt(&entry.path))
2363 .flat_map(|receipt| receipt.predictions.into_iter())
2364 .map(|prediction| prediction.action)
2365 .collect())
2366}
2367
2368fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2371 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2372}
2373
2374fn claim_has_expired(record: &CheckoutRecord) -> bool {
2375 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2376 return false;
2377 };
2378 now.as_secs()
2379 .saturating_sub(record.updated_secs)
2380 .gt(&CHECKOUT_RETENTION.as_secs())
2381}
2382
2383pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2396 if !matches!(workspace_root.try_exists(), Ok(false)) {
2397 return true;
2398 }
2399
2400 let Ok(store_metadata) = std::fs::metadata(store) else {
2401 return true;
2402 };
2403 for ancestor in workspace_root.ancestors().skip(1) {
2404 match std::fs::metadata(ancestor) {
2405 Ok(ancestor_metadata) => {
2406 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2407 }
2408 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2409 Err(_) => return true,
2410 }
2411 }
2412 true
2413}
2414
2415pub fn checkout_is_live(workspace_root: &Path) -> bool {
2421 let Some(parent) = workspace_root.parent() else {
2422 return true;
2423 };
2424 checkout_is_live_on(parent, workspace_root)
2425}
2426
2427pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2433 let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2434 return false;
2435 };
2436 same_filesystem(a, &a_metadata, b, &b_metadata)
2437}
2438
2439#[cfg(unix)]
2440fn same_filesystem(
2441 _a_path: &Path,
2442 a: &std::fs::Metadata,
2443 _b_path: &Path,
2444 b: &std::fs::Metadata,
2445) -> bool {
2446 use std::os::unix::fs::MetadataExt as _;
2447 a.dev() == b.dev()
2448}
2449
2450#[cfg(windows)]
2451fn same_filesystem(
2452 a_path: &Path,
2453 _a: &std::fs::Metadata,
2454 b_path: &Path,
2455 _b: &std::fs::Metadata,
2456) -> bool {
2457 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2458}
2459
2460#[cfg(windows)]
2461fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2462 use std::os::windows::ffi::OsStrExt as _;
2463 use windows_sys::Win32::Storage::FileSystem::{
2464 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2465 };
2466
2467 let path = path
2468 .as_os_str()
2469 .encode_wide()
2470 .chain(Some(0))
2471 .collect::<Vec<_>>();
2472 let mut mount = vec![0_u16; 261];
2476 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2479 return None;
2480 }
2481 let mut volume = vec![0_u16; 261];
2482 if unsafe {
2485 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2486 } == 0
2487 {
2488 return None;
2489 }
2490 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2491 Some(volume)
2492}
2493
2494#[cfg(not(any(unix, windows)))]
2495fn same_filesystem(
2496 _a_path: &Path,
2497 _a: &std::fs::Metadata,
2498 _b_path: &Path,
2499 _b: &std::fs::Metadata,
2500) -> bool {
2501 false
2502}
2503
2504fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2512 let mut actions = BTreeSet::new();
2513 for identity in identities {
2514 let manifest_actions = match task_manifest_actions(store, identity) {
2517 Ok(actions) => actions,
2518 Err(error) => {
2519 log::debug!("could not read the manifest for {identity}: {error}");
2520 continue;
2521 }
2522 };
2523 actions.extend(manifest_actions);
2524 }
2525 Ok(rooted_action_objects(store, actions))
2526}
2527
2528fn rooted_action_objects(
2530 store: &Path,
2531 actions: impl IntoIterator<Item = CacheDigest>,
2532) -> HashSet<PathBuf> {
2533 let cas = LocalCas::new(store);
2534 let mut rooted = HashSet::new();
2535 let mut visited = BTreeSet::new();
2536 for action in actions {
2537 let Some(result) = read_action_result(store, &action) else {
2538 continue;
2539 };
2540 root_digest(&cas, &mut rooted, &result.action);
2541 if let Some(metadata) = &result.metadata {
2542 root_digest(&cas, &mut rooted, metadata);
2543 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2550 root_digest(&cas, &mut rooted, &rustc.stdout);
2551 root_digest(&cas, &mut rooted, &rustc.stderr);
2552 }
2553 }
2554 if let Some(output_root) = &result.output_root {
2555 root_digest(&cas, &mut rooted, output_root);
2556 root_tree(&cas, output_root, &mut rooted, &mut visited);
2557 }
2558 }
2559 rooted
2560}
2561
2562fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2563 let path = mbx_cache_core::LocalActionCache::new(store)
2564 .path_for(action)
2565 .ok()?;
2566 let bytes = read_result_untouched(&path).ok()?;
2567 serde_json::from_slice(&bytes).ok()
2568}
2569
2570fn root_tree(
2571 cas: &LocalCas,
2572 output_root: &CacheDigest,
2573 rooted: &mut HashSet<PathBuf>,
2574 visited: &mut BTreeSet<CacheDigest>,
2575) {
2576 let mut pending = vec![output_root.clone()];
2577 while let Some(digest) = pending.pop() {
2578 if !visited.insert(digest.clone()) {
2579 continue;
2580 }
2581 let Some(directory) = read_directory(cas, &digest) else {
2582 continue;
2583 };
2584 for file in &directory.files {
2585 root_digest(cas, rooted, &file.digest);
2586 }
2587 for child in &directory.directories {
2588 root_digest(cas, rooted, &child.digest);
2589 pending.push(child.digest.clone());
2590 }
2591 }
2592}
2593
2594fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2595 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2596 serde_json::from_slice(&bytes).ok()
2597}
2598
2599fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2600 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2601 serde_json::from_slice(&bytes).ok()
2602}
2603
2604fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2605 if let Ok(path) = cas.path_for(digest) {
2606 rooted.insert(path);
2607 }
2608}
2609
2610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2611enum Removal {
2612 Removed,
2613 Missing,
2614 Blocked,
2615}
2616
2617fn remove(path: &Path) -> Result<Removal> {
2618 match std::fs::remove_file(path) {
2619 Ok(()) => Ok(Removal::Removed),
2620 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2622 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2627 log::debug!("could not evict {}: {error}", path.display());
2628 Ok(Removal::Blocked)
2629 }
2630 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2631 }
2632}
2633
2634#[derive(Debug, Default, PartialEq, Eq)]
2635struct ObjectEvictions {
2636 removed_objects: u64,
2637 removed_bytes: u64,
2638 remaining_bytes: u64,
2639}
2640
2641fn evict_objects(
2644 objects: &[Entry],
2645 rooted: &HashSet<PathBuf>,
2646 mut live_bytes: u64,
2647 max_bytes: u64,
2648 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2649) -> Result<ObjectEvictions> {
2650 let mut outcome = ObjectEvictions::default();
2651 let mut blocked_unrooted = false;
2652 for entry in objects {
2653 if live_bytes <= max_bytes {
2654 break;
2655 }
2656 let is_rooted = rooted.contains(&entry.path);
2657 if is_rooted && blocked_unrooted {
2658 break;
2662 }
2663 match remove_file(&entry.path)? {
2664 Removal::Removed => {
2665 live_bytes = live_bytes.saturating_sub(entry.size);
2666 outcome.removed_objects += 1;
2667 outcome.removed_bytes += entry.size;
2668 }
2669 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2670 Removal::Blocked if !is_rooted => blocked_unrooted = true,
2671 Removal::Blocked => {}
2672 }
2673 }
2674 outcome.remaining_bytes = live_bytes;
2675 Ok(outcome)
2676}
2677
2678fn action_result_is_dangling(
2700 cas: &LocalCas,
2701 path: &Path,
2702 virtually_removed: &HashSet<PathBuf>,
2703 walk_tree: bool,
2704 trees: &mut HashMap<CacheDigest, bool>,
2705) -> Result<bool> {
2706 let bytes = match read_result_untouched(path) {
2707 Ok(bytes) => bytes,
2708 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2709 Err(error) => {
2710 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2711 }
2712 };
2713 action_result_bytes_are_dangling(cas, &bytes, virtually_removed, walk_tree, trees)
2714}
2715
2716fn held_by_another_process(error: &std::io::Error) -> bool {
2723 error.kind() == std::io::ErrorKind::PermissionDenied
2724 || (cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)))
2725}
2726
2727fn action_result_bytes_are_dangling(
2728 cas: &LocalCas,
2729 bytes: &[u8],
2730 virtually_removed: &HashSet<PathBuf>,
2731 walk_tree: bool,
2732 trees: &mut HashMap<CacheDigest, bool>,
2733) -> Result<bool> {
2734 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(bytes) else {
2736 return Ok(false);
2737 };
2738 let missing = |digest: &CacheDigest| match cas.path_for(digest) {
2739 Ok(path) => virtually_removed.contains(&path) || matches!(path.try_exists(), Ok(false)),
2741 Err(_) => true,
2743 };
2744 for digest in [
2745 Some(&result.action),
2746 result.metadata.as_ref(),
2747 result.output_root.as_ref(),
2748 ]
2749 .into_iter()
2750 .flatten()
2751 {
2752 if missing(digest) {
2753 return Ok(true);
2754 }
2755 }
2756 if walk_tree && let Some(output_root) = &result.output_root {
2757 return Ok(!tree_is_complete(cas, output_root, &missing, trees));
2758 }
2759 Ok(false)
2760}
2761
2762fn tree_is_complete(
2765 cas: &LocalCas,
2766 digest: &CacheDigest,
2767 missing: &impl Fn(&CacheDigest) -> bool,
2768 trees: &mut HashMap<CacheDigest, bool>,
2769) -> bool {
2770 if let Some(&complete) = trees.get(digest) {
2771 return complete;
2772 }
2773 trees.insert(digest.clone(), true);
2776 let complete = read_directory(cas, digest).is_none_or(|directory| {
2778 directory.files.iter().all(|file| !missing(&file.digest))
2779 && directory.directories.iter().all(|child| {
2780 !missing(&child.digest) && tree_is_complete(cas, &child.digest, missing, trees)
2781 })
2782 });
2783 trees.insert(digest.clone(), complete);
2784 complete
2785}
2786
2787struct Entry {
2788 path: PathBuf,
2789 size: u64,
2790 used: SystemTime,
2791}
2792
2793fn walk_files(root: &Path) -> Result<Vec<Entry>> {
2794 let mut entries = Vec::new();
2795 let mut pending = vec![root.to_path_buf()];
2796 while let Some(directory) = pending.pop() {
2797 let listing = match std::fs::read_dir(&directory) {
2798 Ok(listing) => listing,
2799 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2800 Err(error) => {
2801 return Err(error)
2802 .wrap_err_with(|| format!("failed to read {}", directory.display()));
2803 }
2804 };
2805 for entry in listing {
2806 let entry = entry?;
2807 let metadata = match entry.metadata() {
2808 Ok(metadata) => metadata,
2809 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2811 Err(error) => return Err(error.into()),
2812 };
2813 if metadata.is_dir() {
2814 pending.push(entry.path());
2815 } else if metadata.is_file() {
2816 let used = metadata
2817 .accessed()
2818 .or_else(|_| metadata.modified())
2819 .unwrap_or(SystemTime::UNIX_EPOCH);
2820 entries.push(Entry {
2821 path: entry.path(),
2822 size: metadata.len(),
2823 used,
2824 });
2825 }
2826 }
2827 }
2828 Ok(entries)
2829}
2830
2831fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
2832 match std::fs::read_dir(root) {
2833 Ok(entries) => entries
2834 .collect::<std::io::Result<Vec<_>>>()
2835 .map_err(Into::into),
2836 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
2837 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
2838 }
2839}
2840
2841fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
2842 let name = path.file_name()?.to_str()?;
2843 let name = if action_result {
2844 name.strip_suffix(".json")?
2845 } else {
2846 name
2847 };
2848 let (hash, size) = name.rsplit_once('-')?;
2849 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
2850 let digest = CacheDigest {
2851 algorithm,
2852 hash: hash.to_string(),
2853 size: size.parse().ok()?,
2854 };
2855 digest.validate().ok()?;
2856 Some(digest)
2857}
2858
2859fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
2860 let digest = digest_from_path(path, action_result)?;
2861 let canonical = if action_result {
2862 mbx_cache_core::LocalActionCache::new(store)
2863 .path_for(&digest)
2864 .ok()?
2865 } else {
2866 LocalCas::new(store).path_for(&digest).ok()?
2867 };
2868 (canonical == path).then_some(digest)
2869}
2870
2871fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
2872 *cache
2873 .entry(root.to_path_buf())
2874 .or_insert_with(|| tree_bytes(root))
2875}
2876
2877fn tree_bytes(root: &Path) -> u64 {
2878 let Ok(root_metadata) = std::fs::metadata(root) else {
2879 return 0;
2880 };
2881 if root_metadata.is_file() {
2882 return root_metadata.len();
2883 }
2884 let Ok(entries) = std::fs::read_dir(root) else {
2885 return 0;
2886 };
2887 let mut bytes = 0_u64;
2888 let mut pending = entries
2889 .filter_map(std::result::Result::ok)
2890 .map(|entry| entry.path())
2891 .collect::<Vec<_>>();
2892 while let Some(path) = pending.pop() {
2893 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
2894 continue;
2895 };
2896 if metadata.is_file() {
2897 bytes = bytes.saturating_add(metadata.len());
2898 } else if metadata.is_dir()
2899 && let Ok(entries) = std::fs::read_dir(path)
2900 {
2901 pending.extend(
2902 entries
2903 .filter_map(std::result::Result::ok)
2904 .map(|entry| entry.path()),
2905 );
2906 }
2907 }
2908 bytes
2909}
2910
2911#[cfg(test)]
2912#[path = "store_tests.rs"]
2913mod tests;
2914
2915fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
2918 let parent = path
2919 .parent()
2920 .filter(|parent| !parent.as_os_str().is_empty())
2921 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2922 std::fs::create_dir_all(parent)
2923 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2924 let mut temporary = tempfile::Builder::new()
2925 .prefix(".mbx-")
2926 .tempfile_in(parent)?;
2927 use std::io::Write as _;
2928 temporary.write_all(contents)?;
2929 temporary
2930 .persist(path)
2931 .map_err(|error| error.error)
2932 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2933 Ok(())
2934}
2935
2936fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
2937 let parent = path
2938 .parent()
2939 .filter(|parent| !parent.as_os_str().is_empty())
2940 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
2941 std::fs::create_dir_all(parent)
2942 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
2943 let mut temporary = tempfile::Builder::new()
2944 .prefix(".mbx-")
2945 .tempfile_in(parent)?;
2946 use std::io::Write as _;
2947 temporary.write_all(contents)?;
2948 temporary.as_file_mut().sync_all()?;
2949 temporary
2950 .persist(path)
2951 .map_err(|error| error.error)
2952 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
2953 Ok(())
2954}