1mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16 ActionPrediction, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata,
17 TaskActionManifest, is_task_identity, merge_task_action_predictions, task_manifest_actions,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashSet};
21use std::path::{Component, Path, PathBuf};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24const CAS_DIR: &str = "cas/v1";
25const ACTION_RESULTS_DIR: &str = "action-results/v1";
26const CHECKOUTS_DIR: &str = "checkouts/v1";
27const SWEEP_STAMP: &str = "gc/v1/last-sweep";
28const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
29const CHECKOUT_RECORD_VERSION: u8 = 1;
30const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
31const BUILD_RECEIPT_VERSION: u8 = 1;
32const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
33const EXPORT_VERSION: u8 = 2;
34const LEGACY_EXPORT_VERSION: u8 = 1;
35
36const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
37const MAX_SESSIONS: usize = 256;
38
39const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
49
50#[derive(Debug, Default, PartialEq, Eq)]
51pub struct StoreStats {
52 pub objects: u64,
53 pub object_bytes: u64,
54 pub action_results: u64,
55 pub action_result_bytes: u64,
56 pub live_checkouts: u64,
57 pub stale_checkouts: u64,
62}
63
64impl StoreStats {
65 pub fn total_bytes(&self) -> u64 {
66 self.object_bytes.saturating_add(self.action_result_bytes)
67 }
68}
69
70#[derive(Debug, Default, PartialEq, Eq)]
71pub struct GcOutcome {
72 pub removed_objects: u64,
73 pub removed_action_results: u64,
74 pub removed_checkout_records: u64,
75 pub removed_session_streams: u64,
78 pub removed_bytes: u64,
79 pub remaining_bytes: u64,
80}
81
82#[derive(Debug, PartialEq, Eq)]
83pub struct ProjectUsage {
84 pub workspace_root: PathBuf,
85 pub identities: u64,
86 pub action_bytes: u64,
87 pub target_bytes: u64,
88 pub live: bool,
89}
90
91#[derive(Debug, PartialEq, Eq)]
92pub struct LargestEntry {
93 pub kind: &'static str,
94 pub path: PathBuf,
95 pub bytes: u64,
96}
97
98#[derive(Debug, Default, PartialEq, Eq)]
99pub struct VerifyOutcome {
100 pub checked_objects: u64,
101 pub checked_action_results: u64,
102 pub problems: Vec<PathBuf>,
103}
104
105#[derive(Debug, Default, PartialEq, Eq)]
106pub struct RemoveProjectOutcome {
107 pub removed_checkout_records: u64,
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
111pub struct TransferOutcome {
112 pub actions: u64,
113 pub objects: u64,
114 pub bytes: u64,
115}
116
117#[derive(Debug)]
119#[non_exhaustive]
120pub struct ImportOutcome {
121 pub transfer: TransferOutcome,
122 pub attachments: BTreeMap<String, CacheDigest>,
123}
124
125#[derive(Debug, Default)]
127pub struct ExportAdditions {
128 pub attachments: BTreeMap<String, CacheDigest>,
130 pub objects: BTreeSet<CacheDigest>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
136pub struct WorkspaceTarget {
137 pub workspace_root: PathBuf,
138 pub target_dir: PathBuf,
139}
140
141#[derive(Debug, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143struct ExportManifest {
144 version: u8,
145 tasks: Vec<TaskActionManifest>,
146 actions: Vec<CacheDigest>,
147 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148 attachments: BTreeMap<String, CacheDigest>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 objects: Vec<CacheDigest>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156struct BuildReceipt {
157 version: u8,
158 workspace_root: PathBuf,
159 identity: String,
160 completed_nanos: u64,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 group: Option<String>,
163 predictions: Vec<ActionPrediction>,
164}
165
166pub fn record_build_receipt(
168 store: &Path,
169 run: &str,
170 identity: &str,
171 workspace_root: &Path,
172 group: Option<&str>,
173 predictions: Vec<ActionPrediction>,
174) -> Result<()> {
175 if !is_task_identity(run) || !is_task_identity(identity) {
176 eyre::bail!("invalid build receipt identity");
177 }
178 if !(TaskActionManifest {
179 version: 1,
180 task: identity.to_owned(),
181 predictions: predictions.clone(),
182 })
183 .validate()
184 {
185 eyre::bail!("invalid build receipt prediction");
186 }
187 if let Some(group) = group {
188 validate_export_group(group)?;
189 }
190 if group.is_none()
195 && read_build_receipt(&latest_receipt_path(store, workspace_root)).is_some_and(|latest| {
196 latest.identity == identity
197 && latest.workspace_root == workspace_root
198 && latest.group.is_none()
199 && latest.predictions == predictions
200 })
201 {
202 return Ok(());
203 }
204 let key = workspace_key(workspace_root);
205 let lock_path = store
206 .join(BUILD_RECEIPTS_DIR)
207 .join("locks")
208 .join(format!("{key}.lock"));
209 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
210 let mut lock = fslock::LockFile::open(&lock_path)?;
211 lock.lock()?;
212 let completed_nanos = SystemTime::now()
213 .duration_since(UNIX_EPOCH)
214 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
215 .unwrap_or_default();
216 let receipt = BuildReceipt {
217 version: BUILD_RECEIPT_VERSION,
218 workspace_root: workspace_root.to_path_buf(),
219 identity: identity.to_owned(),
220 completed_nanos,
221 group: group.map(str::to_owned),
222 predictions,
223 };
224 let bytes = serde_json::to_vec(&receipt)?;
225 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
226 if let Some(group) = group {
227 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
228 }
229 Ok(())
230}
231
232fn workspace_key(workspace_root: &Path) -> String {
233 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
234}
235
236fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
237 store
238 .join(BUILD_RECEIPTS_DIR)
239 .join("checkouts")
240 .join(format!("{}.json", workspace_key(workspace_root)))
241}
242
243fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
244 store
245 .join(BUILD_RECEIPTS_DIR)
246 .join("groups")
247 .join(group_key(group))
248 .join(format!("{run}.json"))
249}
250
251fn group_key(group: &str) -> String {
252 CacheDigest::blake3(group.as_bytes()).hash
253}
254
255fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
256 let bytes = std::fs::read(path).ok()?;
257 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
258 let valid = TaskActionManifest {
259 version: 1,
260 task: receipt.identity.clone(),
261 predictions: receipt.predictions.clone(),
262 }
263 .validate();
264 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
265}
266
267fn validate_export_group(group: &str) -> Result<()> {
268 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
269 eyre::bail!("invalid build export group");
270 }
271 Ok(())
272}
273
274pub fn export_checkout(
276 store: &Path,
277 workspace_root: &Path,
278 archive: &Path,
279) -> Result<TransferOutcome> {
280 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
281 .filter(|receipt| receipt.workspace_root == workspace_root)
282 .ok_or_else(|| {
283 eyre::eyre!(
284 "no completed mbx build is recorded for {}",
285 workspace_root.display()
286 )
287 })?;
288 export_receipts(store, vec![receipt], archive, ExportAdditions::default())
289}
290
291pub fn checkout_workspace_target(
293 store: &Path,
294 workspace_root: &Path,
295) -> Result<Option<WorkspaceTarget>> {
296 let Some(receipt) = read_build_receipt(&latest_receipt_path(store, workspace_root))
297 .filter(|receipt| receipt.workspace_root == workspace_root)
298 else {
299 return Ok(None);
300 };
301 Ok(workspace_target_for_receipt(store, &receipt))
302}
303
304pub fn group_workspace_targets(store: &Path, group: &str) -> Result<Vec<WorkspaceTarget>> {
306 validate_export_group(group)?;
307 let root = store
308 .join(BUILD_RECEIPTS_DIR)
309 .join("groups")
310 .join(group_key(group));
311 let targets = walk_files(&root)?
312 .into_iter()
313 .filter_map(|entry| read_build_receipt(&entry.path))
314 .filter(|receipt| receipt.group.as_deref() == Some(group))
315 .filter_map(|receipt| workspace_target_for_receipt(store, &receipt))
316 .collect::<BTreeSet<_>>();
317 Ok(targets.into_iter().collect())
318}
319
320fn workspace_target_for_receipt(store: &Path, receipt: &BuildReceipt) -> Option<WorkspaceTarget> {
321 let record = read_checkout_record(&checkout_record_path(
322 store,
323 &receipt.identity,
324 &receipt.workspace_root,
325 ))?;
326 (record.workspace_root == receipt.workspace_root && record.target_dir != record.workspace_root)
327 .then_some(WorkspaceTarget {
328 workspace_root: record.workspace_root,
329 target_dir: record.target_dir,
330 })
331}
332
333pub fn export_checkout_with(
335 store: &Path,
336 workspace_root: &Path,
337 archive: &Path,
338 additions: ExportAdditions,
339) -> Result<TransferOutcome> {
340 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
341 .filter(|receipt| receipt.workspace_root == workspace_root)
342 .ok_or_else(|| {
343 eyre::eyre!(
344 "no completed mbx build is recorded for {}",
345 workspace_root.display()
346 )
347 })?;
348 export_receipts(store, vec![receipt], archive, additions)
349}
350
351pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
353 export_group_with(store, group, archive, ExportAdditions::default())
354}
355
356pub fn export_group_with(
358 store: &Path,
359 group: &str,
360 archive: &Path,
361 additions: ExportAdditions,
362) -> Result<TransferOutcome> {
363 validate_export_group(group)?;
364 let root = store
365 .join(BUILD_RECEIPTS_DIR)
366 .join("groups")
367 .join(group_key(group));
368 let receipts = walk_files(&root)?
369 .into_iter()
370 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
371 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
372 .collect::<Vec<_>>();
373 if receipts.is_empty() {
374 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
375 }
376 let outcome = export_receipts(
377 store,
378 receipts
379 .iter()
380 .map(|(_, receipt)| receipt.clone())
381 .collect(),
382 archive,
383 additions,
384 )?;
385 for (path, _) in receipts {
390 std::fs::remove_file(path)?;
391 }
392 let _ = std::fs::remove_dir(root);
393 Ok(outcome)
394}
395
396fn export_receipts(
397 store: &Path,
398 mut receipts: Vec<BuildReceipt>,
399 archive: &Path,
400 additions: ExportAdditions,
401) -> Result<TransferOutcome> {
402 receipts.sort_by(|left, right| {
403 left.completed_nanos
404 .cmp(&right.completed_nanos)
405 .then_with(|| {
406 fn key(prediction: &ActionPrediction) -> (&CacheDigest, &CacheDigest, &str, &str) {
409 (
410 &prediction.invocation,
411 &prediction.action,
412 &prediction.adapter,
413 &prediction.payload,
414 )
415 }
416 left.predictions
417 .iter()
418 .map(key)
419 .cmp(right.predictions.iter().map(key))
420 })
421 });
422 let mut actions = BTreeSet::new();
423 let mut tasks = BTreeMap::new();
424 for receipt in receipts {
425 actions.extend(
426 receipt
427 .predictions
428 .iter()
429 .map(|prediction| prediction.action.clone()),
430 );
431 tasks
434 .entry(receipt.identity)
435 .or_insert_with(Vec::new)
436 .extend(receipt.predictions);
437 }
438 let tasks = tasks
439 .into_iter()
440 .map(|(task, predictions)| {
441 let mut invocations = BTreeSet::new();
442 let mut predictions = predictions
443 .into_iter()
444 .rev()
445 .filter(|prediction| invocations.insert(prediction.invocation.clone()))
446 .collect::<Vec<_>>();
447 predictions.reverse();
448 TaskActionManifest {
449 version: 1,
450 task,
451 predictions,
452 }
453 })
454 .collect::<Vec<_>>();
455 if tasks.iter().any(|task| !task.validate()) {
456 eyre::bail!("combined export predictions exceed task manifest limits");
457 }
458 validate_export_additions(&additions)?;
459 let (mut objects, result_paths) = strict_closure(store, &actions)?;
460 let cas = LocalCas::new(store);
461 for digest in &additions.objects {
462 require_object(&cas, &mut objects, digest)?;
463 }
464 let parent = archive
465 .parent()
466 .filter(|parent| !parent.as_os_str().is_empty())
467 .unwrap_or_else(|| Path::new("."));
468 std::fs::create_dir_all(parent)?;
469 let temporary = tempfile::Builder::new()
470 .prefix(".mbx-export-")
471 .tempfile_in(parent)?;
472 let mut builder = tar::Builder::new(temporary.reopen()?);
473 append_bytes(
474 &mut builder,
475 Path::new(EXPORT_MANIFEST),
476 &serde_json::to_vec(&ExportManifest {
477 version: if additions.attachments.is_empty() && additions.objects.is_empty() {
478 LEGACY_EXPORT_VERSION
479 } else {
480 EXPORT_VERSION
481 },
482 tasks,
483 actions: actions.iter().cloned().collect(),
484 attachments: additions.attachments.clone(),
485 objects: additions.objects.iter().cloned().collect(),
486 })?,
487 )?;
488 for path in objects.iter().chain(result_paths.iter()) {
489 append_file(&mut builder, store, path)?;
490 }
491 builder.finish()?;
492 drop(builder);
493 temporary
494 .persist(archive)
495 .map_err(|error| error.error)
496 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
497 let bytes = std::fs::metadata(archive)?.len();
498 Ok(TransferOutcome {
499 actions: actions.len() as u64,
500 objects: objects.len() as u64,
501 bytes,
502 })
503}
504
505pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
507 Ok(import_archive_with_attachments(store, archive)?.transfer)
508}
509
510pub fn import_archive_with_attachments(store: &Path, archive: &Path) -> Result<ImportOutcome> {
512 let staging = tempfile::tempdir()?;
513 let file = std::fs::File::open(archive)
514 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
515 let mut bundle = tar::Archive::new(file);
516 let mut seen = BTreeSet::new();
517 for entry in bundle.entries()? {
518 let mut entry = entry?;
519 let entry_type = entry.header().entry_type();
520 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
521 eyre::bail!("cache export contains a non-file entry");
522 }
523 let path = entry.path()?.into_owned();
524 validate_archive_path(&path)?;
525 if !seen.insert(path.clone()) {
526 eyre::bail!("cache export contains duplicate entry {}", path.display());
527 }
528 let destination = staging.path().join(&path);
529 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
530 entry.unpack(&destination)?;
534 }
535 let manifest: ExportManifest =
536 serde_json::from_slice(&std::fs::read(staging.path().join(EXPORT_MANIFEST))?)?;
537 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
538 let task_identities = manifest
539 .tasks
540 .iter()
541 .map(|task| task.task.as_str())
542 .collect::<BTreeSet<_>>();
543 if !matches!(manifest.version, LEGACY_EXPORT_VERSION | EXPORT_VERSION)
544 || (manifest.version == LEGACY_EXPORT_VERSION
545 && (!manifest.attachments.is_empty() || !manifest.objects.is_empty()))
546 || manifest.tasks.is_empty()
547 || actions.len() != manifest.actions.len()
548 || task_identities.len() != manifest.tasks.len()
549 || manifest.tasks.iter().any(|task| !task.validate())
550 || manifest
551 .actions
552 .iter()
553 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
554 || manifest.tasks.iter().any(|task| {
555 task.predictions
556 .iter()
557 .any(|prediction| !actions.contains(&prediction.action))
558 })
559 || manifest
560 .attachments
561 .keys()
562 .any(|name| !valid_attachment_name(name))
563 || manifest.attachments.values().any(|digest| {
564 digest.algorithm != "blake3"
565 || digest.validate().is_err()
566 || !manifest.objects.contains(digest)
567 })
568 || manifest.objects.iter().collect::<BTreeSet<_>>().len() != manifest.objects.len()
569 || manifest
570 .objects
571 .iter()
572 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
573 {
574 eyre::bail!("unsupported or invalid cache export manifest");
575 }
576 let (mut objects, result_paths) = strict_closure(staging.path(), &actions)
577 .wrap_err("cache export is incomplete or corrupt")?;
578 let staged_cas = LocalCas::new(staging.path());
579 for digest in &manifest.objects {
580 require_object(&staged_cas, &mut objects, digest)
581 .wrap_err("cache export attachment is incomplete or corrupt")?;
582 }
583
584 let cas = LocalCas::new(store);
585 for path in &objects {
586 let relative = path.strip_prefix(staging.path())?;
587 let source = staging.path().join(relative);
588 let digest = addressed_digest(staging.path(), &source, false)
589 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
590 cas.adopt_verified_file(&digest, &source)?;
594 }
595 let action_cache = mbx_cache_core::LocalActionCache::new(store);
596 for path in &result_paths {
597 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)?;
598 action_cache.store(&result)?;
599 }
600 for task in manifest.tasks {
601 merge_imported_manifest(store, task)?;
602 }
603 Ok(ImportOutcome {
604 transfer: TransferOutcome {
605 actions: actions.len() as u64,
606 objects: objects.len() as u64,
607 bytes: std::fs::metadata(archive)?.len(),
608 },
609 attachments: manifest.attachments,
610 })
611}
612
613fn validate_export_additions(additions: &ExportAdditions) -> Result<()> {
614 if additions
615 .attachments
616 .keys()
617 .any(|name| !valid_attachment_name(name))
618 || additions
619 .attachments
620 .values()
621 .any(|digest| !additions.objects.contains(digest))
622 || additions
623 .objects
624 .iter()
625 .any(|digest| digest.algorithm != "blake3" || digest.validate().is_err())
626 {
627 eyre::bail!("invalid cache export attachment");
628 }
629 Ok(())
630}
631
632fn valid_attachment_name(name: &str) -> bool {
633 !name.is_empty()
634 && name.len() <= 128
635 && name
636 .bytes()
637 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
638}
639
640fn strict_closure(
641 store: &Path,
642 actions: &BTreeSet<CacheDigest>,
643) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
644 let cas = LocalCas::new(store);
645 let action_cache = mbx_cache_core::LocalActionCache::new(store);
646 let mut objects = BTreeSet::new();
647 let mut results = BTreeSet::new();
648 let mut directories = BTreeSet::new();
649 for action in actions {
650 let result = action_cache
651 .find(action)?
652 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
653 results.insert(action_cache.path_for(action)?);
654 require_object(&cas, &mut objects, &result.action)?;
655 if let Some(metadata) = &result.metadata {
656 require_object(&cas, &mut objects, metadata)?;
657 let captured: CapturedOutput =
658 serde_json::from_slice(&std::fs::read(cas.path_for(metadata)?)?)
659 .wrap_err("action metadata is invalid")?;
660 require_object(&cas, &mut objects, &captured.stdout)?;
661 require_object(&cas, &mut objects, &captured.stderr)?;
662 }
663 if let Some(root) = &result.output_root {
664 require_object(&cas, &mut objects, root)?;
665 let mut pending = vec![root.clone()];
666 while let Some(digest) = pending.pop() {
667 if !directories.insert(digest.clone()) {
668 continue;
669 }
670 let directory: CacheDirectory =
671 serde_json::from_slice(&std::fs::read(cas.path_for(&digest)?)?)
672 .wrap_err("output directory is invalid")?;
673 for file in directory.files {
674 require_object(&cas, &mut objects, &file.digest)?;
675 }
676 for child in directory.directories {
677 require_object(&cas, &mut objects, &child.digest)?;
678 pending.push(child.digest);
679 }
680 }
681 }
682 }
683 Ok((objects, results))
684}
685
686#[derive(Deserialize)]
687struct CapturedOutput {
688 stdout: CacheDigest,
689 stderr: CacheDigest,
690}
691
692fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
693 let identity = imported.task.clone();
694 let destination_path = task_manifest_path(destination, &identity);
695 let lock_path = task_manifest_lock_path(destination, &identity);
696 std::fs::create_dir_all(lock_path.parent().expect("task manifest lock has a parent"))?;
697 let mut lock = fslock::LockFile::open(&lock_path)?;
698 lock.lock()?;
699 if let Ok(bytes) = std::fs::read(&destination_path)
700 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
701 && existing.task == identity
702 && existing.validate()
703 {
704 let existing_predictions: BTreeMap<_, _> = existing
705 .predictions
706 .iter()
707 .cloned()
708 .map(|prediction| (prediction.invocation.clone(), prediction))
709 .collect();
710 let updates = imported
713 .predictions
714 .into_iter()
715 .map(|prediction| {
716 existing_predictions
717 .get(&prediction.invocation)
718 .cloned()
719 .unwrap_or(prediction)
720 })
721 .collect();
722 imported.predictions =
723 merge_task_action_predictions(existing.predictions, updates, &BTreeSet::new())?;
724 }
725 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
726}
727
728fn require_object(
729 cas: &LocalCas,
730 objects: &mut BTreeSet<PathBuf>,
731 digest: &CacheDigest,
732) -> Result<()> {
733 let expected = cas.path_for(digest)?;
734 if objects.contains(&expected) {
735 return Ok(());
736 }
737 let path = cas
738 .find(digest)?
739 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
740 objects.insert(path);
741 Ok(())
742}
743
744fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
745 store
746 .join("task-manifests/v1")
747 .join(format!("{identity}.json"))
748}
749
750fn task_manifest_lock_path(store: &Path, identity: &str) -> PathBuf {
751 store
752 .join("task-manifests/v1/locks")
753 .join(format!("{identity}.lock"))
754}
755
756fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
757 let name = path.strip_prefix(root)?;
758 builder.append_path_with_name(path, name)?;
759 Ok(())
760}
761
762fn append_bytes(
763 builder: &mut tar::Builder<std::fs::File>,
764 name: &Path,
765 bytes: &[u8],
766) -> Result<()> {
767 let mut header = tar::Header::new_gnu();
768 header.set_size(bytes.len() as u64);
769 header.set_mode(0o644);
770 header.set_cksum();
771 builder.append_data(&mut header, name, bytes)?;
772 Ok(())
773}
774
775fn validate_archive_path(path: &Path) -> Result<()> {
776 if path.as_os_str().is_empty()
777 || path.is_absolute()
778 || path
779 .components()
780 .any(|component| !matches!(component, Component::Normal(_)))
781 {
782 eyre::bail!("cache export contains unsafe path {}", path.display());
783 }
784 let text = path.to_string_lossy();
785 if text != EXPORT_MANIFEST
786 && !text.starts_with("cas/v1/")
787 && !text.starts_with("action-results/v1/")
788 {
789 eyre::bail!("cache export contains unexpected path {}", path.display());
790 }
791 Ok(())
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(deny_unknown_fields)]
802struct CheckoutRecord {
803 version: u8,
804 workspace_root: PathBuf,
805 target_dir: PathBuf,
806 updated_secs: u64,
807}
808
809pub fn stats(store: &Path) -> Result<StoreStats> {
811 let objects = walk_files(&store.join(CAS_DIR))?;
812 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
813 let checkouts = scan_checkouts(store)?;
814 Ok(StoreStats {
815 objects: objects.len() as u64,
816 object_bytes: objects.iter().map(|entry| entry.size).sum(),
817 action_results: results.len() as u64,
818 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
819 live_checkouts: checkouts.live_records,
820 stale_checkouts: checkouts.stale_records.len() as u64,
821 })
822}
823
824fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
831 if record.target_dir == record.workspace_root {
832 return 0;
833 }
834 cached_tree_bytes(sizes, &record.target_dir)
835}
836
837pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
843 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
844 let mut target_sizes = BTreeMap::new();
845 let root = store.join(CHECKOUTS_DIR);
846 for identity_entry in read_dir_or_empty(&root)? {
847 let identity = identity_entry.file_name().to_string_lossy().into_owned();
848 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
849 continue;
850 }
851 for entry in walk_files(&identity_entry.path())? {
852 let Some(record) = read_checkout_record(&entry.path) else {
853 continue;
854 };
855 let project = projects.entry(record.workspace_root.clone()).or_default();
856 if claim_is_live(store, &record) {
857 project.0.insert(identity.clone());
858 project.1 = true;
859 }
860 project.2 = project
861 .2
862 .max(checkout_target_bytes(&mut target_sizes, &record));
863 }
864 }
865 let action_cache = mbx_cache_core::LocalActionCache::new(store);
866 let mut usages = Vec::new();
867 for (workspace_root, (identities, live, target_bytes)) in projects {
868 let mut paths = rooted_objects(store, &identities)?;
869 for identity in &identities {
870 for action in task_manifest_actions(store, identity).unwrap_or_default() {
871 if let Ok(path) = action_cache.path_for(&action) {
872 paths.insert(path);
873 }
874 }
875 }
876 let action_bytes = paths
877 .iter()
878 .filter_map(|path| std::fs::metadata(path).ok())
879 .map(|metadata| metadata.len())
880 .sum();
881 usages.push(ProjectUsage {
882 workspace_root,
883 identities: identities.len() as u64,
884 action_bytes,
885 target_bytes,
886 live,
887 });
888 }
889 usages.sort_by(|left, right| {
890 right
891 .action_bytes
892 .saturating_add(right.target_bytes)
893 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
894 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
895 });
896 Ok(usages)
897}
898
899pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
901 let mut entries = Vec::new();
902 for (kind, root, action_result) in [
903 ("object", store.join(CAS_DIR), false),
904 ("action result", store.join(ACTION_RESULTS_DIR), true),
905 ] {
906 entries.extend(
907 walk_files(&root)?
908 .into_iter()
909 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
910 .map(|entry| LargestEntry {
911 kind,
912 path: entry.path,
913 bytes: entry.size,
914 }),
915 );
916 }
917 entries.sort_by(|left, right| {
918 right
919 .bytes
920 .cmp(&left.bytes)
921 .then_with(|| left.path.cmp(&right.path))
922 });
923 entries.truncate(limit);
924 Ok(entries)
925}
926
927pub fn verify(store: &Path) -> Result<VerifyOutcome> {
929 let cas = LocalCas::new(store);
930 let action_cache = mbx_cache_core::LocalActionCache::new(store);
931 let virtually_removed = HashSet::new();
934 let mut outcome = VerifyOutcome::default();
935 for entry in walk_files(&store.join(CAS_DIR))? {
936 let Some(digest) = addressed_digest(store, &entry.path, false) else {
937 continue;
938 };
939 outcome.checked_objects += 1;
940 if !matches!(cas.find(&digest), Ok(Some(_))) {
941 outcome.problems.push(entry.path);
942 }
943 }
944 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
945 let Some(digest) = addressed_digest(store, &entry.path, true) else {
946 continue;
947 };
948 outcome.checked_action_results += 1;
949 if !matches!(action_cache.find(&digest), Ok(Some(_)))
950 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
951 {
952 outcome.problems.push(entry.path);
953 }
954 }
955 outcome.problems.sort();
956 Ok(outcome)
957}
958
959pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
961 let mut outcome = RemoveProjectOutcome::default();
962 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
963 if read_checkout_record(&entry.path)
964 .is_some_and(|record| record.workspace_root == workspace_root)
965 {
966 std::fs::remove_file(&entry.path)?;
967 outcome.removed_checkout_records += 1;
968 if let Some(parent) = entry.path.parent() {
969 let _ = std::fs::remove_dir(parent);
970 }
971 }
972 }
973 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
974 Ok(()) => {}
975 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
976 Err(error) => return Err(error.into()),
977 }
978 Ok(outcome)
979}
980
981pub fn record_checkout(
993 store: &Path,
994 identity: &str,
995 workspace_root: &Path,
996 target_dir: &Path,
997) -> Result<()> {
998 let record = CheckoutRecord {
999 version: CHECKOUT_RECORD_VERSION,
1000 workspace_root: workspace_root.to_path_buf(),
1001 target_dir: target_dir.to_path_buf(),
1002 updated_secs: SystemTime::now()
1003 .duration_since(UNIX_EPOCH)
1004 .map(|since| since.as_secs())
1005 .unwrap_or_default(),
1006 };
1007 let mut contents = serde_json::to_vec(&record)?;
1008 contents.push(b'\n');
1009 write_advisory(
1012 &checkout_record_path(store, identity, workspace_root),
1013 &contents,
1014 )
1015}
1016
1017fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
1018 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
1022 store
1023 .join(CHECKOUTS_DIR)
1024 .join(identity)
1025 .join(format!("{key}.json"))
1026}
1027
1028pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1038 gc_with_mode(store, max_bytes, false)
1039}
1040
1041pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
1043 gc_with_mode(store, max_bytes, true)
1044}
1045
1046fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
1047 let mut objects = walk_files(&store.join(CAS_DIR))?;
1048 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
1049 let mut live_bytes = objects
1050 .iter()
1051 .chain(results.iter())
1052 .map(|entry| entry.size)
1053 .sum::<u64>();
1054
1055 let mut outcome = GcOutcome::default();
1056
1057 let checkouts = scan_checkouts(store)?;
1060 for path in &checkouts.stale_records {
1061 if dry_run || matches!(remove(path)?, Removal::Removed) {
1062 outcome.removed_checkout_records += 1;
1063 }
1064 if !dry_run && let Some(parent) = path.parent() {
1067 let _ = std::fs::remove_dir(parent);
1068 }
1069 }
1070
1071 let mut virtually_removed = HashSet::new();
1072 if live_bytes > max_bytes {
1073 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
1077 rooted.extend(rooted_action_objects(
1084 store,
1085 grouped_receipt_actions(store)?,
1086 ));
1087 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
1091 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
1092 if dry_run {
1093 virtually_removed.insert(path.to_path_buf());
1094 Ok(Removal::Removed)
1095 } else {
1096 remove(path)
1097 }
1098 })?;
1099 live_bytes = evicted.remaining_bytes;
1100 outcome.removed_objects += evicted.removed_objects;
1101 outcome.removed_bytes += evicted.removed_bytes;
1102 }
1103
1104 let cas = LocalCas::new(store);
1109 for entry in &results {
1110 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
1111 match if dry_run {
1112 Removal::Removed
1113 } else {
1114 remove(&entry.path)?
1115 } {
1116 Removal::Removed => {
1117 live_bytes = live_bytes.saturating_sub(entry.size);
1118 outcome.removed_action_results += 1;
1119 outcome.removed_bytes += entry.size;
1120 }
1121 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1122 Removal::Blocked => {}
1123 }
1124 }
1125 }
1126
1127 let sessions = prune_sessions(store, dry_run)?;
1128 outcome.removed_session_streams += sessions.removed_streams;
1129 outcome.removed_bytes += sessions.removed_bytes;
1130
1131 outcome.remaining_bytes = live_bytes;
1132 Ok(outcome)
1133}
1134
1135#[derive(Debug, Default, PartialEq, Eq)]
1136struct SessionPrune {
1137 removed_streams: u64,
1138 removed_bytes: u64,
1139}
1140
1141fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
1142 let mut prune = SessionPrune::default();
1143 let ids = events::session_ids(store);
1144 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
1145 for (index, id) in ids.iter().enumerate() {
1146 let paths = events::session_paths(store, id);
1147 let metadata = match std::fs::metadata(&paths.events) {
1148 Ok(metadata) => metadata,
1149 Err(_) => continue,
1150 };
1151 let stale = metadata
1152 .modified()
1153 .ok()
1154 .and_then(|at| SystemTime::now().duration_since(at).ok())
1155 .is_some_and(|age| age > SESSION_RETENTION);
1156 if index >= surplus && !stale {
1157 continue;
1158 }
1159 if events::session_is_live(store, id) {
1160 continue;
1161 }
1162 if dry_run {
1163 prune.removed_streams += 1;
1164 prune.removed_bytes += metadata.len();
1165 continue;
1166 }
1167 if matches!(remove(&paths.events)?, Removal::Removed) {
1168 prune.removed_streams += 1;
1169 prune.removed_bytes += metadata.len();
1170 let _ = std::fs::remove_file(&paths.lock);
1171 }
1172 }
1173 if !dry_run {
1174 for lock in events::orphaned_locks(store) {
1175 let _ = std::fs::remove_file(lock);
1176 }
1177 }
1178 Ok(prune)
1179}
1180
1181pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
1187 if !claim_sweep(store, interval)? {
1188 return Ok(None);
1189 }
1190 gc(store, max_bytes).map(Some)
1191}
1192
1193pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
1195 let lock_path = store.join(SWEEP_LOCK);
1196 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
1197 let mut lock = fslock::LockFile::open(&lock_path)?;
1198 lock.lock()?;
1199
1200 let stamp = store.join(SWEEP_STAMP);
1201 if let Ok(metadata) = std::fs::metadata(&stamp)
1202 && let Ok(modified) = metadata.modified()
1203 && let Ok(since) = modified.elapsed()
1204 && since < interval
1205 {
1206 return Ok(false);
1207 }
1208 write_atomic(&stamp, b"")?;
1209 Ok(true)
1210}
1211
1212struct CheckoutScan {
1214 live_identities: BTreeSet<String>,
1215 live_records: u64,
1216 stale_records: Vec<PathBuf>,
1217}
1218
1219fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
1220 let root = store.join(CHECKOUTS_DIR);
1221 let mut scan = CheckoutScan {
1222 live_identities: BTreeSet::new(),
1223 live_records: 0,
1224 stale_records: Vec::new(),
1225 };
1226 let listing = match std::fs::read_dir(&root) {
1227 Ok(listing) => listing,
1228 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
1229 Err(error) => {
1230 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
1231 }
1232 };
1233 for entry in listing {
1234 let entry = entry?;
1235 let name = entry.file_name().to_string_lossy().into_owned();
1236 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
1242 continue;
1243 }
1244 for record in walk_files(&entry.path())? {
1245 match read_checkout_record(&record.path) {
1246 Some(checkout) if claim_is_live(store, &checkout) => {
1247 scan.live_identities.insert(name.clone());
1248 scan.live_records += 1;
1249 }
1250 Some(_) => scan.stale_records.push(record.path),
1251 None => {}
1254 }
1255 }
1256 }
1257 Ok(scan)
1258}
1259
1260fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
1261 let bytes = std::fs::read(path).ok()?;
1262 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
1263 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
1264}
1265
1266fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
1268 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
1269 Ok(walk_files(&root)?
1270 .into_iter()
1271 .filter_map(|entry| read_build_receipt(&entry.path))
1272 .flat_map(|receipt| receipt.predictions.into_iter())
1273 .map(|prediction| prediction.action)
1274 .collect())
1275}
1276
1277fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
1280 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
1281}
1282
1283fn claim_has_expired(record: &CheckoutRecord) -> bool {
1284 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1285 return false;
1286 };
1287 now.as_secs()
1288 .saturating_sub(record.updated_secs)
1289 .gt(&CHECKOUT_RETENTION.as_secs())
1290}
1291
1292pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
1305 if !matches!(workspace_root.try_exists(), Ok(false)) {
1306 return true;
1307 }
1308
1309 let Ok(store_metadata) = std::fs::metadata(store) else {
1310 return true;
1311 };
1312 for ancestor in workspace_root.ancestors().skip(1) {
1313 match std::fs::metadata(ancestor) {
1314 Ok(ancestor_metadata) => {
1315 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
1316 }
1317 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1318 Err(_) => return true,
1319 }
1320 }
1321 true
1322}
1323
1324pub fn checkout_is_live(workspace_root: &Path) -> bool {
1330 let Some(parent) = workspace_root.parent() else {
1331 return true;
1332 };
1333 checkout_is_live_on(parent, workspace_root)
1334}
1335
1336#[cfg(unix)]
1337fn same_filesystem(
1338 _a_path: &Path,
1339 a: &std::fs::Metadata,
1340 _b_path: &Path,
1341 b: &std::fs::Metadata,
1342) -> bool {
1343 use std::os::unix::fs::MetadataExt as _;
1344 a.dev() == b.dev()
1345}
1346
1347#[cfg(windows)]
1348fn same_filesystem(
1349 a_path: &Path,
1350 _a: &std::fs::Metadata,
1351 b_path: &Path,
1352 _b: &std::fs::Metadata,
1353) -> bool {
1354 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
1355}
1356
1357#[cfg(windows)]
1358fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
1359 use std::os::windows::ffi::OsStrExt as _;
1360 use windows_sys::Win32::Storage::FileSystem::{
1361 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
1362 };
1363
1364 let path = path
1365 .as_os_str()
1366 .encode_wide()
1367 .chain(Some(0))
1368 .collect::<Vec<_>>();
1369 let mut mount = vec![0_u16; 261];
1373 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
1376 return None;
1377 }
1378 let mut volume = vec![0_u16; 261];
1379 if unsafe {
1382 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
1383 } == 0
1384 {
1385 return None;
1386 }
1387 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
1388 Some(volume)
1389}
1390
1391#[cfg(not(any(unix, windows)))]
1392fn same_filesystem(
1393 _a_path: &Path,
1394 _a: &std::fs::Metadata,
1395 _b_path: &Path,
1396 _b: &std::fs::Metadata,
1397) -> bool {
1398 false
1399}
1400
1401fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
1409 let mut actions = BTreeSet::new();
1410 for identity in identities {
1411 let manifest_actions = match task_manifest_actions(store, identity) {
1414 Ok(actions) => actions,
1415 Err(error) => {
1416 log::debug!("could not read the manifest for {identity}: {error}");
1417 continue;
1418 }
1419 };
1420 actions.extend(manifest_actions);
1421 }
1422 Ok(rooted_action_objects(store, actions))
1423}
1424
1425fn rooted_action_objects(
1427 store: &Path,
1428 actions: impl IntoIterator<Item = CacheDigest>,
1429) -> HashSet<PathBuf> {
1430 let cas = LocalCas::new(store);
1431 let mut rooted = HashSet::new();
1432 let mut visited = BTreeSet::new();
1433 for action in actions {
1434 let Some(result) = read_action_result(store, &action) else {
1435 continue;
1436 };
1437 root_digest(&cas, &mut rooted, &result.action);
1438 if let Some(metadata) = &result.metadata {
1439 root_digest(&cas, &mut rooted, metadata);
1440 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
1447 root_digest(&cas, &mut rooted, &rustc.stdout);
1448 root_digest(&cas, &mut rooted, &rustc.stderr);
1449 }
1450 }
1451 if let Some(output_root) = &result.output_root {
1452 root_digest(&cas, &mut rooted, output_root);
1453 root_tree(&cas, output_root, &mut rooted, &mut visited);
1454 }
1455 }
1456 rooted
1457}
1458
1459fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
1460 let path = mbx_cache_core::LocalActionCache::new(store)
1461 .path_for(action)
1462 .ok()?;
1463 let bytes = std::fs::read(path).ok()?;
1464 serde_json::from_slice(&bytes).ok()
1465}
1466
1467fn root_tree(
1468 cas: &LocalCas,
1469 output_root: &CacheDigest,
1470 rooted: &mut HashSet<PathBuf>,
1471 visited: &mut BTreeSet<CacheDigest>,
1472) {
1473 let mut pending = vec![output_root.clone()];
1474 while let Some(digest) = pending.pop() {
1475 if !visited.insert(digest.clone()) {
1476 continue;
1477 }
1478 let Some(directory) = read_directory(cas, &digest) else {
1479 continue;
1480 };
1481 for file in &directory.files {
1482 root_digest(cas, rooted, &file.digest);
1483 }
1484 for child in &directory.directories {
1485 root_digest(cas, rooted, &child.digest);
1486 pending.push(child.digest.clone());
1487 }
1488 }
1489}
1490
1491fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
1492 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1493 serde_json::from_slice(&bytes).ok()
1494}
1495
1496fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
1497 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1498 serde_json::from_slice(&bytes).ok()
1499}
1500
1501fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
1502 if let Ok(path) = cas.path_for(digest) {
1503 rooted.insert(path);
1504 }
1505}
1506
1507#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1508enum Removal {
1509 Removed,
1510 Missing,
1511 Blocked,
1512}
1513
1514fn remove(path: &Path) -> Result<Removal> {
1515 match std::fs::remove_file(path) {
1516 Ok(()) => Ok(Removal::Removed),
1517 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
1519 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1524 log::debug!("could not evict {}: {error}", path.display());
1525 Ok(Removal::Blocked)
1526 }
1527 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
1528 }
1529}
1530
1531#[derive(Debug, Default, PartialEq, Eq)]
1532struct ObjectEvictions {
1533 removed_objects: u64,
1534 removed_bytes: u64,
1535 remaining_bytes: u64,
1536}
1537
1538fn evict_objects(
1541 objects: &[Entry],
1542 rooted: &HashSet<PathBuf>,
1543 mut live_bytes: u64,
1544 max_bytes: u64,
1545 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
1546) -> Result<ObjectEvictions> {
1547 let mut outcome = ObjectEvictions::default();
1548 let mut blocked_unrooted = false;
1549 for entry in objects {
1550 if live_bytes <= max_bytes {
1551 break;
1552 }
1553 let is_rooted = rooted.contains(&entry.path);
1554 if is_rooted && blocked_unrooted {
1555 break;
1559 }
1560 match remove_file(&entry.path)? {
1561 Removal::Removed => {
1562 live_bytes = live_bytes.saturating_sub(entry.size);
1563 outcome.removed_objects += 1;
1564 outcome.removed_bytes += entry.size;
1565 }
1566 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1567 Removal::Blocked if !is_rooted => blocked_unrooted = true,
1568 Removal::Blocked => {}
1569 }
1570 }
1571 outcome.remaining_bytes = live_bytes;
1572 Ok(outcome)
1573}
1574
1575fn action_result_is_dangling(
1593 cas: &LocalCas,
1594 path: &Path,
1595 virtually_removed: &HashSet<PathBuf>,
1596) -> Result<bool> {
1597 let bytes = match std::fs::read(path) {
1598 Ok(bytes) => bytes,
1599 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1600 Err(error) => {
1601 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
1602 }
1603 };
1604 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
1606 return Ok(false);
1607 };
1608 for digest in [
1609 Some(&result.action),
1610 result.metadata.as_ref(),
1611 result.output_root.as_ref(),
1612 ]
1613 .into_iter()
1614 .flatten()
1615 {
1616 match cas.path_for(digest) {
1617 Ok(path) => {
1618 if virtually_removed.contains(&path) || !path.exists() {
1619 return Ok(true);
1620 }
1621 }
1622 Err(_) => return Ok(true),
1624 }
1625 }
1626 Ok(false)
1627}
1628
1629struct Entry {
1630 path: PathBuf,
1631 size: u64,
1632 used: SystemTime,
1633}
1634
1635fn walk_files(root: &Path) -> Result<Vec<Entry>> {
1636 let mut entries = Vec::new();
1637 let mut pending = vec![root.to_path_buf()];
1638 while let Some(directory) = pending.pop() {
1639 let listing = match std::fs::read_dir(&directory) {
1640 Ok(listing) => listing,
1641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1642 Err(error) => {
1643 return Err(error)
1644 .wrap_err_with(|| format!("failed to read {}", directory.display()));
1645 }
1646 };
1647 for entry in listing {
1648 let entry = entry?;
1649 let metadata = match entry.metadata() {
1650 Ok(metadata) => metadata,
1651 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1653 Err(error) => return Err(error.into()),
1654 };
1655 if metadata.is_dir() {
1656 pending.push(entry.path());
1657 } else if metadata.is_file() {
1658 let used = metadata
1659 .accessed()
1660 .or_else(|_| metadata.modified())
1661 .unwrap_or(SystemTime::UNIX_EPOCH);
1662 entries.push(Entry {
1663 path: entry.path(),
1664 size: metadata.len(),
1665 used,
1666 });
1667 }
1668 }
1669 }
1670 Ok(entries)
1671}
1672
1673fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
1674 match std::fs::read_dir(root) {
1675 Ok(entries) => entries
1676 .collect::<std::io::Result<Vec<_>>>()
1677 .map_err(Into::into),
1678 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
1679 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
1680 }
1681}
1682
1683fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
1684 let name = path.file_name()?.to_str()?;
1685 let name = if action_result {
1686 name.strip_suffix(".json")?
1687 } else {
1688 name
1689 };
1690 let (hash, size) = name.rsplit_once('-')?;
1691 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
1692 let digest = CacheDigest {
1693 algorithm,
1694 hash: hash.to_string(),
1695 size: size.parse().ok()?,
1696 };
1697 digest.validate().ok()?;
1698 Some(digest)
1699}
1700
1701fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
1702 let digest = digest_from_path(path, action_result)?;
1703 let canonical = if action_result {
1704 mbx_cache_core::LocalActionCache::new(store)
1705 .path_for(&digest)
1706 .ok()?
1707 } else {
1708 LocalCas::new(store).path_for(&digest).ok()?
1709 };
1710 (canonical == path).then_some(digest)
1711}
1712
1713fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
1714 *cache
1715 .entry(root.to_path_buf())
1716 .or_insert_with(|| tree_bytes(root))
1717}
1718
1719fn tree_bytes(root: &Path) -> u64 {
1720 let Ok(root_metadata) = std::fs::metadata(root) else {
1721 return 0;
1722 };
1723 if root_metadata.is_file() {
1724 return root_metadata.len();
1725 }
1726 let Ok(entries) = std::fs::read_dir(root) else {
1727 return 0;
1728 };
1729 let mut bytes = 0_u64;
1730 let mut pending = entries
1731 .filter_map(std::result::Result::ok)
1732 .map(|entry| entry.path())
1733 .collect::<Vec<_>>();
1734 while let Some(path) = pending.pop() {
1735 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1736 continue;
1737 };
1738 if metadata.is_file() {
1739 bytes = bytes.saturating_add(metadata.len());
1740 } else if metadata.is_dir()
1741 && let Ok(entries) = std::fs::read_dir(path)
1742 {
1743 pending.extend(
1744 entries
1745 .filter_map(std::result::Result::ok)
1746 .map(|entry| entry.path()),
1747 );
1748 }
1749 }
1750 bytes
1751}
1752
1753#[cfg(test)]
1754#[path = "store_tests.rs"]
1755mod tests;
1756
1757fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
1760 let parent = path
1761 .parent()
1762 .filter(|parent| !parent.as_os_str().is_empty())
1763 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1764 std::fs::create_dir_all(parent)
1765 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1766 let mut temporary = tempfile::Builder::new()
1767 .prefix(".mbx-")
1768 .tempfile_in(parent)?;
1769 use std::io::Write as _;
1770 temporary.write_all(contents)?;
1771 temporary
1772 .persist(path)
1773 .map_err(|error| error.error)
1774 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1775 Ok(())
1776}
1777
1778fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
1779 let parent = path
1780 .parent()
1781 .filter(|parent| !parent.as_os_str().is_empty())
1782 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1783 std::fs::create_dir_all(parent)
1784 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1785 let mut temporary = tempfile::Builder::new()
1786 .prefix(".mbx-")
1787 .tempfile_in(parent)?;
1788 use std::io::Write as _;
1789 temporary.write_all(contents)?;
1790 temporary.as_file_mut().sync_all()?;
1791 temporary
1792 .persist(path)
1793 .map_err(|error| error.error)
1794 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1795 Ok(())
1796}