Skip to content

Commit 2dfcb77

Browse files
authored
Merge pull request #45 from AI45Lab/fix/box-oci-concurrent-copy
fix(runtime): atomic OCI store/build-cache writes (stage + rename)
2 parents 54b0e47 + f0ad7d3 commit 2dfcb77

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

src/runtime/src/oci/build/cache.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515
//! NOT fail the build.
1616
1717
use std::path::{Path, PathBuf};
18+
use std::sync::atomic::{AtomicU64, Ordering};
1819

1920
use a3s_box_core::dirs_home;
2021
use serde::{Deserialize, Serialize};
2122

2223
use super::layer::{sha256_bytes, LayerInfo};
2324

25+
/// Per-process counter for unique staging-file names in `store`.
26+
static STORE_SEQ: AtomicU64 = AtomicU64::new(0);
27+
2428
/// Default cap on the total size of cached layer blobs (2 GiB). Override with
2529
/// `A3S_BOX_BUILDCACHE_MAX_BYTES`. When the cap is exceeded after a store, the
2630
/// oldest blobs are evicted (FIFO) until the total is back under the cap; a
@@ -125,8 +129,27 @@ impl BuildCache {
125129
/// the `keys/<key>` record. Best-effort: I/O errors are ignored.
126130
pub(crate) fn store(&self, key: &str, layer: &LayerInfo, diff_id: &str) {
127131
let blob_path = self.dir.join("blobs").join(&layer.digest);
128-
if !blob_path.exists() && std::fs::copy(&layer.path, &blob_path).is_err() {
129-
return;
132+
if !blob_path.exists() {
133+
// Copy to a unique temp file then atomically rename into place, so a
134+
// concurrent build (or a copy that fails partway) never publishes a
135+
// half-written blob that a key record then points at. The blob is
136+
// content-addressed, so a rename that overwrites a racing winner is
137+
// harmless (identical bytes).
138+
let seq = STORE_SEQ.fetch_add(1, Ordering::Relaxed);
139+
let staging = self.dir.join("blobs").join(format!(
140+
".staging-{}-{}-{}",
141+
layer.digest,
142+
std::process::id(),
143+
seq
144+
));
145+
if std::fs::copy(&layer.path, &staging).is_err() {
146+
let _ = std::fs::remove_file(&staging);
147+
return;
148+
}
149+
if std::fs::rename(&staging, &blob_path).is_err() {
150+
let _ = std::fs::remove_file(&staging);
151+
return;
152+
}
130153
}
131154

132155
let record = KeyRecord {

src/runtime/src/oci/store.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
use std::collections::HashMap;
88
use std::path::{Path, PathBuf};
9+
use std::sync::atomic::{AtomicU64, Ordering};
910
use std::sync::Arc;
1011

1112
use a3s_box_core::error::{BoxError, Result};
@@ -14,6 +15,9 @@ use chrono::Utc;
1415
use serde::{Deserialize, Serialize};
1516
use tokio::sync::RwLock;
1617

18+
/// Per-process counter for unique staging-dir names in `put`.
19+
static PUT_SEQ: AtomicU64 = AtomicU64::new(0);
20+
1721
/// Persistent index stored as JSON on disk.
1822
#[derive(Debug, Default, Serialize, Deserialize)]
1923
struct StoreIndex {
@@ -130,11 +134,36 @@ impl ImageStore {
130134
let digest_hex = digest.strip_prefix("sha256:").unwrap_or(digest);
131135
let target_dir = self.store_dir.join("sha256").join(digest_hex);
132136

133-
// Copy source to target if not already present
137+
// Copy source to target if not already present. Stage into a unique temp
138+
// dir then atomically rename into place, so a concurrent put() for the
139+
// same digest — or a copy that fails partway — can never leave a
140+
// half-populated content-addressed dir that a later caller mistakes for a
141+
// complete image (the bare check-then-copy raced on both counts).
134142
if !target_dir.exists() {
135-
copy_dir_recursive(source_dir, &target_dir).map_err(|e| {
143+
let seq = PUT_SEQ.fetch_add(1, Ordering::Relaxed);
144+
let staging = self.store_dir.join("sha256").join(format!(
145+
".staging-{}-{}-{}",
146+
digest_hex,
147+
std::process::id(),
148+
seq
149+
));
150+
let _ = std::fs::remove_dir_all(&staging);
151+
copy_dir_recursive(source_dir, &staging).map_err(|e| {
152+
let _ = std::fs::remove_dir_all(&staging);
136153
BoxError::OciImageError(format!("Failed to copy image to store: {}", e))
137154
})?;
155+
if let Err(e) = std::fs::rename(&staging, &target_dir) {
156+
let _ = std::fs::remove_dir_all(&staging);
157+
// A concurrent put() may have populated target_dir first (rename
158+
// onto a non-empty dir fails); that's fine — only propagate if the
159+
// image still isn't there.
160+
if !target_dir.exists() {
161+
return Err(BoxError::OciImageError(format!(
162+
"Failed to publish image to store: {}",
163+
e
164+
)));
165+
}
166+
}
138167
}
139168

140169
let size_bytes = dir_size(&target_dir);

0 commit comments

Comments
 (0)