Skip to content

Commit 6cfd59b

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(runtime): GC orphaned build-cache key records (operability audit #21) (#132)
BuildCache::store wrote a keys/<chain-key> JSON record on every cacheable step and called prune_to, but prune_to evicted ONLY blobs/ — the keys/ directory was never cleaned. The chain key folds COPY/ADD content hashes, so edit-and-rebuild yields a fresh key per content change; over a long-lived build host these small records accumulate without bound, and after blob eviction they become permanent dangling pointers (lookup correctly misses but never removes them). 'system prune' has no buildcache references either. After evicting blobs, prune_to now sweeps keys/ and removes any record whose referenced blobs/<digest> no longer exists (also drops unparseable/truncated key records). Test prune_evicts_orphan_key_records. Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent 6ebb5b7 commit 6cfd59b

1 file changed

Lines changed: 53 additions & 0 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,32 @@ impl BuildCache {
200200
total = total.saturating_sub(len);
201201
}
202202
}
203+
204+
// Blobs were just evicted — drop key records that now point at nothing,
205+
// so the keys/ dir doesn't accumulate dangling pointers without bound.
206+
self.prune_orphan_keys();
207+
}
208+
209+
/// Remove key records whose referenced blob no longer exists. Each key is a
210+
/// small JSON record, but a long-lived build host edits-and-rebuilds many
211+
/// chain keys and `prune_to` only evicts blobs, so without this the keys/
212+
/// directory grows unbounded (and leaves dangling pointers after eviction).
213+
/// Best-effort; a missing keys/ dir is a no-op.
214+
fn prune_orphan_keys(&self) {
215+
let Ok(read_dir) = std::fs::read_dir(self.dir.join("keys")) else {
216+
return;
217+
};
218+
for entry in read_dir.flatten() {
219+
let path = entry.path();
220+
let keep = std::fs::read(&path)
221+
.ok()
222+
.and_then(|bytes| serde_json::from_slice::<KeyRecord>(&bytes).ok())
223+
.is_some_and(|record| self.dir.join("blobs").join(&record.digest).exists());
224+
if !keep {
225+
// Blob evicted, or an unparseable/truncated key record: cruft.
226+
let _ = std::fs::remove_file(&path);
227+
}
228+
}
203229
}
204230
}
205231

@@ -332,6 +358,33 @@ mod tests {
332358
assert_eq!(fs::read(&hit.blob_path).unwrap(), b"fake layer contents");
333359
}
334360

361+
#[test]
362+
fn prune_evicts_orphan_key_records() {
363+
let tmp = TempDir::new().unwrap();
364+
let cache_dir = tmp.path().join("buildcache");
365+
let cache = open_at(&cache_dir);
366+
367+
let layer_path = tmp.path().join("layer.tar.gz");
368+
fs::write(&layer_path, vec![0u8; 4096]).unwrap();
369+
let layer = LayerInfo {
370+
path: layer_path,
371+
digest: "cafef00d".to_string(),
372+
size: 4096,
373+
};
374+
let key = BuildCache::chain("", "RUN make", None);
375+
cache.store(&key, &layer, "diff");
376+
377+
let blob = cache_dir.join("blobs").join("cafef00d");
378+
let key_file = cache_dir.join("keys").join(&key);
379+
assert!(blob.exists() && key_file.exists());
380+
381+
// Force the cache under the blob size: evicts the blob AND prunes its
382+
// now-dangling key record (previously the key file leaked forever).
383+
cache.prune_to(0);
384+
assert!(!blob.exists(), "blob should be evicted");
385+
assert!(!key_file.exists(), "orphaned key record should be pruned");
386+
}
387+
335388
#[test]
336389
fn test_lookup_misses_when_blob_removed() {
337390
let tmp = TempDir::new().unwrap();

0 commit comments

Comments
 (0)