1mod events;
14
15use eyre::{Context, Result};
16use mbx_cache_core::{
17 ActionPrediction, ActionSlot, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult,
18 RustcMetadata, TaskActionManifest, is_task_identity, merge_task_action_predictions,
19 task_manifest_actions,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
23use std::path::{Component, Path, PathBuf};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex};
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28const CAS_DIR: &str = "cas/v1";
29const ACTION_RESULTS_DIR: &str = "action-results/v1";
30const TASK_MANIFESTS_DIR: &str = "task-manifests/v1";
31const CHECKOUTS_DIR: &str = "checkouts/v1";
32const SWEEP_STAMP: &str = "gc/v1/last-sweep";
33const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
34const CHECKOUT_RECORD_VERSION: u8 = 1;
35const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
36const BUILD_RECEIPT_VERSION: u8 = 1;
37const IMPORT_STAGING_DIR: &str = "import-staging";
38const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
39const EXPORT_VERSION: u8 = 2;
40const LEGACY_EXPORT_VERSION: u8 = 1;
41
42const IMPORT_STAGING_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
43const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
44
45const TASK_MANIFEST_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
54const MAX_SESSIONS: usize = 256;
55
56const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
66
67#[derive(Debug, Default, PartialEq, Eq)]
68pub struct StoreStats {
69 pub objects: u64,
70 pub object_bytes: u64,
71 pub action_results: u64,
72 pub action_result_bytes: u64,
73 pub live_checkouts: u64,
74 pub stale_checkouts: u64,
79}
80
81impl StoreStats {
82 pub fn total_bytes(&self) -> u64 {
83 self.object_bytes.saturating_add(self.action_result_bytes)
84 }
85}
86
87#[derive(Debug, Default, PartialEq, Eq)]
88pub struct GcOutcome {
89 pub removed_objects: u64,
90 pub removed_action_results: u64,
91 pub removed_checkout_records: u64,
92 pub removed_task_manifests: u64,
96 pub removed_session_streams: u64,
99 pub removed_bytes: u64,
100 pub remaining_bytes: u64,
101}
102
103#[derive(Debug, PartialEq, Eq)]
104pub struct ProjectUsage {
105 pub workspace_root: PathBuf,
106 pub identities: u64,
107 pub action_bytes: u64,
108 pub target_bytes: u64,
109 pub live: bool,
110}
111
112#[derive(Debug, PartialEq, Eq)]
113pub struct LargestEntry {
114 pub kind: &'static str,
115 pub path: PathBuf,
116 pub bytes: u64,
117}
118
119#[derive(Debug, Default, PartialEq, Eq)]
120pub struct VerifyOutcome {
121 pub checked_objects: u64,
122 pub checked_action_results: u64,
123 pub problems: Vec<PathBuf>,
124}
125
126#[derive(Debug, Default, PartialEq, Eq)]
127pub struct RemoveProjectOutcome {
128 pub removed_checkout_records: u64,
129}
130
131#[derive(Debug, Default, PartialEq, Eq)]
132pub struct TransferOutcome {
133 pub actions: u64,
134 pub objects: u64,
135 pub bytes: u64,
136}
137
138#[derive(Debug)]
140#[non_exhaustive]
141pub struct ImportOutcome {
142 pub transfer: TransferOutcome,
143 pub attachments: BTreeMap<String, CacheDigest>,
144}
145
146#[derive(Debug, Default)]
148pub struct ExportAdditions {
149 pub attachments: BTreeMap<String, CacheDigest>,
151 pub objects: BTreeSet<CacheDigest>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
157pub struct WorkspaceTarget {
158 pub workspace_root: PathBuf,
159 pub target_dir: PathBuf,
160}
161
162#[derive(Debug, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164struct ExportManifest {
165 version: u8,
166 tasks: Vec<TaskActionManifest>,
167 actions: Vec<CacheDigest>,
168 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
169 attachments: BTreeMap<String, CacheDigest>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 objects: Vec<CacheDigest>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177struct BuildReceipt {
178 version: u8,
179 workspace_root: PathBuf,
180 identity: String,
181 completed_nanos: u64,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 group: Option<String>,
184 predictions: Vec<ActionPrediction>,
185}
186
187pub fn record_build_receipt(
189 store: &Path,
190 run: &str,
191 identity: &str,
192 workspace_root: &Path,
193 group: Option<&str>,
194 predictions: Vec<ActionPrediction>,
195) -> Result<()> {
196 if !is_task_identity(run) || !is_task_identity(identity) {
197 eyre::bail!("invalid build receipt identity");
198 }
199 if !(TaskActionManifest {
200 version: 1,
201 task: identity.to_owned(),
202 predictions: predictions.clone(),
203 })
204 .validate()
205 {
206 eyre::bail!("invalid build receipt prediction");
207 }
208 if let Some(group) = group {
209 validate_export_group(group)?;
210 }
211 if group.is_none()
216 && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
217 latest.identity == identity
218 && latest.workspace_root == workspace_root
219 && latest.group.is_none()
220 && latest.predictions == predictions
221 })
222 {
223 return Ok(());
224 }
225 let key = workspace_key(workspace_root);
226 let lock_path = store
227 .join(BUILD_RECEIPTS_DIR)
228 .join("locks")
229 .join(format!("{key}.lock"));
230 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
231 let mut lock = fslock::LockFile::open(&lock_path)?;
232 lock.lock()?;
233 let completed_nanos = SystemTime::now()
234 .duration_since(UNIX_EPOCH)
235 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
236 .unwrap_or_default();
237 let receipt = BuildReceipt {
238 version: BUILD_RECEIPT_VERSION,
239 workspace_root: workspace_root.to_path_buf(),
240 identity: identity.to_owned(),
241 completed_nanos,
242 group: group.map(str::to_owned),
243 predictions,
244 };
245 let bytes = serde_json::to_vec(&receipt)?;
246 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
247 if let Some(group) = group {
248 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
249 }
250 Ok(())
251}
252
253fn workspace_key(workspace_root: &Path) -> String {
254 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
255}
256
257fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
258 store
259 .join(BUILD_RECEIPTS_DIR)
260 .join("checkouts")
261 .join(format!("{}.json", workspace_key(workspace_root)))
262}
263
264fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
265 store
266 .join(BUILD_RECEIPTS_DIR)
267 .join("groups")
268 .join(group_key(group))
269 .join(format!("{run}.json"))
270}
271
272fn group_key(group: &str) -> String {
273 CacheDigest::blake3(group.as_bytes()).hash
274}
275
276fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
277 let bytes = std::fs::read(path).ok()?;
278 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
279 let valid = TaskActionManifest {
280 version: 1,
281 task: receipt.identity.clone(),
282 predictions: receipt.predictions.clone(),
283 }
284 .validate();
285 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
286}
287
288fn validate_export_group(group: &str) -> Result<()> {
289 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
290 eyre::bail!("invalid build export group");
291 }
292 Ok(())
293}
294
295#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
303pub enum ExportForm {
304 #[default]
306 Tar,
307 Directory,
309}
310
311impl std::str::FromStr for ExportForm {
312 type Err = eyre::Report;
313
314 fn from_str(text: &str) -> Result<Self> {
315 match text {
316 "tar" => Ok(Self::Tar),
317 "directory" => Ok(Self::Directory),
318 other => eyre::bail!("unknown export format {other:?}; expected tar or directory"),
319 }
320 }
321}
322
323pub fn export_checkout(
325 store: &Path,
326 workspace_root: &Path,
327 archive: &Path,
328) -> Result<TransferOutcome> {
329 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
330 .filter(|receipt| receipt.workspace_root == workspace_root)
331 .ok_or_else(|| {
332 eyre::eyre!(
333 "no completed mbx build is recorded for {}",
334 workspace_root.display()
335 )
336 })?;
337 export_receipts(
338 store,
339 vec![receipt],
340 archive,
341 ExportAdditions::default(),
342 ExportForm::Tar,
343 )
344}
345
346pub fn checkout_workspace_target(
348 store: &Path,
349 workspace_root: &Path,
350) -> Result<Option<WorkspaceTarget>> {
351 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
352 .filter(|receipt| receipt.workspace_root == workspace_root)
353 else {
354 return Ok(None);
355 };
356 Ok(workspace_target_for_receipt(store, &receipt))
357}
358
359pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
361 validate_export_group(group)?;
362 let root = store
363 .join(BUILD_RECEIPTS_DIR)
364 .join("groups")
365 .join(group_key(group));
366 let targets = walk_files(&root)?
367 .into_iter()
368 .filter_map(|entry| read_build_receipt(&entry.path))
369 .filter(|receipt| receipt.group.as_deref() == Some(group))
370 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
371 .collect::<BTreeSet<_>>();
372 Ok(targets.into_iter().collect())
373}
374
375fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
376 let record = read_checkout_record(&checkout_record_path(
377 store,
378 &receipt.identity,
379 &receipt.workspace_root,
380 ))?;
381 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
382 .then_some(WorkspaceTarget {
383 workspace_root: record.workspace_root,
384 target_dir: record.target_dir,
385 })
386}
387
388pub fn export_checkout_with(
390 store: &Path,
391 workspace_root: &Path,
392 archive: &Path,
393 additions: ExportAdditions,
394) -> Result<TransferOutcome> {
395 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
396 .filter(|receipt| receipt.workspace_root == workspace_root)
397 .ok_or_else(|| {
398 eyre::eyre!(
399 "no completed mbx build is recorded for {}",
400 workspace_root.display()
401 )
402 })?;
403 export_receipts(store, vec![receipt], archive, additions, ExportForm::Tar)
404}
405
406pub fn export_checkout_as(
408 store: &Path,
409 workspace_root: &Path,
410 destination: &Path,
411 additions: ExportAdditions,
412 form: ExportForm,
413) -> Result<TransferOutcome> {
414 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
415 .filter(|receipt| receipt.workspace_root == workspace_root)
416 .ok_or_else(|| {
417 eyre::eyre!(
418 "no completed mbx build is recorded for {}",
419 workspace_root.display()
420 )
421 })?;
422 export_receipts(store, vec![receipt], destination, additions, form)
423}
424
425pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
427 export_group_with(store, group, archive, ExportAdditions::default())
428}
429
430pub fn export_group_with(
432 store: &Path,
433 group: &str,
434 archive: &Path,
435 additions: ExportAdditions,
436) -> Result<TransferOutcome> {
437 export_group_as(store, group, archive, additions, ExportForm::Tar)
438}
439
440pub fn export_group_as(
442 store: &Path,
443 group: &str,
444 destination: &Path,
445 additions: ExportAdditions,
446 form: ExportForm,
447) -> Result<TransferOutcome> {
448 validate_export_group(group)?;
449 let root = store
450 .join(BUILD_RECEIPTS_DIR)
451 .join("groups")
452 .join(group_key(group));
453 let receipts = walk_files(&root)?
454 .into_iter()
455 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
456 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
457 .collect::<Vec<_>>();
458 if receipts.is_empty() {
459 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
460 }
461 let outcome = export_receipts(
462 store,
463 receipts
464 .iter()
465 .map(|(_, receipt)| receipt.clone())
466 .collect(),
467 destination,
468 additions,
469 form,
470 )?;
471 for (path, _) in receipts {
476 std::fs::remove_file(path)?;
477 }
478 let _ = std::fs::remove_dir(root);
479 Ok(outcome)
480}
481
482fn export_receipts(
483 store: &Path,
484 mut receipts: Vec<BuildReceipt>,
485 archive: &Path,
486 additions: ExportAdditions,
487 form: ExportForm,
488) -> Result<TransferOutcome> {
489 receipts.sort_by(|left, right| {
490 left.completed_nanos
491 .cmp(&right.completed_nanos)
492 .then_with(|| {
493 fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
496 (
497 &prediction.invocation,
498 &prediction.action,
499 &prediction.adapter,
500 &prediction.payload,
501 )
502 }
503 left.predictions
504 .iter()
505 .map(key)
506 .cmp(right.predictions.iter().map(key))
507 })
508 });
509 let mut actions = BTreeSet::new();
510 let mut tasks = BTreeMap::new();
511 for receipt in receipts {
512 actions.extend(
513 receipt
514 .predictions
515 .iter()
516 .map(|prediction| prediction.action.clone()),
517 );
518 tasks
521 .entry(receipt.identity)
522 .or_insert_with(Vec::new)
523 .extend(receipt.predictions);
524 }
525 let tasks = tasks
526 .into_iter()
527 .map(|(task, predictions)| {
528 let mut invocations = BTreeSet::new();
529 let mut predictions = predictions
530 .into_iter()
531 .rev()
532 .filter(|prediction| invocations.insert(prediction.invocation.clone()))
533 .collect::<Vec<_>>();
534 predictions.reverse();
535 TaskActionManifest {
536 version: 1,
537 task,
538 predictions,
539 }
540 })
541 .collect::<Vec<_>>();
542 if tasks.iter().any(|task| !task.validate()) {
543 eyre::bail!("combined export predictions exceed task manifest limits");
544 }
545 validate_export_additions(&additions)?;
546 let mut closure = strict_closure(store, &actions)?;
547 let cas = LocalCas::new(store);
548 for digest in &additions.objects {
549 require_object(&cas, &mut closure, digest)?;
550 }
551 verify_pending(&mut closure.pending).wrap_err("cache closure is incomplete or corrupt")?;
555 let parent = archive
556 .parent()
557 .filter(|parent| !parent.as_os_str().is_empty())
558 .unwrap_or_else(|| Path::new("."));
559 std::fs::create_dir_all(parent)?;
560 let manifest = serde_json::to_vec(&ExportManifest {
561 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
562 LEGACY_EXPORT_VERSION
563 } else {
564 EXPORT_VERSION
565 },
566 tasks,
567 actions: actions.iter().cloned().collect(),
568 attachments: additions.attachments.clone(),
569 objects: additions.objects.iter().cloned().collect(),
570 })?;
571 let members = closure
572 .objects
573 .iter()
574 .chain(closure.results.iter())
575 .collect::<Vec<_>>();
576 let bytes = match form {
577 ExportForm::Tar => write_tar_export(store, archive, parent, &manifest, &members)?,
578 ExportForm::Directory => {
579 write_directory_export(store, archive, parent, &manifest, &members)?
580 }
581 };
582 Ok(TransferOutcome {
583 actions: actions.len() as u64,
584 objects: closure.objects.len() as u64,
585 bytes,
586 })
587}
588
589fn write_tar_export(
591 store: &Path,
592 archive: &Path,
593 parent: &Path,
594 manifest: &[u8],
595 members: &[&PathBuf],
596) -> Result<u64> {
597 let temporary = tempfile::Builder::new()
598 .prefix(".mbx-export-")
599 .tempfile_in(parent)?;
600 let mut builder = tar::Builder::new(temporary.reopen()?);
601 append_bytes(&mut builder, Path::new(EXPORT_MANIFEST), manifest)?;
602 for path in members {
603 append_file(&mut builder, store, path)?;
604 }
605 builder.finish()?;
606 drop(builder);
607 temporary
608 .persist(archive)
609 .map_err(|error| error.error)
610 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
611 Ok(std::fs::metadata(archive)?.len())
612}
613
614fn write_directory_export(
620 store: &Path,
621 destination: &Path,
622 parent: &Path,
623 manifest: &[u8],
624 members: &[&PathBuf],
625) -> Result<u64> {
626 let staging = tempfile::Builder::new()
627 .prefix(".mbx-export-")
628 .tempdir_in(parent)?;
629 std::fs::write(staging.path().join(EXPORT_MANIFEST), manifest)?;
630 let mut bytes = manifest.len() as u64;
631 for path in members {
632 let relative = path.strip_prefix(store)?;
633 let target = staging.path().join(relative);
634 std::fs::create_dir_all(target.parent().expect("export member has a parent"))?;
635 bytes += std::fs::copy(path, &target)?;
636 }
637 let staged = staging.keep();
638 let retired = match std::fs::symlink_metadata(destination) {
643 Ok(_) => {
644 let holder = tempfile::Builder::new()
645 .prefix(".mbx-export-retired-")
646 .tempdir_in(parent)?;
647 let moved = holder.path().join("bundle");
648 std::fs::rename(destination, &moved)
649 .wrap_err_with(|| format!("failed to replace {}", destination.display()))?;
650 Some((holder, moved))
651 }
652 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
653 Err(error) => {
654 return Err(error)
655 .wrap_err_with(|| format!("failed to replace {}", destination.display()));
656 }
657 };
658 if let Err(error) = std::fs::rename(&staged, destination) {
659 let _ = std::fs::remove_dir_all(&staged);
660 if let Some((holder, moved)) = retired
661 && std::fs::rename(&moved, destination).is_err()
662 {
663 let kept = holder.keep();
668 log::warn!(
669 "could not restore {} after a failed export; its previous contents are at {}",
670 destination.display(),
671 kept.join("bundle").display()
672 );
673 }
674 return Err(error).wrap_err_with(|| format!("failed to publish {}", destination.display()));
675 }
676 drop(retired);
678 Ok(bytes)
679}
680
681pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
683 Ok(import_archive_with_attachments(store, archive)?.transfer)
684}
685
686pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
688 let staged;
692 let _staging_lock;
693 let root = if archive.is_dir() {
694 _staging_lock = None;
695 validate_directory_bundle(archive)?;
696 archive
697 } else {
698 let staging_root = store.join(IMPORT_STAGING_DIR);
704 std::fs::create_dir_all(&staging_root)?;
705 staged = tempfile::Builder::new()
706 .prefix("import-")
707 .tempdir_in(&staging_root)?;
708 _staging_lock = Some(lock_import_staging(staged.path())?);
713 unpack_archive(archive, staged.path())?;
714 staged.path()
715 };
716 let bundle_bytes = archive.is_dir().then(|| tree_bytes(archive));
719 let manifest: ExportManifest =
720 serde_json::from_slice(&std::fs::read(root.join(EXPORT_MANIFEST))?)?;
721 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
722 let task_identities = manifest
723 .tasks
724 .iter()
725 .map(|task| task.task.as_str())
726 .collect::<BTreeSet<_>>();
727 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
728 || (manifest.version == LEGACY_EXPORT_VERSION
729 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
730 || manifest.tasks.is_empty()
731 || actions.len() != manifest.actions.len()
732 || task_identities.len() != manifest.tasks.len()
733 || manifest.tasks.iter().any(|task| !task.validate())
734 || manifest
735 .actions
736 .iter()
737 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
738 || manifest.tasks.iter().any(|task| {
739 task.predictions
740 .iter()
741 .any(|prediction| !actions.contains(&prediction.action))
742 })
743 || manifest
744 .attachments
745 .keys()
746 .any(|name| !valid_attachment_name(name))
747 || manifest.attachments.values().any(|digest| {
748 digest.algorithm != "blake3"
749 || digest.validate().is_err()
750 || !manifest.objects.contains(digest)
751 })
752 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
753 || manifest
754 .objects
755 .iter()
756 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
757 {
758 eyre::bail!("unsupported or invalid cache export manifest");
759 }
760 let mut closure =
761 strict_closure(root, &actions).wrap_err("cache export is incomplete or corrupt")?;
762 let staged_cas = LocalCas::new(root);
763 for digest in &manifest.objects {
764 require_object(&staged_cas, &mut closure, digest)
765 .wrap_err("cache export attachment is incomplete or corrupt")?;
766 }
767 verify_pending(&mut closure.pending).wrap_err("cache export is incomplete or corrupt")?;
768
769 let results = closure
777 .results
778 .iter()
779 .map(|path| {
780 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)
781 .wrap_err_with(|| format!("action result is invalid: {}", path.display()))?;
782 Ok(result)
783 })
784 .collect::<Result<Vec<_>>>()
785 .wrap_err("cache export is incomplete or corrupt")?;
786
787 let cas = LocalCas::new(store);
788 for path in &closure.objects {
789 let relative = path.strip_prefix(root)?;
790 let source = root.join(relative);
791 let digest = addressed_digest(root, &source, false)
792 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
793 cas.adopt_verified_file(&digest, &source)?;
797 }
798 let action_cache = mbx_cache_core::LocalActionCache::new(store);
799 let mut kept_existing = 0;
800 for result in &results {
801 if action_cache.store_unless_restorable(result, |existing| {
802 extend_result_closure(
803 &cas,
804 &mut Closure::default(),
805 &mut BTreeSet::new(),
806 existing,
807 false,
808 )
809 .is_ok()
810 })? == mbx_cache_core::ActionStoreOutcome::KeptExisting
811 {
812 kept_existing += 1;
813 }
814 }
815 if kept_existing > 0 {
816 log::debug!(
817 "cache import kept {kept_existing} intact existing results that differed from the bundle"
818 );
819 }
820 for task in manifest.tasks {
821 merge_imported_manifest(store, task)?;
822 }
823 let bytes = match bundle_bytes {
824 Some(bytes) => {
825 std::fs::remove_dir_all(archive)
829 .wrap_err_with(|| format!("failed to remove {}", archive.display()))?;
830 bytes
831 }
832 None => std::fs::metadata(archive)?.len(),
833 };
834 Ok(ImportOutcome {
835 transfer: TransferOutcome {
836 actions: actions.len() as u64,
837 objects: closure.objects.len() as u64,
838 bytes,
839 },
840 attachments: manifest.attachments,
841 })
842}
843
844fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
845 if additions
846 .attachments
847 .keys()
848 .any(|name| !valid_attachment_name(name))
849 || additions
850 .attachments
851 .values()
852 .any(|digest| !additions.objects.contains(digest))
853 || additions
854 .objects
855 .iter()
856 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
857 {
858 eyre::bail!("invalid cache export attachment");
859 }
860 Ok(())
861}
862
863fn valid_attachment_name(name: &str) -> bool {
864 !name.is_empty()
865 && name.len() <= 128
866 && name
867 .bytes()
868 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
869}
870
871#[derive(Default)]
873struct Closure {
874 objects: BTreeSet<PathBuf>,
875 results: BTreeSet<PathBuf>,
876 pending: Vec<PendingObject>,
877}
878
879struct PendingObject {
881 digest: CacheDigest,
882 path: PathBuf,
883}
884
885fn strict_closure(store: &Path, actions: &BTreeSet<CacheDigest>) -> Result<Closure> {
886 let cas = LocalCas::new(store);
887 let action_cache = mbx_cache_core::LocalActionCache::new(store);
888 let mut closure = Closure::default();
889 let mut directories = BTreeSet::new();
890 for action in actions {
891 let result = action_cache
892 .find(action)?
893 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
894 closure.results.insert(action_cache.path_for(action)?);
895 extend_result_closure(&cas, &mut closure, &mut directories, &result, true)?;
896 }
897 Ok(closure)
898}
899
900fn extend_result_closure(
903 cas: &LocalCas,
904 closure: &mut Closure,
905 directories: &mut BTreeSet<CacheDigest>,
906 result: &RemoteActionResult,
907 verify_descriptors: bool,
908) -> Result<()> {
909 let parsed_object = |closure: &mut Closure, digest: &CacheDigest| {
910 if verify_descriptors {
911 require_parsed_object(cas, closure, digest)
912 } else {
913 require_object(cas, closure, digest)?;
914 cas.path_for(digest)
915 }
916 };
917 require_object(cas, closure, &result.action)?;
918 if let Some(metadata) = &result.metadata {
919 let path = parsed_object(closure, metadata)?;
920 let captured: CapturedOutput =
921 serde_json::from_slice(&std::fs::read(path)?).wrap_err("action metadata is invalid")?;
922 require_object(cas, closure, &captured.stdout)?;
923 require_object(cas, closure, &captured.stderr)?;
924 }
925 if let Some(root) = &result.output_root {
926 let mut nodes = vec![root.clone()];
927 while let Some(digest) = nodes.pop() {
928 if !directories.insert(digest.clone()) {
929 continue;
930 }
931 let path = parsed_object(closure, &digest)?;
932 let directory: CacheDirectory = serde_json::from_slice(&std::fs::read(path)?)
933 .wrap_err("output directory is invalid")?;
934 for file in directory.files {
935 require_object(cas, closure, &file.digest)?;
936 }
937 for child in directory.directories {
938 nodes.push(child.digest);
939 }
940 }
941 }
942 Ok(())
943}
944
945fn verify_pending(pending: &mut [PendingObject]) -> Result<()> {
953 if pending.is_empty() {
954 return Ok(());
955 }
956 pending.sort_by(|left, right| {
960 right
961 .digest
962 .size
963 .cmp(&left.digest.size)
964 .then_with(|| left.path.cmp(&right.path))
965 });
966 let workers = std::thread::available_parallelism()
967 .map(usize::from)
968 .unwrap_or(1)
969 .min(pending.len());
970 let next = AtomicUsize::new(0);
971 let failure: Mutex<Option<(PathBuf, eyre::Report)>> = Mutex::new(None);
974 let pending: &[PendingObject] = pending;
975 std::thread::scope(|scope| {
976 for _ in 0..workers {
977 scope.spawn(|| {
978 loop {
979 let index = next.fetch_add(1, Ordering::Relaxed);
980 let Some(entry) = pending.get(index) else {
981 break;
982 };
983 let error = match entry.digest.matches_file(&entry.path) {
984 Ok(true) => continue,
985 Ok(false) => eyre::eyre!(
986 "local CAS blob failed digest verification: {}",
987 entry.path.display()
988 ),
989 Err(error) => {
990 error.wrap_err(format!("failed to verify {}", entry.path.display()))
991 }
992 };
993 let mut failure = failure.lock().unwrap_or_else(|error| error.into_inner());
994 if failure.as_ref().is_none_or(|(path, _)| entry.path < *path) {
995 *failure = Some((entry.path.clone(), error));
996 }
997 }
998 });
999 }
1000 });
1001 match failure
1002 .into_inner()
1003 .unwrap_or_else(|error| error.into_inner())
1004 {
1005 Some((_, error)) => Err(error),
1006 None => Ok(()),
1007 }
1008}
1009
1010#[derive(Deserialize)]
1011struct CapturedOutput {
1012 stdout: CacheDigest,
1013 stderr: CacheDigest,
1014}
1015
1016fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
1017 let identity = imported.task.clone();
1018 let destination_path = task_manifest_path(destination, &identity);
1019 let lock_path = task_manifest_lock_path(destination, &identity);
1020 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
1021 let mut lock = fslock::LockFile::open(&lock_path)?;
1022 lock.lock()?;
1023 if let Ok(bytes) = std::fs::read(&destination_path)
1024 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
1025 && existing.task == identity
1026 && existing.validate()
1027 {
1028 let existing_predictions: BTreeMap<_, _> = existing
1029 .predictions
1030 .iter()
1031 .cloned()
1032 .map(|prediction| (prediction.invocation.clone(), prediction))
1033 .collect();
1034 let updates = imported
1037 .predictions
1038 .into_iter()
1039 .map(|prediction| {
1040 existing_predictions
1041 .get(&prediction.invocation)
1042 .cloned()
1043 .unwrap_or(prediction)
1044 })
1045 .collect();
1046 imported.predictions =
1047 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
1048 }
1049 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
1050}
1051
1052fn require_object(cas: &LocalCas, closure: &mut Closure, digest: &CacheDigest) -> Result<()> {
1059 let expected = cas.path_for(digest)?;
1060 if !closure.objects.insert(expected.clone()) {
1061 return Ok(());
1062 }
1063 let metadata = match std::fs::metadata(&expected) {
1064 Ok(metadata) => metadata,
1065 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1066 eyre::bail!("cache object is missing for {}", digest.hash)
1067 }
1068 Err(error) => {
1069 return Err(error).wrap_err_with(|| format!("failed to read {}", expected.display()));
1070 }
1071 };
1072 if metadata.len() != digest.size {
1073 eyre::bail!(
1074 "local CAS blob failed digest verification: {}",
1075 expected.display()
1076 );
1077 }
1078 closure.pending.push(PendingObject {
1079 digest: digest.clone(),
1080 path: expected,
1081 });
1082 Ok(())
1083}
1084
1085fn require_parsed_object(
1092 cas: &LocalCas,
1093 closure: &mut Closure,
1094 digest: &CacheDigest,
1095) -> Result<PathBuf> {
1096 let path = cas
1097 .find(digest)?
1098 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
1099 closure.objects.insert(path.clone());
1100 Ok(path)
1101}
1102
1103#[derive(Default)]
1104struct ManifestPrune {
1105 removed_manifests: u64,
1106 removed_bytes: u64,
1107}
1108
1109fn prune_task_manifests(store: &Path, live: &BTreeSet<String>, dry_run: bool) -> ManifestPrune {
1124 let mut prune = ManifestPrune::default();
1125 let root = store.join(TASK_MANIFESTS_DIR);
1126 let mut removed = BTreeSet::new();
1127 let manifests = read_dir_or_empty(&root).unwrap_or_else(|error| {
1128 log::warn!("could not list task manifests: {error}");
1129 Vec::new()
1130 });
1131 for entry in manifests {
1132 let name = entry.file_name().to_string_lossy().into_owned();
1133 let Some(identity) = name.strip_suffix(".json") else {
1134 continue;
1135 };
1136 if !is_task_identity(identity) || live.contains(identity) {
1137 continue;
1138 }
1139 let Ok(metadata) = entry.metadata() else {
1140 continue;
1141 };
1142 if !metadata.is_file() || !task_manifest_is_idle(&metadata) {
1143 continue;
1144 }
1145 let lock_path = task_manifest_lock_path(store, identity);
1146 if dry_run {
1147 if lock_path.exists() && claim_task_manifest_lock(&lock_path).is_none() {
1151 continue;
1152 }
1153 prune.removed_manifests += 1;
1154 prune.removed_bytes += metadata.len();
1155 continue;
1156 }
1157 let Some(guard) = claim_task_manifest_lock(&lock_path) else {
1160 continue;
1161 };
1162 let Ok(metadata) = std::fs::metadata(entry.path()) else {
1165 continue;
1166 };
1167 if !metadata.is_file() || !task_manifest_is_idle(&metadata) {
1168 continue;
1169 }
1170 match remove(&entry.path()) {
1171 Ok(Removal::Removed) => {
1172 removed.insert(identity.to_string());
1173 prune.removed_manifests += 1;
1174 prune.removed_bytes += metadata.len();
1175 }
1176 Ok(_) => {}
1177 Err(error) => log::warn!("could not remove task manifest {name}: {error}"),
1178 }
1179 drop(guard);
1180 if removed.contains(identity) {
1181 let _ = std::fs::remove_file(&lock_path);
1182 }
1183 }
1184
1185 if !dry_run {
1189 let locks = read_dir_or_empty(&root.join("locks")).unwrap_or_else(|error| {
1190 log::warn!("could not list task manifest locks: {error}");
1191 Vec::new()
1192 });
1193 for entry in locks {
1194 let name = entry.file_name().to_string_lossy().into_owned();
1195 let Some(identity) = name.strip_suffix(".lock") else {
1196 continue;
1197 };
1198 if !is_task_identity(identity)
1199 || live.contains(identity)
1200 || task_manifest_path(store, identity).exists()
1201 {
1202 continue;
1203 }
1204 let idle = entry
1205 .metadata()
1206 .is_ok_and(|metadata| metadata.is_file() && task_manifest_is_idle(&metadata));
1207 if idle && let Some(guard) = claim_task_manifest_lock(&entry.path()) {
1208 drop(guard);
1209 let _ = std::fs::remove_file(entry.path());
1210 }
1211 }
1212 }
1213 prune
1214}
1215
1216fn task_manifest_is_idle(metadata: &std::fs::Metadata) -> bool {
1217 metadata
1218 .modified()
1219 .ok()
1220 .and_then(|at| SystemTime::now().duration_since(at).ok())
1221 .is_some_and(|age| age > TASK_MANIFEST_GRACE)
1222}
1223
1224fn claim_task_manifest_lock(path: &Path) -> Option<fslock::LockFile> {
1226 let mut lock = fslock::LockFile::open(path).ok()?;
1227 lock.try_lock().ok()?.then_some(lock)
1228}
1229
1230fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
1231 store
1232 .join(TASK_MANIFESTS_DIR)
1233 .join(format!("{identity}.json"))
1234}
1235
1236fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
1237 store
1238 .join(TASK_MANIFESTS_DIR)
1239 .join("locks")
1240 .join(format!("{identity}.lock"))
1241}
1242
1243fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
1244 let name = path.strip_prefix(root)?;
1245 builder.append_path_with_name(path, name)?;
1246 Ok(())
1247}
1248
1249fn append_bytes(
1250 builder: &mut tar::Builder<std::fs::File>,
1251 name: &Path,
1252 bytes: &[u8],
1253) -> Result<()> {
1254 let mut header = tar::Header::new_gnu();
1255 header.set_size(bytes.len() as u64);
1256 header.set_mode(0o644);
1257 header.set_cksum();
1258 builder.append_data(&mut header, name, bytes)?;
1259 Ok(())
1260}
1261
1262fn unpack_archive(archive: &Path, staging: &Path) -> Result<()> {
1264 let file = std::fs::File::open(archive)
1265 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
1266 let mut bundle = tar::Archive::new(file);
1267 let mut seen = BTreeSet::new();
1268 for entry in bundle.entries()? {
1269 let mut entry = entry?;
1270 let entry_type = entry.header().entry_type();
1271 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
1272 eyre::bail!("cache export contains a non-file entry");
1273 }
1274 let path = entry.path()?.into_owned();
1275 validate_archive_path(&path)?;
1276 if !seen.insert(path.clone()) {
1277 eyre::bail!("cache export contains duplicate entry {}", path.display());
1278 }
1279 let destination = staging.join(&path);
1280 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
1281 entry.unpack(&destination)?;
1285 }
1286 Ok(())
1287}
1288
1289fn validate_directory_bundle(root: &Path) -> Result<()> {
1297 let mut pending = vec![root.to_path_buf()];
1298 while let Some(directory) = pending.pop() {
1299 for entry in std::fs::read_dir(&directory)
1300 .wrap_err_with(|| format!("failed to read {}", directory.display()))?
1301 {
1302 let entry = entry?;
1303 let metadata = entry.metadata()?;
1304 let path = entry.path();
1305 if metadata.is_dir() {
1306 pending.push(path);
1307 continue;
1308 }
1309 if !metadata.is_file() {
1310 eyre::bail!("cache export contains a non-file entry");
1311 }
1312 validate_archive_path(path.strip_prefix(root)?)?;
1313 reject_linked_file(&metadata, &path)?;
1314 }
1315 }
1316 Ok(())
1317}
1318
1319#[cfg(unix)]
1326fn reject_linked_file(metadata: &std::fs::Metadata, path: &Path) -> Result<()> {
1327 use std::os::unix::fs::MetadataExt as _;
1328
1329 if metadata.nlink() > 1 {
1330 eyre::bail!("cache export contains a hard link at {}", path.display());
1331 }
1332 Ok(())
1333}
1334
1335#[cfg(not(unix))]
1341fn reject_linked_file(_metadata: &std::fs::Metadata, _path: &Path) -> Result<()> {
1342 Ok(())
1343}
1344
1345fn validate_archive_path(path: &Path) -> Result<()> {
1346 if path.as_os_str().is_empty()
1347 || path.is_absolute()
1348 || path
1349 .components()
1350 .any(|component| !matches!(component, Component::Normal(_)))
1351 {
1352 eyre::bail!("cache export contains unsafe path {}", path.display());
1353 }
1354 let parts = path
1359 .components()
1360 .map(|component| component.as_os_str().to_string_lossy().into_owned())
1361 .collect::<Vec<_>>();
1362 let expected = match parts.as_slice() {
1363 [name] => name == EXPORT_MANIFEST,
1364 [root, version, _rest @ ..] => {
1365 parts.len() > 2 && (root == "cas" || root == "action-results") && version == "v1"
1366 }
1367 [] => false,
1368 };
1369 if !expected {
1370 eyre::bail!("cache export contains unexpected path {}", path.display());
1371 }
1372 Ok(())
1373}
1374
1375#[derive(Debug, Clone, Serialize, Deserialize)]
1382#[serde(deny_unknown_fields)]
1383struct CheckoutRecord {
1384 version: u8,
1385 workspace_root: PathBuf,
1386 target_dir: PathBuf,
1387 updated_secs: u64,
1388}
1389
1390pub fn stats(store: &Path) -> Result<StoreStats> {
1392 let objects = walk_files(&store.join(CAS_DIR))?;
1393 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1394 let checkouts = scan_checkouts(store)?;
1395 Ok(StoreStats {
1396 objects: objects.len() as u64,
1397 object_bytes: objects.iter().map(|entry| entry.size).sum(),
1398 action_results: results.len() as u64,
1399 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
1400 live_checkouts: checkouts.live_records,
1401 stale_checkouts: checkouts.stale_records.len() as u64,
1402 })
1403}
1404
1405fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
1412 if record.target_dir == record.workspace_root {
1413 return 0;
1414 }
1415 cached_tree_bytes(sizes, &record.target_dir)
1416}
1417
1418pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
1424 project_usage(store, true)
1425}
1426
1427pub fn live_project_cache_bytes(store: &Path) -> Result<Vec<u64>> {
1433 Ok(project_usage(store, false)?
1434 .into_iter()
1435 .filter(|project| project.live)
1436 .map(|project| project.action_bytes)
1437 .collect())
1438}
1439
1440fn project_usage(store: &Path, size_targets: bool) -> Result<Vec<ProjectUsage>> {
1441 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
1442 let mut target_sizes = BTreeMap::new();
1443 let root = store.join(CHECKOUTS_DIR);
1444 for identity_entry in read_dir_or_empty(&root)? {
1445 let identity = identity_entry.file_name().to_string_lossy().into_owned();
1446 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
1447 continue;
1448 }
1449 for entry in walk_files(&identity_entry.path())? {
1450 let Some(record) = read_checkout_record(&entry.path) else {
1451 continue;
1452 };
1453 let project = projects.entry(record.workspace_root.clone()).or_default();
1454 if claim_is_live(store, &record) {
1455 project.0.insert(identity.clone());
1456 project.1 = true;
1457 }
1458 if size_targets {
1459 project.2 = project
1460 .2
1461 .max(checkout_target_bytes(&mut target_sizes, &record));
1462 }
1463 }
1464 }
1465 let live = projects
1466 .values()
1467 .flat_map(|(identities, _, _)| identities.iter().cloned())
1468 .collect::<BTreeSet<_>>();
1469 let mut reachability = Reachability::new(store);
1470 resolve_identities(&reachability, &live);
1471 let mut usages = Vec::new();
1472 for (workspace_root, (identities, live, target_bytes)) in projects {
1473 let action_bytes = reachability.bytes(&identities);
1474 usages.push(ProjectUsage {
1475 workspace_root,
1476 identities: identities.len() as u64,
1477 action_bytes,
1478 target_bytes,
1479 live,
1480 });
1481 }
1482 usages.sort_by(|left, right| {
1483 right
1484 .action_bytes
1485 .saturating_add(right.target_bytes)
1486 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
1487 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
1488 });
1489 Ok(usages)
1490}
1491
1492fn resolve_identities(reachability: &Reachability, identities: &BTreeSet<String>) {
1500 const MAX_WORKERS: usize = 8;
1501 let identities = identities.iter().collect::<Vec<_>>();
1502 let workers = std::thread::available_parallelism()
1503 .map(usize::from)
1504 .unwrap_or(1)
1505 .min(MAX_WORKERS)
1506 .min(identities.len());
1507 let next = AtomicUsize::new(0);
1508 std::thread::scope(|scope| {
1509 for _ in 0..workers {
1510 scope.spawn(|| {
1511 while let Some(identity) = identities.get(next.fetch_add(1, Ordering::Relaxed)) {
1512 let actions = task_manifest_actions(&reachability.store, identity)
1516 .unwrap_or_else(|error| {
1517 log::debug!("could not read the manifest for {identity}: {error}");
1518 Vec::new()
1519 });
1520 reachability.record(identity, &actions);
1521 }
1522 });
1523 }
1524 });
1525}
1526
1527struct Reachability {
1540 store: PathBuf,
1541 cas: LocalCas,
1542 action_cache: mbx_cache_core::LocalActionCache,
1543 paths: Mutex<PathIndex>,
1545 identities: Mutex<HashMap<String, ActionPaths>>,
1546 actions: Mutex<HashMap<CacheDigest, Arc<[usize]>>>,
1547 directories: Mutex<HashMap<CacheDigest, Option<DirectoryPaths>>>,
1549 counted: Vec<usize>,
1551 workspaces: usize,
1552}
1553
1554#[derive(Default)]
1555struct PathIndex {
1556 indices: HashMap<PathBuf, usize>,
1557 sizes: Vec<u64>,
1558}
1559
1560type ActionPaths = Arc<[Arc<[usize]>]>;
1562
1563type DirectoryPaths = (Arc<[CacheDigest]>, Arc<[usize]>);
1564
1565fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1568 mutex.lock().unwrap_or_else(|error| error.into_inner())
1569}
1570
1571impl Reachability {
1572 fn new(store: &Path) -> Self {
1573 Self {
1574 store: store.to_path_buf(),
1575 cas: LocalCas::new(store),
1576 action_cache: mbx_cache_core::LocalActionCache::new(store),
1577 paths: Mutex::default(),
1578 identities: Mutex::default(),
1579 actions: Mutex::default(),
1580 directories: Mutex::default(),
1581 counted: Vec::new(),
1582 workspaces: 0,
1583 }
1584 }
1585
1586 fn bytes(&mut self, identities: &BTreeSet<String>) -> u64 {
1590 let reachable = {
1591 let recorded = locked(&self.identities);
1592 identities
1593 .iter()
1594 .filter_map(|identity| recorded.get(identity).cloned())
1595 .collect::<Vec<_>>()
1596 };
1597 let sizes = std::mem::take(&mut locked(&self.paths).sizes);
1598 self.workspaces += 1;
1599 self.counted.resize(sizes.len(), 0);
1600 let mut bytes = 0_u64;
1601 for index in reachable
1602 .iter()
1603 .flat_map(|actions| actions.iter())
1604 .flat_map(|paths| paths.iter())
1605 {
1606 if self.counted[*index] != self.workspaces {
1607 self.counted[*index] = self.workspaces;
1608 bytes = bytes.saturating_add(sizes[*index]);
1609 }
1610 }
1611 locked(&self.paths).sizes = sizes;
1612 bytes
1613 }
1614
1615 fn record(&self, identity: &str, actions: &[CacheDigest]) {
1620 let actions = actions
1621 .iter()
1622 .map(|action| self.action(action))
1623 .collect::<Arc<[_]>>();
1624 locked(&self.identities).insert(identity.to_string(), actions);
1625 }
1626
1627 fn action(&self, action: &CacheDigest) -> Arc<[usize]> {
1628 if let Some(paths) = locked(&self.actions).get(action) {
1629 return paths.clone();
1630 }
1631 let mut paths = Vec::new();
1632 if let Ok(path) = self.action_cache.path_for(action) {
1633 paths.push(self.index(path));
1634 }
1635 if let Some(result) = read_action_result(&self.store, action) {
1636 self.push_digest(&mut paths, &result.action);
1637 if let Some(metadata) = &result.metadata {
1638 self.push_digest(&mut paths, metadata);
1639 if let Some(rustc) = read_rustc_metadata(&self.cas, metadata) {
1640 self.push_digest(&mut paths, &rustc.stdout);
1641 self.push_digest(&mut paths, &rustc.stderr);
1642 }
1643 }
1644 if let Some(output_root) = &result.output_root {
1645 self.push_digest(&mut paths, output_root);
1646 let mut pending = vec![output_root.clone()];
1647 let mut visited = HashSet::new();
1648 while let Some(digest) = pending.pop() {
1649 if !visited.insert(digest.clone()) {
1650 continue;
1651 }
1652 let Some((children, named)) = self.directory(&digest) else {
1653 continue;
1654 };
1655 paths.extend(named.iter().copied());
1656 pending.extend(children.iter().cloned());
1657 }
1658 }
1659 }
1660 paths.sort_unstable();
1661 paths.dedup();
1662 let paths = Arc::<[usize]>::from(paths);
1663 locked(&self.actions).insert(action.clone(), paths.clone());
1664 paths
1665 }
1666
1667 fn directory(&self, digest: &CacheDigest) -> Option<DirectoryPaths> {
1669 if let Some(entry) = locked(&self.directories).get(digest) {
1670 return entry.clone();
1671 }
1672 let entry = read_directory(&self.cas, digest).map(|directory| {
1673 let mut paths = Vec::new();
1674 for file in &directory.files {
1675 self.push_digest(&mut paths, &file.digest);
1676 }
1677 for child in &directory.directories {
1678 self.push_digest(&mut paths, &child.digest);
1679 }
1680 let children = directory
1681 .directories
1682 .into_iter()
1683 .map(|child| child.digest)
1684 .collect::<Arc<[_]>>();
1685 (children, Arc::<[usize]>::from(paths))
1686 });
1687 locked(&self.directories).insert(digest.clone(), entry.clone());
1688 entry
1689 }
1690
1691 fn push_digest(&self, paths: &mut Vec<usize>, digest: &CacheDigest) {
1692 if let Ok(path) = self.cas.path_for(digest) {
1693 paths.push(self.index(path));
1694 }
1695 }
1696
1697 fn index(&self, path: PathBuf) -> usize {
1698 if let Some(index) = locked(&self.paths).indices.get(&path) {
1699 return *index;
1700 }
1701 let size = std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
1704 let mut paths = locked(&self.paths);
1705 if let Some(index) = paths.indices.get(&path) {
1706 return *index;
1707 }
1708 let index = paths.sizes.len();
1709 paths.sizes.push(size);
1710 paths.indices.insert(path, index);
1711 index
1712 }
1713}
1714
1715pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
1717 let mut entries = Vec::new();
1718 for (kind, root, action_result) in [
1719 ("object", store.join(CAS_DIR), false),
1720 ("action result", store.join(ACTION_RESULTS_DIR), true),
1721 ] {
1722 entries.extend(
1723 walk_files(&root)?
1724 .into_iter()
1725 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
1726 .map(|entry| LargestEntry {
1727 kind,
1728 path: entry.path,
1729 bytes: entry.size,
1730 }),
1731 );
1732 }
1733 entries.sort_by(|left, right| {
1734 right
1735 .bytes
1736 .cmp(&left.bytes)
1737 .then_with(|| left.path.cmp(&right.path))
1738 });
1739 entries.truncate(limit);
1740 Ok(entries)
1741}
1742
1743pub fn verify(store: &Path) -> Result<VerifyOutcome> {
1745 let cas = LocalCas::new(store);
1746 let action_cache = mbx_cache_core::LocalActionCache::new(store);
1747 let virtually_removed = HashSet::new();
1750 let mut outcome = VerifyOutcome::default();
1751 let mut trees = HashMap::new();
1752 for entry in walk_files(&store.join(CAS_DIR))? {
1753 let Some(digest) = addressed_digest(store, &entry.path, false) else {
1754 continue;
1755 };
1756 outcome.checked_objects += 1;
1757 if !matches!(cas.find(&digest), Ok(Some(_))) {
1758 outcome.problems.push(entry.path);
1759 }
1760 }
1761 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
1762 let Some(digest) = addressed_digest(store, &entry.path, true) else {
1763 continue;
1764 };
1765 outcome.checked_action_results += 1;
1766 if !matches!(action_cache.find(&digest), Ok(Some(_)))
1767 || action_result_is_dangling(&cas, &entry.path, &virtually_removed, true, &mut trees)?
1768 {
1769 outcome.problems.push(entry.path);
1770 }
1771 }
1772 outcome.problems.sort();
1773 Ok(outcome)
1774}
1775
1776pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
1778 let mut outcome = RemoveProjectOutcome::default();
1779 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
1780 if read_checkout_record(&entry.path)
1781 .is_some_and(|record| record.workspace_root == workspace_root)
1782 {
1783 std::fs::remove_file(&entry.path)?;
1784 outcome.removed_checkout_records += 1;
1785 if let Some(parent) = entry.path.parent() {
1786 let _ = std::fs::remove_dir(parent);
1787 }
1788 }
1789 }
1790 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
1791 Ok(()) => {}
1792 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1793 Err(error) => return Err(error.into()),
1794 }
1795 Ok(outcome)
1796}
1797
1798pub fn record_checkout(
1810 store: &Path,
1811 identity: &str,
1812 workspace_root: &Path,
1813 target_dir: &Path,
1814) -> Result<()> {
1815 let record = CheckoutRecord {
1816 version: CHECKOUT_RECORD_VERSION,
1817 workspace_root: workspace_root.to_path_buf(),
1818 target_dir: target_dir.to_path_buf(),
1819 updated_secs: SystemTime::now()
1820 .duration_since(UNIX_EPOCH)
1821 .map(|since| since.as_secs())
1822 .unwrap_or_default(),
1823 };
1824 let mut contents = serde_json::to_vec(&record)?;
1825 contents.push(b'\n');
1826 write_advisory(
1829 &checkout_record_path(store, identity, workspace_root),
1830 &contents,
1831 )
1832}
1833
1834fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1835 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1839 store
1840 .join(CHECKOUTS_DIR)
1841 .join(identity)
1842 .join(format!("{key}.json"))
1843}
1844
1845pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1855 gc_with_mode(store, max_bytes, false, HashSet::new())
1856}
1857
1858pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1860 gc_with_mode(store, max_bytes, true, HashSet::new())
1861}
1862
1863#[cfg(test)]
1864type DanglingReadHook = Box<dyn FnMut(&Path)>;
1865
1866#[cfg(test)]
1867thread_local! {
1868 static ACTION_RECORD_CLAIMS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1869 static AFTER_DANGLING_READ: std::cell::RefCell<Option<DanglingReadHook>> =
1871 const { std::cell::RefCell::new(None) };
1872}
1873
1874fn gc_with_mode(
1877 store: &Path,
1878 max_bytes: u64,
1879 dry_run: bool,
1880 already_removed: HashSet<PathBuf>,
1881) -> Result<GcOutcome> {
1882 let mut objects = walk_files(&store.join(CAS_DIR))?;
1883 let mut results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1884 objects.retain(|entry| !already_removed.contains(&entry.path));
1885 results.retain(|entry| !already_removed.contains(&entry.path));
1886 let mut live_bytes = objects
1887 .iter()
1888 .chain(results.iter())
1889 .map(|entry| entry.size)
1890 .sum::<u64>();
1891
1892 let mut outcome = GcOutcome::default();
1893
1894 let checkouts = scan_checkouts(store)?;
1897 for path in &checkouts.stale_records {
1898 if dry_run || matches!(remove(path)?, Removal::Removed) {
1899 outcome.removed_checkout_records += 1;
1900 }
1901 if !dry_run && let Some(parent) = path.parent() {
1904 let _ = std::fs::remove_dir(parent);
1905 }
1906 }
1907
1908 let manifests = prune_task_manifests(store, &checkouts.live_identities, dry_run);
1909 outcome.removed_task_manifests += manifests.removed_manifests;
1910 outcome.removed_bytes += manifests.removed_bytes;
1911
1912 let mut virtually_removed = already_removed;
1913 if live_bytes > max_bytes {
1914 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1918 rooted.extend(rooted_action_objects(
1925 store,
1926 grouped_receipt_actions(store)?,
1927 ));
1928 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1932 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1933 if dry_run {
1934 virtually_removed.insert(path.to_path_buf());
1935 Ok(Removal::Removed)
1936 } else {
1937 remove(path)
1938 }
1939 })?;
1940 live_bytes = evicted.remaining_bytes;
1941 outcome.removed_objects += evicted.removed_objects;
1942 outcome.removed_bytes += evicted.removed_bytes;
1943 }
1944
1945 let walk_trees = outcome.removed_objects > 0;
1958 let cas = LocalCas::new(store);
1959 let mut trees = HashMap::new();
1960 for entry in &results {
1961 match action_result_is_dangling(
1962 &cas,
1963 &entry.path,
1964 &virtually_removed,
1965 walk_trees,
1966 &mut trees,
1967 ) {
1968 Ok(false) => continue,
1969 Ok(true) => {}
1970 Err(error)
1972 if error
1973 .chain()
1974 .filter_map(|cause| cause.downcast_ref::<std::io::Error>())
1975 .any(held_by_another_process) =>
1976 {
1977 log::debug!(
1978 "could not inspect {} for eviction: {error}",
1979 entry.path.display()
1980 );
1981 continue;
1982 }
1983 Err(error) => return Err(error),
1984 }
1985 let removal = if dry_run {
1986 Removal::Removed
1987 } else {
1988 #[cfg(test)]
1989 AFTER_DANGLING_READ.with(|hook| {
1990 if let Some(hook) = hook.borrow_mut().as_mut() {
1991 hook(&entry.path);
1992 }
1993 });
1994 #[cfg(test)]
1995 ACTION_RECORD_CLAIMS.with(|count| count.set(count.get() + 1));
1996 match ActionSlot::try_claim(&entry.path) {
1997 Err(error) if held_by_another_process(&error) => {
1998 log::debug!(
1999 "could not claim {} for eviction: {error}",
2000 entry.path.display()
2001 );
2002 Removal::Blocked
2003 }
2004 Err(error) => return Err(error.into()),
2005 Ok(None) => Removal::Blocked,
2006 Ok(Some(slot)) => match slot.read()? {
2007 None => Removal::Missing,
2008 Some(bytes) => {
2009 if !action_result_bytes_are_dangling(
2012 &cas,
2013 &bytes,
2014 &virtually_removed,
2015 walk_trees,
2016 &mut HashMap::new(),
2017 )? {
2018 continue;
2019 }
2020 match slot.remove() {
2021 Ok(true) => Removal::Removed,
2022 Ok(false) => Removal::Missing,
2023 Err(error) if held_by_another_process(&error) => {
2026 log::debug!("could not evict {}: {error}", entry.path.display());
2027 Removal::Blocked
2028 }
2029 Err(error) => {
2030 return Err(error).wrap_err_with(|| {
2031 format!("failed to evict {}", entry.path.display())
2032 });
2033 }
2034 }
2035 }
2036 },
2037 }
2038 };
2039 match removal {
2040 Removal::Removed => {
2041 live_bytes = live_bytes.saturating_sub(entry.size);
2042 outcome.removed_action_results += 1;
2043 outcome.removed_bytes += entry.size;
2044 }
2045 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2046 Removal::Blocked => {}
2047 }
2048 }
2049
2050 let sessions = prune_sessions(store, dry_run)?;
2051 outcome.removed_session_streams += sessions.removed_streams;
2052 outcome.removed_bytes += sessions.removed_bytes;
2053 prune_import_staging(store, dry_run);
2054
2055 outcome.remaining_bytes = live_bytes;
2056 Ok(outcome)
2057}
2058
2059pub fn gc_stale_compilers(store: &Path, max_age: Duration, dry_run: bool) -> Result<GcOutcome> {
2073 gc_compilers(
2074 store,
2075 &CompilerSelection {
2076 max_age: Some(max_age),
2077 release: None,
2078 },
2079 dry_run,
2080 )
2081}
2082
2083#[derive(Debug, Clone, Default)]
2085pub struct CompilerSelection {
2086 pub max_age: Option<Duration>,
2088 pub release: Option<String>,
2096}
2097
2098pub fn gc_compilers(
2103 store: &Path,
2104 selection: &CompilerSelection,
2105 dry_run: bool,
2106) -> Result<GcOutcome> {
2107 stale_compilers_pass(store, selection, dry_run).map(|(outcome, _)| outcome)
2108}
2109
2110pub fn gc_dry_run_after_stale(
2113 store: &Path,
2114 max_bytes: u64,
2115 max_age: Duration,
2116) -> Result<GcOutcome> {
2117 gc_dry_run_after_compilers(
2118 store,
2119 max_bytes,
2120 &CompilerSelection {
2121 max_age: Some(max_age),
2122 release: None,
2123 },
2124 )
2125}
2126
2127pub fn gc_dry_run_after_compilers(
2130 store: &Path,
2131 max_bytes: u64,
2132 selection: &CompilerSelection,
2133) -> Result<GcOutcome> {
2134 let (mut stale, planned) = stale_compilers_pass(store, selection, true)?;
2135 let budget = gc_with_mode(store, max_bytes, true, planned)?;
2136 stale.removed_objects += budget.removed_objects;
2137 stale.removed_action_results += budget.removed_action_results;
2138 stale.removed_checkout_records += budget.removed_checkout_records;
2139 stale.removed_task_manifests += budget.removed_task_manifests;
2140 stale.removed_session_streams += budget.removed_session_streams;
2141 stale.removed_bytes += budget.removed_bytes;
2142 stale.remaining_bytes = budget.remaining_bytes;
2143 Ok(stale)
2144}
2145
2146fn stale_compilers_pass(
2148 store: &Path,
2149 selection: &CompilerSelection,
2150 dry_run: bool,
2151) -> Result<(GcOutcome, HashSet<PathBuf>)> {
2152 struct Attributed {
2153 entry: Entry,
2154 action: CacheDigest,
2155 }
2156
2157 let cas = LocalCas::new(store);
2158 let mut by_version: BTreeMap<String, Vec<Attributed>> = BTreeMap::new();
2159 let mut unattributed = Vec::new();
2160 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
2161 let result = read_result_untouched(&entry.path)
2162 .ok()
2163 .and_then(|bytes| serde_json::from_slice::<RemoteActionResult>(&bytes).ok());
2164 let Some(result) = result else {
2165 continue;
2166 };
2167 match rustc_version_of(&cas, &result.action) {
2168 Some(version) => by_version.entry(version).or_default().push(Attributed {
2169 entry,
2170 action: result.action,
2171 }),
2172 None => unattributed.push(result.action),
2173 }
2174 }
2175
2176 let last_used = |results: &[Attributed]| results.iter().map(|r| r.entry.used).max();
2177 let newest = by_version
2178 .iter()
2179 .max_by_key(|(_, results)| last_used(results))
2180 .map(|(version, _)| version.clone());
2181 let cutoff = selection.max_age.map(|max_age| {
2182 SystemTime::now()
2183 .checked_sub(max_age)
2184 .unwrap_or(SystemTime::UNIX_EPOCH)
2185 });
2186 let mut outcome = GcOutcome::default();
2187 let mut stale = Vec::new();
2188 let mut kept_actions = unattributed;
2189 for (version, results) in by_version {
2190 let idle = cutoff
2191 .zip(last_used(&results))
2192 .is_some_and(|(cutoff, used)| used < cutoff)
2193 && Some(&version) != newest.as_ref();
2194 let named = selection
2195 .release
2196 .as_deref()
2197 .is_some_and(|release| names_compiler(release, &version));
2198 if idle || named {
2199 stale.extend(results);
2200 } else {
2201 kept_actions.extend(results.into_iter().map(|r| r.action));
2202 }
2203 }
2204 let pending = grouped_receipt_actions(store)?;
2207 let (pinned, stale): (Vec<_>, Vec<_>) =
2208 stale.into_iter().partition(|r| pending.contains(&r.action));
2209 kept_actions.extend(pinned.into_iter().map(|r| r.action));
2210 let mut planned = HashSet::new();
2211 if stale.is_empty() {
2212 return Ok((outcome, planned));
2213 }
2214
2215 let doomed = rooted_action_objects(store, stale.iter().map(|r| r.action.clone()));
2216 let kept = rooted_action_objects(store, kept_actions);
2217 for result in &stale {
2218 if dry_run || matches!(remove(&result.entry.path)?, Removal::Removed) {
2219 outcome.removed_action_results += 1;
2220 outcome.removed_bytes += result.entry.size;
2221 planned.insert(result.entry.path.clone());
2222 }
2223 }
2224 for path in doomed.difference(&kept) {
2225 let Ok(metadata) = std::fs::metadata(path) else {
2226 continue;
2227 };
2228 if dry_run || matches!(remove(path)?, Removal::Removed) {
2229 outcome.removed_objects += 1;
2230 outcome.removed_bytes += metadata.len();
2231 planned.insert(path.clone());
2232 }
2233 }
2234 Ok((outcome, planned))
2235}
2236
2237fn read_result_untouched(path: &Path) -> std::io::Result<Vec<u8>> {
2247 #[cfg(target_os = "linux")]
2248 {
2249 use std::io::Read as _;
2250 use std::os::unix::fs::OpenOptionsExt as _;
2251 if let Ok(mut file) = std::fs::OpenOptions::new()
2252 .read(true)
2253 .custom_flags(libc::O_NOATIME)
2254 .open(path)
2255 {
2256 let mut bytes = Vec::new();
2257 file.read_to_end(&mut bytes)?;
2258 return Ok(bytes);
2259 }
2260 }
2261 let before = std::fs::metadata(path)?;
2262 let bytes = std::fs::read(path)?;
2263 let _ = filetime::set_file_atime(path, filetime::FileTime::from_last_access_time(&before));
2264 Ok(bytes)
2265}
2266
2267fn names_compiler(wanted: &str, version: &str) -> bool {
2273 let wanted = wanted.trim();
2274 let wanted = wanted.strip_prefix("rustc ").unwrap_or(wanted);
2275 if wanted.is_empty() {
2276 return false;
2277 }
2278 let line = version.split(';').next().unwrap_or(version).trim();
2279 let line = line.strip_prefix("rustc ").unwrap_or(line);
2280 line == wanted || line.split_whitespace().next() == Some(wanted)
2281}
2282
2283fn rustc_version_of(cas: &LocalCas, action: &CacheDigest) -> Option<String> {
2285 let bytes = read_result_untouched(&cas.path_for(action).ok()?).ok()?;
2286 let descriptor: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
2287 if descriptor.get("kind")?.as_str()? != "rustc" {
2288 return None;
2289 }
2290 Some(
2291 descriptor
2292 .pointer("/compiler/rustc_version")?
2293 .as_str()?
2294 .to_owned(),
2295 )
2296}
2297
2298fn prune_import_staging(store: &Path, dry_run: bool) {
2310 if dry_run {
2311 return;
2312 }
2313 let root = store.join(IMPORT_STAGING_DIR);
2314 let Ok(entries) = read_dir_or_empty(&root) else {
2315 return;
2316 };
2317 for entry in entries {
2318 let Ok(metadata) = entry.metadata() else {
2319 continue;
2320 };
2321 let abandoned = metadata
2322 .modified()
2323 .ok()
2324 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
2325 .is_some_and(|age| age >= IMPORT_STAGING_RETENTION);
2326 if !abandoned {
2327 continue;
2328 }
2329 let path = entry.path();
2330 if !metadata.is_dir() {
2331 if path
2334 .extension()
2335 .is_some_and(|extension| extension == "lock")
2336 && !claimed_staging_path(&path).exists()
2337 {
2338 let _ = std::fs::remove_file(&path);
2339 }
2340 continue;
2341 }
2342 let lock_path = import_staging_lock_path(&path);
2348 let Ok(mut lock) = fslock::LockFile::open(&lock_path) else {
2349 continue;
2350 };
2351 if !matches!(lock.try_lock(), Ok(true)) {
2352 continue;
2353 }
2354 if let Err(error) = std::fs::remove_dir_all(&path) {
2355 log::debug!(
2356 "could not remove abandoned import staging {}: {error}",
2357 path.display()
2358 );
2359 continue;
2360 }
2361 drop(lock);
2362 let _ = std::fs::remove_file(&lock_path);
2363 }
2364}
2365
2366fn claimed_staging_path(lock: &Path) -> PathBuf {
2368 lock.with_extension("")
2369}
2370
2371fn import_staging_lock_path(staging: &Path) -> PathBuf {
2376 let mut name = staging.as_os_str().to_os_string();
2377 name.push(".lock");
2378 PathBuf::from(name)
2379}
2380
2381struct StagingClaim {
2387 lock: Option<fslock::LockFile>,
2388 path: PathBuf,
2389}
2390
2391impl Drop for StagingClaim {
2392 fn drop(&mut self) {
2393 drop(self.lock.take());
2396 let _ = std::fs::remove_file(&self.path);
2397 }
2398}
2399
2400fn lock_import_staging(staging: &Path) -> Result<StagingClaim> {
2402 let path = import_staging_lock_path(staging);
2403 let mut lock = fslock::LockFile::open(&path)
2404 .wrap_err_with(|| format!("failed to open {}", path.display()))?;
2405 lock.lock()?;
2406 Ok(StagingClaim {
2407 lock: Some(lock),
2408 path,
2409 })
2410}
2411
2412#[derive(Debug, Default, PartialEq, Eq)]
2413struct SessionPrune {
2414 removed_streams: u64,
2415 removed_bytes: u64,
2416}
2417
2418fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
2419 let mut prune = SessionPrune::default();
2420 let ids = events::session_ids(store);
2421 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
2422 for (index, id) in ids.iter().enumerate() {
2423 let paths = events::session_paths(store, id);
2424 let metadata = match std::fs::metadata(&paths.events) {
2425 Ok(metadata) => metadata,
2426 Err(_) => continue,
2427 };
2428 let stale = metadata
2429 .modified()
2430 .ok()
2431 .and_then(|at| SystemTime::now().duration_since(at).ok())
2432 .is_some_and(|age| age > SESSION_RETENTION);
2433 if index >= surplus && !stale {
2434 continue;
2435 }
2436 if events::session_is_live(store, id) {
2437 continue;
2438 }
2439 if dry_run {
2440 prune.removed_streams += 1;
2441 prune.removed_bytes += metadata.len();
2442 continue;
2443 }
2444 if matches!(remove(&paths.events)?, Removal::Removed) {
2445 prune.removed_streams += 1;
2446 prune.removed_bytes += metadata.len();
2447 let _ = std::fs::remove_file(&paths.lock);
2448 }
2449 }
2450 if !dry_run {
2451 for lock in events::orphaned_locks(store) {
2452 let _ = std::fs::remove_file(lock);
2453 }
2454 }
2455 Ok(prune)
2456}
2457
2458pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
2465 if !claim_sweep(store, interval)? {
2466 return Ok(None);
2467 }
2468 gc(store, max_bytes).map(Some)
2469}
2470
2471pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
2480 let lock_path = store.join(SWEEP_LOCK);
2481 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
2482 let mut lock = fslock::LockFile::open(&lock_path)?;
2483 lock.lock()?;
2484
2485 let stamp = store.join(SWEEP_STAMP);
2486 if sweep_stamp_is_fresh(&stamp, interval) {
2487 return Ok(false);
2488 }
2489 record_sweep(store)?;
2490 Ok(true)
2491}
2492
2493pub fn record_sweep(store: &Path) -> Result<()> {
2499 let path = store.join(SWEEP_STAMP);
2500 std::fs::create_dir_all(path.parent().expect("the sweep stamp has a parent"))?;
2501 let stamp = std::fs::OpenOptions::new()
2502 .create(true)
2503 .write(true)
2504 .truncate(false)
2505 .open(path)?;
2506 stamp.set_modified(SystemTime::now())?;
2507 Ok(())
2508}
2509
2510pub fn sweep_is_due(store: &Path, interval: Duration) -> bool {
2517 !sweep_stamp_is_fresh(&store.join(SWEEP_STAMP), interval)
2518}
2519
2520fn sweep_stamp_is_fresh(stamp: &Path, interval: Duration) -> bool {
2521 std::fs::metadata(stamp)
2522 .and_then(|metadata| metadata.modified())
2523 .ok()
2524 .and_then(|modified| modified.elapsed().ok())
2525 .is_some_and(|since| since < interval)
2526}
2527
2528struct CheckoutScan {
2530 live_identities: BTreeSet<String>,
2531 live_records: u64,
2532 stale_records: Vec<PathBuf>,
2533}
2534
2535fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
2536 let root = store.join(CHECKOUTS_DIR);
2537 let mut scan = CheckoutScan {
2538 live_identities: BTreeSet::new(),
2539 live_records: 0,
2540 stale_records: Vec::new(),
2541 };
2542 let listing = match std::fs::read_dir(&root) {
2543 Ok(listing) => listing,
2544 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
2545 Err(error) => {
2546 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
2547 }
2548 };
2549 for entry in listing {
2550 let entry = entry?;
2551 let name = entry.file_name().to_string_lossy().into_owned();
2552 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
2558 continue;
2559 }
2560 for record in walk_files(&entry.path())? {
2561 match read_checkout_record(&record.path) {
2562 Some(checkout) if claim_is_live(store, &checkout) => {
2563 scan.live_identities.insert(name.clone());
2564 scan.live_records += 1;
2565 }
2566 Some(_) => scan.stale_records.push(record.path),
2567 None => {}
2570 }
2571 }
2572 }
2573 Ok(scan)
2574}
2575
2576fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
2577 let bytes = std::fs::read(path).ok()?;
2578 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
2579 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
2580}
2581
2582fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
2584 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
2585 Ok(walk_files(&root)?
2586 .into_iter()
2587 .filter_map(|entry| read_build_receipt(&entry.path))
2588 .flat_map(|receipt| receipt.predictions.into_iter())
2589 .map(|prediction| prediction.action)
2590 .collect())
2591}
2592
2593fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
2596 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
2597}
2598
2599fn claim_has_expired(record: &CheckoutRecord) -> bool {
2600 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
2601 return false;
2602 };
2603 now.as_secs()
2604 .saturating_sub(record.updated_secs)
2605 .gt(&CHECKOUT_RETENTION.as_secs())
2606}
2607
2608pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
2621 if !matches!(workspace_root.try_exists(), Ok(false)) {
2622 return true;
2623 }
2624
2625 let Ok(store_metadata) = std::fs::metadata(store) else {
2626 return true;
2627 };
2628 for ancestor in workspace_root.ancestors().skip(1) {
2629 match std::fs::metadata(ancestor) {
2630 Ok(ancestor_metadata) => {
2631 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
2632 }
2633 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2634 Err(_) => return true,
2635 }
2636 }
2637 true
2638}
2639
2640pub fn checkout_is_live(workspace_root: &Path) -> bool {
2646 let Some(parent) = workspace_root.parent() else {
2647 return true;
2648 };
2649 checkout_is_live_on(parent, workspace_root)
2650}
2651
2652pub fn same_filesystem_paths(a: &Path, b: &Path) -> bool {
2658 let (Ok(a_metadata), Ok(b_metadata)) = (std::fs::metadata(a), std::fs::metadata(b)) else {
2659 return false;
2660 };
2661 same_filesystem(a, &a_metadata, b, &b_metadata)
2662}
2663
2664#[cfg(unix)]
2665fn same_filesystem(
2666 _a_path: &Path,
2667 a: &std::fs::Metadata,
2668 _b_path: &Path,
2669 b: &std::fs::Metadata,
2670) -> bool {
2671 use std::os::unix::fs::MetadataExt as _;
2672 a.dev() == b.dev()
2673}
2674
2675#[cfg(windows)]
2676fn same_filesystem(
2677 a_path: &Path,
2678 _a: &std::fs::Metadata,
2679 b_path: &Path,
2680 _b: &std::fs::Metadata,
2681) -> bool {
2682 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
2683}
2684
2685#[cfg(windows)]
2686fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
2687 use std::os::windows::ffi::OsStrExt as _;
2688 use windows_sys::Win32::Storage::FileSystem::{
2689 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
2690 };
2691
2692 let path = path
2693 .as_os_str()
2694 .encode_wide()
2695 .chain(Some(0))
2696 .collect::<Vec<_>>();
2697 let mut mount = vec![0_u16; 261];
2701 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
2704 return None;
2705 }
2706 let mut volume = vec![0_u16; 261];
2707 if unsafe {
2710 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
2711 } == 0
2712 {
2713 return None;
2714 }
2715 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
2716 Some(volume)
2717}
2718
2719#[cfg(not(any(unix, windows)))]
2720fn same_filesystem(
2721 _a_path: &Path,
2722 _a: &std::fs::Metadata,
2723 _b_path: &Path,
2724 _b: &std::fs::Metadata,
2725) -> bool {
2726 false
2727}
2728
2729fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
2737 let mut actions = BTreeSet::new();
2738 for identity in identities {
2739 let manifest_actions = match task_manifest_actions(store, identity) {
2742 Ok(actions) => actions,
2743 Err(error) => {
2744 log::debug!("could not read the manifest for {identity}: {error}");
2745 continue;
2746 }
2747 };
2748 actions.extend(manifest_actions);
2749 }
2750 Ok(rooted_action_objects(store, actions))
2751}
2752
2753fn rooted_action_objects(
2755 store: &Path,
2756 actions: impl IntoIterator<Item = CacheDigest>,
2757) -> HashSet<PathBuf> {
2758 let cas = LocalCas::new(store);
2759 let mut rooted = HashSet::new();
2760 let mut visited = BTreeSet::new();
2761 for action in actions {
2762 let Some(result) = read_action_result(store, &action) else {
2763 continue;
2764 };
2765 root_digest(&cas, &mut rooted, &result.action);
2766 if let Some(metadata) = &result.metadata {
2767 root_digest(&cas, &mut rooted, metadata);
2768 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
2775 root_digest(&cas, &mut rooted, &rustc.stdout);
2776 root_digest(&cas, &mut rooted, &rustc.stderr);
2777 }
2778 }
2779 if let Some(output_root) = &result.output_root {
2780 root_digest(&cas, &mut rooted, output_root);
2781 root_tree(&cas, output_root, &mut rooted, &mut visited);
2782 }
2783 }
2784 rooted
2785}
2786
2787fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
2788 let path = mbx_cache_core::LocalActionCache::new(store)
2789 .path_for(action)
2790 .ok()?;
2791 let bytes = read_result_untouched(&path).ok()?;
2792 serde_json::from_slice(&bytes).ok()
2793}
2794
2795fn root_tree(
2796 cas: &LocalCas,
2797 output_root: &CacheDigest,
2798 rooted: &mut HashSet<PathBuf>,
2799 visited: &mut BTreeSet<CacheDigest>,
2800) {
2801 let mut pending = vec![output_root.clone()];
2802 while let Some(digest) = pending.pop() {
2803 if !visited.insert(digest.clone()) {
2804 continue;
2805 }
2806 let Some(directory) = read_directory(cas, &digest) else {
2807 continue;
2808 };
2809 for file in &directory.files {
2810 root_digest(cas, rooted, &file.digest);
2811 }
2812 for child in &directory.directories {
2813 root_digest(cas, rooted, &child.digest);
2814 pending.push(child.digest.clone());
2815 }
2816 }
2817}
2818
2819fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
2820 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2821 serde_json::from_slice(&bytes).ok()
2822}
2823
2824fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
2825 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
2826 serde_json::from_slice(&bytes).ok()
2827}
2828
2829fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
2830 if let Ok(path) = cas.path_for(digest) {
2831 rooted.insert(path);
2832 }
2833}
2834
2835#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2836enum Removal {
2837 Removed,
2838 Missing,
2839 Blocked,
2840}
2841
2842fn remove(path: &Path) -> Result<Removal> {
2843 match std::fs::remove_file(path) {
2844 Ok(()) => Ok(Removal::Removed),
2845 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
2847 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
2852 log::debug!("could not evict {}: {error}", path.display());
2853 Ok(Removal::Blocked)
2854 }
2855 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
2856 }
2857}
2858
2859#[derive(Debug, Default, PartialEq, Eq)]
2860struct ObjectEvictions {
2861 removed_objects: u64,
2862 removed_bytes: u64,
2863 remaining_bytes: u64,
2864}
2865
2866fn evict_objects(
2869 objects: &[Entry],
2870 rooted: &HashSet<PathBuf>,
2871 mut live_bytes: u64,
2872 max_bytes: u64,
2873 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
2874) -> Result<ObjectEvictions> {
2875 let mut outcome = ObjectEvictions::default();
2876 let mut blocked_unrooted = false;
2877 for entry in objects {
2878 if live_bytes <= max_bytes {
2879 break;
2880 }
2881 let is_rooted = rooted.contains(&entry.path);
2882 if is_rooted && blocked_unrooted {
2883 break;
2887 }
2888 match remove_file(&entry.path)? {
2889 Removal::Removed => {
2890 live_bytes = live_bytes.saturating_sub(entry.size);
2891 outcome.removed_objects += 1;
2892 outcome.removed_bytes += entry.size;
2893 }
2894 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
2895 Removal::Blocked if !is_rooted => blocked_unrooted = true,
2896 Removal::Blocked => {}
2897 }
2898 }
2899 outcome.remaining_bytes = live_bytes;
2900 Ok(outcome)
2901}
2902
2903fn action_result_is_dangling(
2925 cas: &LocalCas,
2926 path: &Path,
2927 virtually_removed: &HashSet<PathBuf>,
2928 walk_tree: bool,
2929 trees: &mut HashMap<CacheDigest, bool>,
2930) -> Result<bool> {
2931 let bytes = match read_result_untouched(path) {
2932 Ok(bytes) => bytes,
2933 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2934 Err(error) => {
2935 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
2936 }
2937 };
2938 action_result_bytes_are_dangling(cas, &bytes, virtually_removed, walk_tree, trees)
2939}
2940
2941fn held_by_another_process(error: &std::io::Error) -> bool {
2948 error.kind() == std::io::ErrorKind::PermissionDenied
2949 || (cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)))
2950}
2951
2952fn action_result_bytes_are_dangling(
2953 cas: &LocalCas,
2954 bytes: &[u8],
2955 virtually_removed: &HashSet<PathBuf>,
2956 walk_tree: bool,
2957 trees: &mut HashMap<CacheDigest, bool>,
2958) -> Result<bool> {
2959 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(bytes) else {
2961 return Ok(false);
2962 };
2963 let missing = |digest: &CacheDigest| match cas.path_for(digest) {
2964 Ok(path) => virtually_removed.contains(&path) || matches!(path.try_exists(), Ok(false)),
2966 Err(_) => true,
2968 };
2969 for digest in [
2970 Some(&result.action),
2971 result.metadata.as_ref(),
2972 result.output_root.as_ref(),
2973 ]
2974 .into_iter()
2975 .flatten()
2976 {
2977 if missing(digest) {
2978 return Ok(true);
2979 }
2980 }
2981 if walk_tree && let Some(output_root) = &result.output_root {
2982 return Ok(!tree_is_complete(cas, output_root, &missing, trees));
2983 }
2984 Ok(false)
2985}
2986
2987fn tree_is_complete(
2990 cas: &LocalCas,
2991 digest: &CacheDigest,
2992 missing: &impl Fn(&CacheDigest) -> bool,
2993 trees: &mut HashMap<CacheDigest, bool>,
2994) -> bool {
2995 if let Some(&complete) = trees.get(digest) {
2996 return complete;
2997 }
2998 trees.insert(digest.clone(), true);
3001 let complete = read_directory(cas, digest).is_none_or(|directory| {
3003 directory.files.iter().all(|file| !missing(&file.digest))
3004 && directory.directories.iter().all(|child| {
3005 !missing(&child.digest) && tree_is_complete(cas, &child.digest, missing, trees)
3006 })
3007 });
3008 trees.insert(digest.clone(), complete);
3009 complete
3010}
3011
3012struct Entry {
3013 path: PathBuf,
3014 size: u64,
3015 used: SystemTime,
3016}
3017
3018fn walk_files(root: &Path) -> Result<Vec<Entry>> {
3019 let mut entries = Vec::new();
3020 let mut pending = vec![root.to_path_buf()];
3021 while let Some(directory) = pending.pop() {
3022 let listing = match std::fs::read_dir(&directory) {
3023 Ok(listing) => listing,
3024 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3025 Err(error) => {
3026 return Err(error)
3027 .wrap_err_with(|| format!("failed to read {}", directory.display()));
3028 }
3029 };
3030 for entry in listing {
3031 let entry = entry?;
3032 let metadata = match entry.metadata() {
3033 Ok(metadata) => metadata,
3034 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3036 Err(error) => return Err(error.into()),
3037 };
3038 if metadata.is_dir() {
3039 pending.push(entry.path());
3040 } else if metadata.is_file() {
3041 let used = metadata
3042 .accessed()
3043 .or_else(|_| metadata.modified())
3044 .unwrap_or(SystemTime::UNIX_EPOCH);
3045 entries.push(Entry {
3046 path: entry.path(),
3047 size: metadata.len(),
3048 used,
3049 });
3050 }
3051 }
3052 }
3053 Ok(entries)
3054}
3055
3056fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
3057 match std::fs::read_dir(root) {
3058 Ok(entries) => entries
3059 .collect::<std::io::Result<Vec<_>>>()
3060 .map_err(Into::into),
3061 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
3062 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
3063 }
3064}
3065
3066fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
3067 let name = path.file_name()?.to_str()?;
3068 let name = if action_result {
3069 name.strip_suffix(".json")?
3070 } else {
3071 name
3072 };
3073 let (hash, size) = name.rsplit_once('-')?;
3074 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
3075 let digest = CacheDigest {
3076 algorithm,
3077 hash: hash.to_string(),
3078 size: size.parse().ok()?,
3079 };
3080 digest.validate().ok()?;
3081 Some(digest)
3082}
3083
3084fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
3085 let digest = digest_from_path(path, action_result)?;
3086 let canonical = if action_result {
3087 mbx_cache_core::LocalActionCache::new(store)
3088 .path_for(&digest)
3089 .ok()?
3090 } else {
3091 LocalCas::new(store).path_for(&digest).ok()?
3092 };
3093 (canonical == path).then_some(digest)
3094}
3095
3096fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
3097 *cache
3098 .entry(root.to_path_buf())
3099 .or_insert_with(|| tree_bytes(root))
3100}
3101
3102fn tree_bytes(root: &Path) -> u64 {
3103 let Ok(root_metadata) = std::fs::metadata(root) else {
3104 return 0;
3105 };
3106 if root_metadata.is_file() {
3107 return root_metadata.len();
3108 }
3109 let Ok(entries) = std::fs::read_dir(root) else {
3110 return 0;
3111 };
3112 let mut bytes = 0_u64;
3113 let mut pending = entries
3114 .filter_map(std::result::Result::ok)
3115 .map(|entry| entry.path())
3116 .collect::<Vec<_>>();
3117 while let Some(path) = pending.pop() {
3118 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
3119 continue;
3120 };
3121 if metadata.is_file() {
3122 bytes = bytes.saturating_add(metadata.len());
3123 } else if metadata.is_dir()
3124 && let Ok(entries) = std::fs::read_dir(path)
3125 {
3126 pending.extend(
3127 entries
3128 .filter_map(std::result::Result::ok)
3129 .map(|entry| entry.path()),
3130 );
3131 }
3132 }
3133 bytes
3134}
3135
3136#[cfg(test)]
3137#[path = "store_tests.rs"]
3138mod tests;
3139
3140fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
3143 let parent = path
3144 .parent()
3145 .filter(|parent| !parent.as_os_str().is_empty())
3146 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
3147 std::fs::create_dir_all(parent)
3148 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
3149 let mut temporary = tempfile::Builder::new()
3150 .prefix(".mbx-")
3151 .tempfile_in(parent)?;
3152 use std::io::Write as _;
3153 temporary.write_all(contents)?;
3154 temporary
3155 .persist(path)
3156 .map_err(|error| error.error)
3157 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
3158 Ok(())
3159}
3160
3161fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
3162 let parent = path
3163 .parent()
3164 .filter(|parent| !parent.as_os_str().is_empty())
3165 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
3166 std::fs::create_dir_all(parent)
3167 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
3168 let mut temporary = tempfile::Builder::new()
3169 .prefix(".mbx-")
3170 .tempfile_in(parent)?;
3171 use std::io::Write as _;
3172 temporary.write_all(contents)?;
3173 temporary.as_file_mut().sync_all()?;
3174 temporary
3175 .persist(path)
3176 .map_err(|error| error.error)
3177 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
3178 Ok(())
3179}