1mod events;
13
14use eyre::{Context, Result};
15use mbx_cache_core::{
16 ActionPrediction, CacheDigest, CacheDirectory, LocalCas, RemoteActionResult, RustcMetadata,
17 TaskActionManifest, is_task_identity, task_manifest_actions,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashSet};
21use std::path::{Component, Path, PathBuf};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24const CAS_DIR: &str = "cas/v1";
25const ACTION_RESULTS_DIR: &str = "action-results/v1";
26const CHECKOUTS_DIR: &str = "checkouts/v1";
27const SWEEP_STAMP: &str = "gc/v1/last-sweep";
28const SWEEP_LOCK: &str = "gc/v1/sweep.lock";
29const CHECKOUT_RECORD_VERSION: u8 = 1;
30const BUILD_RECEIPTS_DIR: &str = "build-receipts/v1";
31const BUILD_RECEIPT_VERSION: u8 = 1;
32const EXPORT_MANIFEST: &str = "mbx-cache-export-v1.json";
33const EXPORT_VERSION: u8 = 1;
34
35const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
36const MAX_SESSIONS: usize = 256;
37
38const CHECKOUT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
48
49#[derive(Debug, Default, PartialEq, Eq)]
50pub struct StoreStats {
51 pub objects: u64,
52 pub object_bytes: u64,
53 pub action_results: u64,
54 pub action_result_bytes: u64,
55 pub live_checkouts: u64,
56 pub stale_checkouts: u64,
61}
62
63impl StoreStats {
64 pub fn total_bytes(&self) -> u64 {
65 self.object_bytes.saturating_add(self.action_result_bytes)
66 }
67}
68
69#[derive(Debug, Default, PartialEq, Eq)]
70pub struct GcOutcome {
71 pub removed_objects: u64,
72 pub removed_action_results: u64,
73 pub removed_checkout_records: u64,
74 pub removed_session_streams: u64,
77 pub removed_bytes: u64,
78 pub remaining_bytes: u64,
79}
80
81#[derive(Debug, PartialEq, Eq)]
82pub struct ProjectUsage {
83 pub workspace_root: PathBuf,
84 pub identities: u64,
85 pub action_bytes: u64,
86 pub target_bytes: u64,
87 pub live: bool,
88}
89
90#[derive(Debug, PartialEq, Eq)]
91pub struct LargestEntry {
92 pub kind: &'static str,
93 pub path: PathBuf,
94 pub bytes: u64,
95}
96
97#[derive(Debug, Default, PartialEq, Eq)]
98pub struct VerifyOutcome {
99 pub checked_objects: u64,
100 pub checked_action_results: u64,
101 pub problems: Vec<PathBuf>,
102}
103
104#[derive(Debug, Default, PartialEq, Eq)]
105pub struct RemoveProjectOutcome {
106 pub removed_checkout_records: u64,
107}
108
109#[derive(Debug, Default, PartialEq, Eq)]
110pub struct TransferOutcome {
111 pub actions: u64,
112 pub objects: u64,
113 pub bytes: u64,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118struct ExportManifest {
119 version: u8,
120 tasks: Vec<TaskActionManifest>,
121 actions: Vec<CacheDigest>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127struct BuildReceipt {
128 version: u8,
129 workspace_root: PathBuf,
130 identity: String,
131 completed_nanos: u64,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 group: Option<String>,
134 predictions: Vec<ActionPrediction>,
135}
136
137pub fn record_build_receipt(
139 store: &Path,
140 run: &str,
141 identity: &str,
142 workspace_root: &Path,
143 group: Option<&str>,
144 predictions: Vec<ActionPrediction>,
145) -> Result<()> {
146 if !is_task_identity(run) || !is_task_identity(identity) {
147 eyre::bail!("invalid build receipt identity");
148 }
149 if !(TaskActionManifest {
150 version: 1,
151 task: identity.to_owned(),
152 predictions: predictions.clone(),
153 })
154 .validate()
155 {
156 eyre::bail!("invalid build receipt prediction");
157 }
158 if let Some(group) = group {
159 validate_export_group(group)?;
160 }
161 let key = workspace_key(workspace_root);
162 let lock_path = store
163 .join(BUILD_RECEIPTS_DIR)
164 .join("locks")
165 .join(format!("{key}.lock"));
166 std::fs::create_dir_all(lock_path.parent().expect("receipt lock has a parent"))?;
167 let mut lock = fslock::LockFile::open(&lock_path)?;
168 lock.lock()?;
169 let completed_nanos = SystemTime::now()
170 .duration_since(UNIX_EPOCH)
171 .map(|elapsed| elapsed.as_nanos().try_into().unwrap_or(u64::MAX))
172 .unwrap_or_default();
173 let receipt = BuildReceipt {
174 version: BUILD_RECEIPT_VERSION,
175 workspace_root: workspace_root.to_path_buf(),
176 identity: identity.to_owned(),
177 completed_nanos,
178 group: group.map(str::to_owned),
179 predictions,
180 };
181 let bytes = serde_json::to_vec(&receipt)?;
182 write_atomic(&latest_receipt_path(store, workspace_root), &bytes)?;
183 if let Some(group) = group {
184 write_atomic(&group_receipt_path(store, group, run), &bytes)?;
185 }
186 Ok(())
187}
188
189fn workspace_key(workspace_root: &Path) -> String {
190 CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
191}
192
193fn latest_receipt_path(store: &Path, workspace_root: &Path) -> PathBuf {
194 store
195 .join(BUILD_RECEIPTS_DIR)
196 .join("checkouts")
197 .join(format!("{}.json", workspace_key(workspace_root)))
198}
199
200fn group_receipt_path(store: &Path, group: &str, run: &str) -> PathBuf {
201 store
202 .join(BUILD_RECEIPTS_DIR)
203 .join("groups")
204 .join(group_key(group))
205 .join(format!("{run}.json"))
206}
207
208fn group_key(group: &str) -> String {
209 CacheDigest::blake3(group.as_bytes()).hash
210}
211
212fn read_build_receipt(path: &Path) -> Option<BuildReceipt> {
213 let bytes = std::fs::read(path).ok()?;
214 let receipt = serde_json::from_slice::<BuildReceipt>(&bytes).ok()?;
215 let valid = TaskActionManifest {
216 version: 1,
217 task: receipt.identity.clone(),
218 predictions: receipt.predictions.clone(),
219 }
220 .validate();
221 (receipt.version == BUILD_RECEIPT_VERSION && valid).then_some(receipt)
222}
223
224fn validate_export_group(group: &str) -> Result<()> {
225 if group.is_empty() || group.len() > 256 || group.chars().any(char::is_control) {
226 eyre::bail!("invalid build export group");
227 }
228 Ok(())
229}
230
231pub fn export_checkout(
233 store: &Path,
234 workspace_root: &Path,
235 archive: &Path,
236) -> Result<TransferOutcome> {
237 let receipt = read_build_receipt(&latest_receipt_path(store, workspace_root))
238 .filter(|receipt| receipt.workspace_root == workspace_root)
239 .ok_or_else(|| {
240 eyre::eyre!(
241 "no completed mbx build is recorded for {}",
242 workspace_root.display()
243 )
244 })?;
245 export_receipts(store, vec![receipt], archive)
246}
247
248pub fn export_group(store: &Path, group: &str, archive: &Path) -> Result<TransferOutcome> {
250 validate_export_group(group)?;
251 let root = store
252 .join(BUILD_RECEIPTS_DIR)
253 .join("groups")
254 .join(group_key(group));
255 let receipts = walk_files(&root)?
256 .into_iter()
257 .filter_map(|entry| read_build_receipt(&entry.path).map(|receipt| (entry.path, receipt)))
258 .filter(|(_, receipt)| receipt.group.as_deref() == Some(group))
259 .collect::<Vec<_>>();
260 if receipts.is_empty() {
261 eyre::bail!("no completed mbx builds are recorded for export group {group:?}");
262 }
263 let outcome = export_receipts(
264 store,
265 receipts
266 .iter()
267 .map(|(_, receipt)| receipt.clone())
268 .collect(),
269 archive,
270 )?;
271 for (path, _) in receipts {
276 std::fs::remove_file(path)?;
277 }
278 let _ = std::fs::remove_dir(root);
279 Ok(outcome)
280}
281
282fn export_receipts(
283 store: &Path,
284 mut receipts: Vec<BuildReceipt>,
285 archive: &Path,
286) -> Result<TransferOutcome> {
287 receipts.sort_by_key(|receipt| receipt.completed_nanos);
288 let mut actions = BTreeSet::new();
289 let mut tasks = BTreeMap::new();
290 for receipt in receipts {
291 actions.extend(
292 receipt
293 .predictions
294 .iter()
295 .map(|prediction| prediction.action.clone()),
296 );
297 tasks.insert(
301 receipt.identity.clone(),
302 TaskActionManifest {
303 version: 1,
304 task: receipt.identity,
305 predictions: receipt.predictions,
306 },
307 );
308 }
309 let tasks = tasks.into_values().collect::<Vec<_>>();
310 let (objects, result_paths) = strict_closure(store, &actions)?;
311 let parent = archive
312 .parent()
313 .filter(|parent| !parent.as_os_str().is_empty())
314 .unwrap_or_else(|| Path::new("."));
315 std::fs::create_dir_all(parent)?;
316 let temporary = tempfile::Builder::new()
317 .prefix(".mbx-export-")
318 .tempfile_in(parent)?;
319 let mut builder = tar::Builder::new(temporary.reopen()?);
320 append_bytes(
321 &mut builder,
322 Path::new(EXPORT_MANIFEST),
323 &serde_json::to_vec(&ExportManifest {
324 version: EXPORT_VERSION,
325 tasks,
326 actions: actions.iter().cloned().collect(),
327 })?,
328 )?;
329 for path in objects.iter().chain(result_paths.iter()) {
330 append_file(&mut builder, store, path)?;
331 }
332 builder.finish()?;
333 drop(builder);
334 temporary
335 .persist(archive)
336 .map_err(|error| error.error)
337 .wrap_err_with(|| format!("failed to publish {}", archive.display()))?;
338 let bytes = std::fs::metadata(archive)?.len();
339 Ok(TransferOutcome {
340 actions: actions.len() as u64,
341 objects: objects.len() as u64,
342 bytes,
343 })
344}
345
346pub fn import_archive(store: &Path, archive: &Path) -> Result<TransferOutcome> {
348 let staging = tempfile::tempdir()?;
349 let file = std::fs::File::open(archive)
350 .wrap_err_with(|| format!("failed to open {}", archive.display()))?;
351 let mut bundle = tar::Archive::new(file);
352 let mut seen = BTreeSet::new();
353 for entry in bundle.entries()? {
354 let mut entry = entry?;
355 let entry_type = entry.header().entry_type();
356 if !entry_type.is_file() && !entry_type.is_gnu_sparse() {
357 eyre::bail!("cache export contains a non-file entry");
358 }
359 let path = entry.path()?.into_owned();
360 validate_archive_path(&path)?;
361 if !seen.insert(path.clone()) {
362 eyre::bail!("cache export contains duplicate entry {}", path.display());
363 }
364 let destination = staging.path().join(&path);
365 std::fs::create_dir_all(destination.parent().expect("entry has a parent"))?;
366 entry.unpack(&destination)?;
370 }
371 let manifest: ExportManifest =
372 serde_json::from_slice(&std::fs::read(staging.path().join(EXPORT_MANIFEST))?)?;
373 let actions = manifest.actions.iter().cloned().collect::<BTreeSet<_>>();
374 let task_identities = manifest
375 .tasks
376 .iter()
377 .map(|task| task.task.as_str())
378 .collect::<BTreeSet<_>>();
379 if manifest.version != EXPORT_VERSION
380 || manifest.tasks.is_empty()
381 || actions.len() != manifest.actions.len()
382 || task_identities.len() != manifest.tasks.len()
383 || manifest.tasks.iter().any(|task| !task.validate())
384 || manifest
385 .actions
386 .iter()
387 .any(|action| action.algorithm != "blake3" || action.validate().is_err())
388 || manifest.tasks.iter().any(|task| {
389 task.predictions
390 .iter()
391 .any(|prediction| !actions.contains(&prediction.action))
392 })
393 {
394 eyre::bail!("unsupported or invalid cache export manifest");
395 }
396 let (objects, result_paths) = strict_closure(staging.path(), &actions)
397 .wrap_err("cache export is incomplete or corrupt")?;
398
399 let cas = LocalCas::new(store);
400 for path in &objects {
401 let relative = path.strip_prefix(staging.path())?;
402 let source = staging.path().join(relative);
403 let digest = addressed_digest(staging.path(), &source, false)
404 .ok_or_else(|| eyre::eyre!("invalid cache object path {}", relative.display()))?;
405 cas.store_file(&digest, &source)?;
406 }
407 let action_cache = mbx_cache_core::LocalActionCache::new(store);
408 for path in &result_paths {
409 let result: RemoteActionResult = serde_json::from_slice(&std::fs::read(path)?)?;
410 action_cache.store(&result)?;
411 }
412 for task in manifest.tasks {
413 merge_imported_manifest(store, task)?;
414 }
415 Ok(TransferOutcome {
416 actions: actions.len() as u64,
417 objects: objects.len() as u64,
418 bytes: std::fs::metadata(archive)?.len(),
419 })
420}
421
422fn strict_closure(
423 store: &Path,
424 actions: &BTreeSet<CacheDigest>,
425) -> Result<(BTreeSet<PathBuf>, BTreeSet<PathBuf>)> {
426 let cas = LocalCas::new(store);
427 let action_cache = mbx_cache_core::LocalActionCache::new(store);
428 let mut objects = BTreeSet::new();
429 let mut results = BTreeSet::new();
430 let mut directories = BTreeSet::new();
431 for action in actions {
432 let result = action_cache
433 .find(action)?
434 .ok_or_else(|| eyre::eyre!("action result is missing for {}", action.hash))?;
435 results.insert(action_cache.path_for(action)?);
436 require_object(&cas, &mut objects, &result.action)?;
437 if let Some(metadata) = &result.metadata {
438 require_object(&cas, &mut objects, metadata)?;
439 let captured: CapturedOutput =
440 serde_json::from_slice(&std::fs::read(cas.path_for(metadata)?)?)
441 .wrap_err("action metadata is invalid")?;
442 require_object(&cas, &mut objects, &captured.stdout)?;
443 require_object(&cas, &mut objects, &captured.stderr)?;
444 }
445 if let Some(root) = &result.output_root {
446 require_object(&cas, &mut objects, root)?;
447 let mut pending = vec![root.clone()];
448 while let Some(digest) = pending.pop() {
449 if !directories.insert(digest.clone()) {
450 continue;
451 }
452 let directory: CacheDirectory =
453 serde_json::from_slice(&std::fs::read(cas.path_for(&digest)?)?)
454 .wrap_err("output directory is invalid")?;
455 for file in directory.files {
456 require_object(&cas, &mut objects, &file.digest)?;
457 }
458 for child in directory.directories {
459 require_object(&cas, &mut objects, &child.digest)?;
460 pending.push(child.digest);
461 }
462 }
463 }
464 }
465 Ok((objects, results))
466}
467
468#[derive(Deserialize)]
469struct CapturedOutput {
470 stdout: CacheDigest,
471 stderr: CacheDigest,
472}
473
474fn merge_imported_manifest(destination: &Path, mut imported: TaskActionManifest) -> Result<()> {
475 let identity = imported.task.clone();
476 let destination_path = task_manifest_path(destination, &identity);
477 if let Ok(bytes) = std::fs::read(&destination_path)
478 && let Ok(existing) = serde_json::from_slice::<TaskActionManifest>(&bytes)
479 && existing.task == identity
480 && existing.validate()
481 {
482 let mut predictions = imported
483 .predictions
484 .into_iter()
485 .map(|prediction| (prediction.invocation.clone(), prediction))
486 .collect::<BTreeMap<_, _>>();
487 predictions.extend(
488 existing
489 .predictions
490 .iter()
491 .cloned()
492 .map(|prediction| (prediction.invocation.clone(), prediction)),
493 );
494 imported.predictions = predictions.into_values().collect();
495 if !imported.validate() {
496 return Ok(());
497 }
498 }
499 write_atomic(&destination_path, &serde_json::to_vec(&imported)?)
500}
501
502fn require_object(
503 cas: &LocalCas,
504 objects: &mut BTreeSet<PathBuf>,
505 digest: &CacheDigest,
506) -> Result<()> {
507 let path = cas
508 .find(digest)?
509 .ok_or_else(|| eyre::eyre!("cache object is missing for {}", digest.hash))?;
510 objects.insert(path);
511 Ok(())
512}
513
514fn task_manifest_path(store: &Path, identity: &str) -> PathBuf {
515 store
516 .join("task-manifests/v1")
517 .join(format!("{identity}.json"))
518}
519
520fn append_file(builder: &mut tar::Builder<std::fs::File>, root: &Path, path: &Path) -> Result<()> {
521 let name = path.strip_prefix(root)?;
522 builder.append_path_with_name(path, name)?;
523 Ok(())
524}
525
526fn append_bytes(
527 builder: &mut tar::Builder<std::fs::File>,
528 name: &Path,
529 bytes: &[u8],
530) -> Result<()> {
531 let mut header = tar::Header::new_gnu();
532 header.set_size(bytes.len() as u64);
533 header.set_mode(0o644);
534 header.set_cksum();
535 builder.append_data(&mut header, name, bytes)?;
536 Ok(())
537}
538
539fn validate_archive_path(path: &Path) -> Result<()> {
540 if path.as_os_str().is_empty()
541 || path.is_absolute()
542 || path
543 .components()
544 .any(|component| !matches!(component, Component::Normal(_)))
545 {
546 eyre::bail!("cache export contains unsafe path {}", path.display());
547 }
548 let text = path.to_string_lossy();
549 if text != EXPORT_MANIFEST
550 && !text.starts_with("cas/v1/")
551 && !text.starts_with("action-results/v1/")
552 {
553 eyre::bail!("cache export contains unexpected path {}", path.display());
554 }
555 Ok(())
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
565#[serde(deny_unknown_fields)]
566struct CheckoutRecord {
567 version: u8,
568 workspace_root: PathBuf,
569 target_dir: PathBuf,
570 updated_secs: u64,
571}
572
573pub fn stats(store: &Path) -> Result<StoreStats> {
575 let objects = walk_files(&store.join(CAS_DIR))?;
576 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
577 let checkouts = scan_checkouts(store)?;
578 Ok(StoreStats {
579 objects: objects.len() as u64,
580 object_bytes: objects.iter().map(|entry| entry.size).sum(),
581 action_results: results.len() as u64,
582 action_result_bytes: results.iter().map(|entry| entry.size).sum(),
583 live_checkouts: checkouts.live_records,
584 stale_checkouts: checkouts.stale_records.len() as u64,
585 })
586}
587
588fn checkout_target_bytes(sizes: &mut BTreeMap<PathBuf, u64>, record: &CheckoutRecord) -> u64 {
595 if record.target_dir == record.workspace_root {
596 return 0;
597 }
598 cached_tree_bytes(sizes, &record.target_dir)
599}
600
601pub fn projects(store: &Path) -> Result<Vec<ProjectUsage>> {
607 let mut projects: BTreeMap<PathBuf, (BTreeSet<String>, bool, u64)> = BTreeMap::new();
608 let mut target_sizes = BTreeMap::new();
609 let root = store.join(CHECKOUTS_DIR);
610 for identity_entry in read_dir_or_empty(&root)? {
611 let identity = identity_entry.file_name().to_string_lossy().into_owned();
612 if !is_task_identity(&identity) || !identity_entry.file_type()?.is_dir() {
613 continue;
614 }
615 for entry in walk_files(&identity_entry.path())? {
616 let Some(record) = read_checkout_record(&entry.path) else {
617 continue;
618 };
619 let project = projects.entry(record.workspace_root.clone()).or_default();
620 if claim_is_live(store, &record) {
621 project.0.insert(identity.clone());
622 project.1 = true;
623 }
624 project.2 = project
625 .2
626 .max(checkout_target_bytes(&mut target_sizes, &record));
627 }
628 }
629 let action_cache = mbx_cache_core::LocalActionCache::new(store);
630 let mut usages = Vec::new();
631 for (workspace_root, (identities, live, target_bytes)) in projects {
632 let mut paths = rooted_objects(store, &identities)?;
633 for identity in &identities {
634 for action in task_manifest_actions(store, identity).unwrap_or_default() {
635 if let Ok(path) = action_cache.path_for(&action) {
636 paths.insert(path);
637 }
638 }
639 }
640 let action_bytes = paths
641 .iter()
642 .filter_map(|path| std::fs::metadata(path).ok())
643 .map(|metadata| metadata.len())
644 .sum();
645 usages.push(ProjectUsage {
646 workspace_root,
647 identities: identities.len() as u64,
648 action_bytes,
649 target_bytes,
650 live,
651 });
652 }
653 usages.sort_by(|left, right| {
654 right
655 .action_bytes
656 .saturating_add(right.target_bytes)
657 .cmp(&left.action_bytes.saturating_add(left.target_bytes))
658 .then_with(|| left.workspace_root.cmp(&right.workspace_root))
659 });
660 Ok(usages)
661}
662
663pub fn largest(store: &Path, limit: usize) -> Result<Vec<LargestEntry>> {
665 let mut entries = Vec::new();
666 for (kind, root, action_result) in [
667 ("object", store.join(CAS_DIR), false),
668 ("action result", store.join(ACTION_RESULTS_DIR), true),
669 ] {
670 entries.extend(
671 walk_files(&root)?
672 .into_iter()
673 .filter(|entry| addressed_digest(store, &entry.path, action_result).is_some())
674 .map(|entry| LargestEntry {
675 kind,
676 path: entry.path,
677 bytes: entry.size,
678 }),
679 );
680 }
681 entries.sort_by(|left, right| {
682 right
683 .bytes
684 .cmp(&left.bytes)
685 .then_with(|| left.path.cmp(&right.path))
686 });
687 entries.truncate(limit);
688 Ok(entries)
689}
690
691pub fn verify(store: &Path) -> Result<VerifyOutcome> {
693 let cas = LocalCas::new(store);
694 let action_cache = mbx_cache_core::LocalActionCache::new(store);
695 let virtually_removed = HashSet::new();
698 let mut outcome = VerifyOutcome::default();
699 for entry in walk_files(&store.join(CAS_DIR))? {
700 let Some(digest) = addressed_digest(store, &entry.path, false) else {
701 continue;
702 };
703 outcome.checked_objects += 1;
704 if !matches!(cas.find(&digest), Ok(Some(_))) {
705 outcome.problems.push(entry.path);
706 }
707 }
708 for entry in walk_files(&store.join(ACTION_RESULTS_DIR))? {
709 let Some(digest) = addressed_digest(store, &entry.path, true) else {
710 continue;
711 };
712 outcome.checked_action_results += 1;
713 if !matches!(action_cache.find(&digest), Ok(Some(_)))
714 || action_result_is_dangling(&cas, &entry.path, &virtually_removed)?
715 {
716 outcome.problems.push(entry.path);
717 }
718 }
719 outcome.problems.sort();
720 Ok(outcome)
721}
722
723pub fn remove_project(store: &Path, workspace_root: &Path) -> Result<RemoveProjectOutcome> {
725 let mut outcome = RemoveProjectOutcome::default();
726 for entry in walk_files(&store.join(CHECKOUTS_DIR))? {
727 if read_checkout_record(&entry.path)
728 .is_some_and(|record| record.workspace_root == workspace_root)
729 {
730 std::fs::remove_file(&entry.path)?;
731 outcome.removed_checkout_records += 1;
732 if let Some(parent) = entry.path.parent() {
733 let _ = std::fs::remove_dir(parent);
734 }
735 }
736 }
737 match std::fs::remove_file(latest_receipt_path(store, workspace_root)) {
738 Ok(()) => {}
739 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
740 Err(error) => return Err(error.into()),
741 }
742 Ok(outcome)
743}
744
745pub fn record_checkout(
757 store: &Path,
758 identity: &str,
759 workspace_root: &Path,
760 target_dir: &Path,
761) -> Result<()> {
762 let record = CheckoutRecord {
763 version: CHECKOUT_RECORD_VERSION,
764 workspace_root: workspace_root.to_path_buf(),
765 target_dir: target_dir.to_path_buf(),
766 updated_secs: SystemTime::now()
767 .duration_since(UNIX_EPOCH)
768 .map(|since| since.as_secs())
769 .unwrap_or_default(),
770 };
771 let mut contents = serde_json::to_vec(&record)?;
772 contents.push(b'\n');
773 write_advisory(
776 &checkout_record_path(store, identity, workspace_root),
777 &contents,
778 )
779}
780
781fn checkout_record_path(store: &Path, identity: &str, workspace_root: &Path) -> PathBuf {
782 let key = CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash;
786 store
787 .join(CHECKOUTS_DIR)
788 .join(identity)
789 .join(format!("{key}.json"))
790}
791
792pub fn gc(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
802 gc_with_mode(store, max_bytes, false)
803}
804
805pub fn gc_dry_run(store: &Path, max_bytes: u64) -> Result<GcOutcome> {
807 gc_with_mode(store, max_bytes, true)
808}
809
810fn gc_with_mode(store: &Path, max_bytes: u64, dry_run: bool) -> Result<GcOutcome> {
811 let mut objects = walk_files(&store.join(CAS_DIR))?;
812 let results = walk_files(&store.join(ACTION_RESULTS_DIR))?;
813 let mut live_bytes = objects
814 .iter()
815 .chain(results.iter())
816 .map(|entry| entry.size)
817 .sum::<u64>();
818
819 let mut outcome = GcOutcome::default();
820
821 let checkouts = scan_checkouts(store)?;
824 for path in &checkouts.stale_records {
825 if dry_run || matches!(remove(path)?, Removal::Removed) {
826 outcome.removed_checkout_records += 1;
827 }
828 if !dry_run && let Some(parent) = path.parent() {
831 let _ = std::fs::remove_dir(parent);
832 }
833 }
834
835 let mut virtually_removed = HashSet::new();
836 if live_bytes > max_bytes {
837 let mut rooted = rooted_objects(store, &checkouts.live_identities)?;
841 rooted.extend(rooted_action_objects(
848 store,
849 grouped_receipt_actions(store)?,
850 ));
851 objects.sort_by_cached_key(|entry| (rooted.contains(&entry.path), entry.used));
855 let evicted = evict_objects(&objects, &rooted, live_bytes, max_bytes, |path| {
856 if dry_run {
857 virtually_removed.insert(path.to_path_buf());
858 Ok(Removal::Removed)
859 } else {
860 remove(path)
861 }
862 })?;
863 live_bytes = evicted.remaining_bytes;
864 outcome.removed_objects += evicted.removed_objects;
865 outcome.removed_bytes += evicted.removed_bytes;
866 }
867
868 let cas = LocalCas::new(store);
873 for entry in &results {
874 if action_result_is_dangling(&cas, &entry.path, &virtually_removed)? {
875 match if dry_run {
876 Removal::Removed
877 } else {
878 remove(&entry.path)?
879 } {
880 Removal::Removed => {
881 live_bytes = live_bytes.saturating_sub(entry.size);
882 outcome.removed_action_results += 1;
883 outcome.removed_bytes += entry.size;
884 }
885 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
886 Removal::Blocked => {}
887 }
888 }
889 }
890
891 let sessions = prune_sessions(store, dry_run)?;
892 outcome.removed_session_streams += sessions.removed_streams;
893 outcome.removed_bytes += sessions.removed_bytes;
894
895 outcome.remaining_bytes = live_bytes;
896 Ok(outcome)
897}
898
899#[derive(Debug, Default, PartialEq, Eq)]
900struct SessionPrune {
901 removed_streams: u64,
902 removed_bytes: u64,
903}
904
905fn prune_sessions(store: &Path, dry_run: bool) -> Result<SessionPrune> {
906 let mut prune = SessionPrune::default();
907 let ids = events::session_ids(store);
908 let surplus = ids.len().saturating_sub(MAX_SESSIONS);
909 for (index, id) in ids.iter().enumerate() {
910 let paths = events::session_paths(store, id);
911 let metadata = match std::fs::metadata(&paths.events) {
912 Ok(metadata) => metadata,
913 Err(_) => continue,
914 };
915 let stale = metadata
916 .modified()
917 .ok()
918 .and_then(|at| SystemTime::now().duration_since(at).ok())
919 .is_some_and(|age| age > SESSION_RETENTION);
920 if index >= surplus && !stale {
921 continue;
922 }
923 if events::session_is_live(store, id) {
924 continue;
925 }
926 if dry_run {
927 prune.removed_streams += 1;
928 prune.removed_bytes += metadata.len();
929 continue;
930 }
931 if matches!(remove(&paths.events)?, Removal::Removed) {
932 prune.removed_streams += 1;
933 prune.removed_bytes += metadata.len();
934 let _ = std::fs::remove_file(&paths.lock);
935 }
936 }
937 if !dry_run {
938 for lock in events::orphaned_locks(store) {
939 let _ = std::fs::remove_file(lock);
940 }
941 }
942 Ok(prune)
943}
944
945pub fn sweep_if_due(store: &Path, max_bytes: u64, interval: Duration) -> Result<Option<GcOutcome>> {
951 if !claim_sweep(store, interval)? {
952 return Ok(None);
953 }
954 gc(store, max_bytes).map(Some)
955}
956
957pub fn claim_sweep(store: &Path, interval: Duration) -> Result<bool> {
959 let lock_path = store.join(SWEEP_LOCK);
960 std::fs::create_dir_all(lock_path.parent().expect("sweep lock has a parent"))?;
961 let mut lock = fslock::LockFile::open(&lock_path)?;
962 lock.lock()?;
963
964 let stamp = store.join(SWEEP_STAMP);
965 if let Ok(metadata) = std::fs::metadata(&stamp)
966 && let Ok(modified) = metadata.modified()
967 && let Ok(since) = modified.elapsed()
968 && since < interval
969 {
970 return Ok(false);
971 }
972 write_atomic(&stamp, b"")?;
973 Ok(true)
974}
975
976struct CheckoutScan {
978 live_identities: BTreeSet<String>,
979 live_records: u64,
980 stale_records: Vec<PathBuf>,
981}
982
983fn scan_checkouts(store: &Path) -> Result<CheckoutScan> {
984 let root = store.join(CHECKOUTS_DIR);
985 let mut scan = CheckoutScan {
986 live_identities: BTreeSet::new(),
987 live_records: 0,
988 stale_records: Vec::new(),
989 };
990 let listing = match std::fs::read_dir(&root) {
991 Ok(listing) => listing,
992 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(scan),
993 Err(error) => {
994 return Err(error).wrap_err_with(|| format!("failed to read {}", root.display()));
995 }
996 };
997 for entry in listing {
998 let entry = entry?;
999 let name = entry.file_name().to_string_lossy().into_owned();
1000 if !is_task_identity(&name) || !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
1006 continue;
1007 }
1008 for record in walk_files(&entry.path())? {
1009 match read_checkout_record(&record.path) {
1010 Some(checkout) if claim_is_live(store, &checkout) => {
1011 scan.live_identities.insert(name.clone());
1012 scan.live_records += 1;
1013 }
1014 Some(_) => scan.stale_records.push(record.path),
1015 None => {}
1018 }
1019 }
1020 }
1021 Ok(scan)
1022}
1023
1024fn read_checkout_record(path: &Path) -> Option<CheckoutRecord> {
1025 let bytes = std::fs::read(path).ok()?;
1026 let record = serde_json::from_slice::<CheckoutRecord>(&bytes).ok()?;
1027 (record.version == CHECKOUT_RECORD_VERSION).then_some(record)
1028}
1029
1030fn grouped_receipt_actions(store: &Path) -> Result<BTreeSet<CacheDigest>> {
1032 let root = store.join(BUILD_RECEIPTS_DIR).join("groups");
1033 Ok(walk_files(&root)?
1034 .into_iter()
1035 .filter_map(|entry| read_build_receipt(&entry.path))
1036 .flat_map(|receipt| receipt.predictions.into_iter())
1037 .map(|prediction| prediction.action)
1038 .collect())
1039}
1040
1041fn claim_is_live(store: &Path, record: &CheckoutRecord) -> bool {
1044 checkout_is_live_on(store, &record.workspace_root) && !claim_has_expired(record)
1045}
1046
1047fn claim_has_expired(record: &CheckoutRecord) -> bool {
1048 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
1049 return false;
1050 };
1051 now.as_secs()
1052 .saturating_sub(record.updated_secs)
1053 .gt(&CHECKOUT_RETENTION.as_secs())
1054}
1055
1056pub fn checkout_is_live_on(store: &Path, workspace_root: &Path) -> bool {
1069 if !matches!(workspace_root.try_exists(), Ok(false)) {
1070 return true;
1071 }
1072
1073 let Ok(store_metadata) = std::fs::metadata(store) else {
1074 return true;
1075 };
1076 for ancestor in workspace_root.ancestors().skip(1) {
1077 match std::fs::metadata(ancestor) {
1078 Ok(ancestor_metadata) => {
1079 return !same_filesystem(store, &store_metadata, ancestor, &ancestor_metadata);
1080 }
1081 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1082 Err(_) => return true,
1083 }
1084 }
1085 true
1086}
1087
1088pub fn checkout_is_live(workspace_root: &Path) -> bool {
1094 let Some(parent) = workspace_root.parent() else {
1095 return true;
1096 };
1097 checkout_is_live_on(parent, workspace_root)
1098}
1099
1100#[cfg(unix)]
1101fn same_filesystem(
1102 _a_path: &Path,
1103 a: &std::fs::Metadata,
1104 _b_path: &Path,
1105 b: &std::fs::Metadata,
1106) -> bool {
1107 use std::os::unix::fs::MetadataExt as _;
1108 a.dev() == b.dev()
1109}
1110
1111#[cfg(windows)]
1112fn same_filesystem(
1113 a_path: &Path,
1114 _a: &std::fs::Metadata,
1115 b_path: &Path,
1116 _b: &std::fs::Metadata,
1117) -> bool {
1118 windows_volume_name(a_path).is_some_and(|volume| windows_volume_name(b_path) == Some(volume))
1119}
1120
1121#[cfg(windows)]
1122fn windows_volume_name(path: &Path) -> Option<Vec<u16>> {
1123 use std::os::windows::ffi::OsStrExt as _;
1124 use windows_sys::Win32::Storage::FileSystem::{
1125 GetVolumeNameForVolumeMountPointW, GetVolumePathNameW,
1126 };
1127
1128 let path = path
1129 .as_os_str()
1130 .encode_wide()
1131 .chain(Some(0))
1132 .collect::<Vec<_>>();
1133 let mut mount = vec![0_u16; 261];
1137 if unsafe { GetVolumePathNameW(path.as_ptr(), mount.as_mut_ptr(), mount.len() as u32) } == 0 {
1140 return None;
1141 }
1142 let mut volume = vec![0_u16; 261];
1143 if unsafe {
1146 GetVolumeNameForVolumeMountPointW(mount.as_ptr(), volume.as_mut_ptr(), volume.len() as u32)
1147 } == 0
1148 {
1149 return None;
1150 }
1151 volume.truncate(volume.iter().position(|unit| *unit == 0)?);
1152 Some(volume)
1153}
1154
1155#[cfg(not(any(unix, windows)))]
1156fn same_filesystem(
1157 _a_path: &Path,
1158 _a: &std::fs::Metadata,
1159 _b_path: &Path,
1160 _b: &std::fs::Metadata,
1161) -> bool {
1162 false
1163}
1164
1165fn rooted_objects(store: &Path, identities: &BTreeSet<String>) -> Result<HashSet<PathBuf>> {
1173 let mut actions = BTreeSet::new();
1174 for identity in identities {
1175 let manifest_actions = match task_manifest_actions(store, identity) {
1178 Ok(actions) => actions,
1179 Err(error) => {
1180 log::debug!("could not read the manifest for {identity}: {error}");
1181 continue;
1182 }
1183 };
1184 actions.extend(manifest_actions);
1185 }
1186 Ok(rooted_action_objects(store, actions))
1187}
1188
1189fn rooted_action_objects(
1191 store: &Path,
1192 actions: impl IntoIterator<Item = CacheDigest>,
1193) -> HashSet<PathBuf> {
1194 let cas = LocalCas::new(store);
1195 let mut rooted = HashSet::new();
1196 let mut visited = BTreeSet::new();
1197 for action in actions {
1198 let Some(result) = read_action_result(store, &action) else {
1199 continue;
1200 };
1201 root_digest(&cas, &mut rooted, &result.action);
1202 if let Some(metadata) = &result.metadata {
1203 root_digest(&cas, &mut rooted, metadata);
1204 if let Some(rustc) = read_rustc_metadata(&cas, metadata) {
1211 root_digest(&cas, &mut rooted, &rustc.stdout);
1212 root_digest(&cas, &mut rooted, &rustc.stderr);
1213 }
1214 }
1215 if let Some(output_root) = &result.output_root {
1216 root_digest(&cas, &mut rooted, output_root);
1217 root_tree(&cas, output_root, &mut rooted, &mut visited);
1218 }
1219 }
1220 rooted
1221}
1222
1223fn read_action_result(store: &Path, action: &CacheDigest) -> Option<RemoteActionResult> {
1224 let path = mbx_cache_core::LocalActionCache::new(store)
1225 .path_for(action)
1226 .ok()?;
1227 let bytes = std::fs::read(path).ok()?;
1228 serde_json::from_slice(&bytes).ok()
1229}
1230
1231fn root_tree(
1232 cas: &LocalCas,
1233 output_root: &CacheDigest,
1234 rooted: &mut HashSet<PathBuf>,
1235 visited: &mut BTreeSet<CacheDigest>,
1236) {
1237 let mut pending = vec![output_root.clone()];
1238 while let Some(digest) = pending.pop() {
1239 if !visited.insert(digest.clone()) {
1240 continue;
1241 }
1242 let Some(directory) = read_directory(cas, &digest) else {
1243 continue;
1244 };
1245 for file in &directory.files {
1246 root_digest(cas, rooted, &file.digest);
1247 }
1248 for child in &directory.directories {
1249 root_digest(cas, rooted, &child.digest);
1250 pending.push(child.digest.clone());
1251 }
1252 }
1253}
1254
1255fn read_rustc_metadata(cas: &LocalCas, digest: &CacheDigest) -> Option<RustcMetadata> {
1256 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1257 serde_json::from_slice(&bytes).ok()
1258}
1259
1260fn read_directory(cas: &LocalCas, digest: &CacheDigest) -> Option<CacheDirectory> {
1261 let bytes = std::fs::read(cas.path_for(digest).ok()?).ok()?;
1262 serde_json::from_slice(&bytes).ok()
1263}
1264
1265fn root_digest(cas: &LocalCas, rooted: &mut HashSet<PathBuf>, digest: &CacheDigest) {
1266 if let Ok(path) = cas.path_for(digest) {
1267 rooted.insert(path);
1268 }
1269}
1270
1271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1272enum Removal {
1273 Removed,
1274 Missing,
1275 Blocked,
1276}
1277
1278fn remove(path: &Path) -> Result<Removal> {
1279 match std::fs::remove_file(path) {
1280 Ok(()) => Ok(Removal::Removed),
1281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Removal::Missing),
1283 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1288 log::debug!("could not evict {}: {error}", path.display());
1289 Ok(Removal::Blocked)
1290 }
1291 Err(error) => Err(error).wrap_err_with(|| format!("failed to evict {}", path.display())),
1292 }
1293}
1294
1295#[derive(Debug, Default, PartialEq, Eq)]
1296struct ObjectEvictions {
1297 removed_objects: u64,
1298 removed_bytes: u64,
1299 remaining_bytes: u64,
1300}
1301
1302fn evict_objects(
1305 objects: &[Entry],
1306 rooted: &HashSet<PathBuf>,
1307 mut live_bytes: u64,
1308 max_bytes: u64,
1309 mut remove_file: impl FnMut(&Path) -> Result<Removal>,
1310) -> Result<ObjectEvictions> {
1311 let mut outcome = ObjectEvictions::default();
1312 let mut blocked_unrooted = false;
1313 for entry in objects {
1314 if live_bytes <= max_bytes {
1315 break;
1316 }
1317 let is_rooted = rooted.contains(&entry.path);
1318 if is_rooted && blocked_unrooted {
1319 break;
1323 }
1324 match remove_file(&entry.path)? {
1325 Removal::Removed => {
1326 live_bytes = live_bytes.saturating_sub(entry.size);
1327 outcome.removed_objects += 1;
1328 outcome.removed_bytes += entry.size;
1329 }
1330 Removal::Missing => live_bytes = live_bytes.saturating_sub(entry.size),
1331 Removal::Blocked if !is_rooted => blocked_unrooted = true,
1332 Removal::Blocked => {}
1333 }
1334 }
1335 outcome.remaining_bytes = live_bytes;
1336 Ok(outcome)
1337}
1338
1339fn action_result_is_dangling(
1357 cas: &LocalCas,
1358 path: &Path,
1359 virtually_removed: &HashSet<PathBuf>,
1360) -> Result<bool> {
1361 let bytes = match std::fs::read(path) {
1362 Ok(bytes) => bytes,
1363 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1364 Err(error) => {
1365 return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
1366 }
1367 };
1368 let Ok(result) = serde_json::from_slice::<RemoteActionResult>(&bytes) else {
1370 return Ok(false);
1371 };
1372 for digest in [
1373 Some(&result.action),
1374 result.metadata.as_ref(),
1375 result.output_root.as_ref(),
1376 ]
1377 .into_iter()
1378 .flatten()
1379 {
1380 match cas.path_for(digest) {
1381 Ok(path) => {
1382 if virtually_removed.contains(&path) || !path.exists() {
1383 return Ok(true);
1384 }
1385 }
1386 Err(_) => return Ok(true),
1388 }
1389 }
1390 Ok(false)
1391}
1392
1393struct Entry {
1394 path: PathBuf,
1395 size: u64,
1396 used: SystemTime,
1397}
1398
1399fn walk_files(root: &Path) -> Result<Vec<Entry>> {
1400 let mut entries = Vec::new();
1401 let mut pending = vec![root.to_path_buf()];
1402 while let Some(directory) = pending.pop() {
1403 let listing = match std::fs::read_dir(&directory) {
1404 Ok(listing) => listing,
1405 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1406 Err(error) => {
1407 return Err(error)
1408 .wrap_err_with(|| format!("failed to read {}", directory.display()));
1409 }
1410 };
1411 for entry in listing {
1412 let entry = entry?;
1413 let metadata = match entry.metadata() {
1414 Ok(metadata) => metadata,
1415 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1417 Err(error) => return Err(error.into()),
1418 };
1419 if metadata.is_dir() {
1420 pending.push(entry.path());
1421 } else if metadata.is_file() {
1422 let used = metadata
1423 .accessed()
1424 .or_else(|_| metadata.modified())
1425 .unwrap_or(SystemTime::UNIX_EPOCH);
1426 entries.push(Entry {
1427 path: entry.path(),
1428 size: metadata.len(),
1429 used,
1430 });
1431 }
1432 }
1433 }
1434 Ok(entries)
1435}
1436
1437fn read_dir_or_empty(root: &Path) -> Result<Vec<std::fs::DirEntry>> {
1438 match std::fs::read_dir(root) {
1439 Ok(entries) => entries
1440 .collect::<std::io::Result<Vec<_>>>()
1441 .map_err(Into::into),
1442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
1443 Err(error) => Err(error).wrap_err_with(|| format!("failed to read {}", root.display())),
1444 }
1445}
1446
1447fn digest_from_path(path: &Path, action_result: bool) -> Option<CacheDigest> {
1448 let name = path.file_name()?.to_str()?;
1449 let name = if action_result {
1450 name.strip_suffix(".json")?
1451 } else {
1452 name
1453 };
1454 let (hash, size) = name.rsplit_once('-')?;
1455 let algorithm = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
1456 let digest = CacheDigest {
1457 algorithm,
1458 hash: hash.to_string(),
1459 size: size.parse().ok()?,
1460 };
1461 digest.validate().ok()?;
1462 Some(digest)
1463}
1464
1465fn addressed_digest(store: &Path, path: &Path, action_result: bool) -> Option<CacheDigest> {
1466 let digest = digest_from_path(path, action_result)?;
1467 let canonical = if action_result {
1468 mbx_cache_core::LocalActionCache::new(store)
1469 .path_for(&digest)
1470 .ok()?
1471 } else {
1472 LocalCas::new(store).path_for(&digest).ok()?
1473 };
1474 (canonical == path).then_some(digest)
1475}
1476
1477fn cached_tree_bytes(cache: &mut BTreeMap<PathBuf, u64>, root: &Path) -> u64 {
1478 *cache
1479 .entry(root.to_path_buf())
1480 .or_insert_with(|| tree_bytes(root))
1481}
1482
1483fn tree_bytes(root: &Path) -> u64 {
1484 let Ok(root_metadata) = std::fs::metadata(root) else {
1485 return 0;
1486 };
1487 if root_metadata.is_file() {
1488 return root_metadata.len();
1489 }
1490 let Ok(entries) = std::fs::read_dir(root) else {
1491 return 0;
1492 };
1493 let mut bytes = 0_u64;
1494 let mut pending = entries
1495 .filter_map(std::result::Result::ok)
1496 .map(|entry| entry.path())
1497 .collect::<Vec<_>>();
1498 while let Some(path) = pending.pop() {
1499 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1500 continue;
1501 };
1502 if metadata.is_file() {
1503 bytes = bytes.saturating_add(metadata.len());
1504 } else if metadata.is_dir()
1505 && let Ok(entries) = std::fs::read_dir(path)
1506 {
1507 pending.extend(
1508 entries
1509 .filter_map(std::result::Result::ok)
1510 .map(|entry| entry.path()),
1511 );
1512 }
1513 }
1514 bytes
1515}
1516
1517#[cfg(test)]
1518#[path = "store_tests.rs"]
1519mod tests;
1520
1521fn write_advisory(path: &Path, contents: &[u8]) -> Result<()> {
1524 let parent = path
1525 .parent()
1526 .filter(|parent| !parent.as_os_str().is_empty())
1527 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1528 std::fs::create_dir_all(parent)
1529 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1530 let mut temporary = tempfile::Builder::new()
1531 .prefix(".mbx-")
1532 .tempfile_in(parent)?;
1533 use std::io::Write as _;
1534 temporary.write_all(contents)?;
1535 temporary
1536 .persist(path)
1537 .map_err(|error| error.error)
1538 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1539 Ok(())
1540}
1541
1542fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
1543 let parent = path
1544 .parent()
1545 .filter(|parent| !parent.as_os_str().is_empty())
1546 .ok_or_else(|| eyre::eyre!("path has no parent: {}", path.display()))?;
1547 std::fs::create_dir_all(parent)
1548 .wrap_err_with(|| format!("failed to create {}", parent.display()))?;
1549 let mut temporary = tempfile::Builder::new()
1550 .prefix(".mbx-")
1551 .tempfile_in(parent)?;
1552 use std::io::Write as _;
1553 temporary.write_all(contents)?;
1554 temporary.as_file_mut().sync_all()?;
1555 temporary
1556 .persist(path)
1557 .map_err(|error| error.error)
1558 .wrap_err_with(|| format!("failed atomic write: {}", path.display()))?;
1559 Ok(())
1560}