Repository navigation
Serialize and atomically publish OCI cache writes - #502
Conversation
layout.AppendImage does an unsynchronized read-modify-write of index.json. Overlapping appends from registry pushes, remote pulls, and builder imports lost descriptors or left the index malformed. Route all three through ocicache.AppendImage, which writes blobs concurrently and holds a mutex around the index append.
0a7a1fa to
41790f7
Compare
chruffins
left a comment
There was a problem hiding this comment.
reviewed — approving with findings below. the two bugs are worth fixing, ideally in this PR or a fast follow-up.
Bugs
lib/ocicache/layout.go:38-41— the init fallback runs on anylayout.FromPatherror, not just not-exist.FromPathonly statsindex.json, so a non-ENOENT stat error (EACCES, EIO) falls intolayout.Write(cacheDir, empty.Index), which rewritesindex.jsonas empty and drops every existing descriptor. consider only initializing onfs.ErrNotExistand returning other errors.lib/ocicache/layout.go:43—AppendDescriptorwritesindex.jsonin place (WriteFiletruncates, then writes). the mutex serializes writers, but an interrupted or failed write (ENOSPC, crash) leaves a truncated index. consider marshaling under the lock, writing a sibling temp file, andos.Rename-ing it overindex.json. rename is atomic, so this also closes the unlocked-reader partial-read gap the PR defers.
Structural / Maintainability
lib/ocicache/layout.go:35-43— the init branch and the append are two separate index operations under one lock. a single read-modify-write (missing index = empty, append, temp+rename) would drop theFromPathprobe and thelayout.Writefallback, and would also fix the first bug.
Questions
lib/ocicache/layout.go:16— is one process per cache directory an enforced invariant? the mutex only covers in-process callers.lib/ocicache/layout_test.go:14— the PR body says this test fails on main, but it callsocicache.AppendImage, which doesn't exist on main. what was actually run to show that?
Nits
lib/ocicache/layout.go:33— nit:partial.Descriptorcan return the image's retained descriptor pointer (remote images do), so settingAnnotationsmutates the caller's image. not reachable from the three current callers, but copying the struct before annotating avoids it.
coverage — all 5 changed files inspected statically; no generated files in the diff. dimensions checked: behavior parity with the old caller code, boundary inputs, errors/cleanup, concurrency, API compatibility, performance, test adequacy. unverified: runtime interleavings, cross-process behavior, and the test outcome on main. no tests were run during review.
status: review complete; approved at 41790f7
Any other FromPath error used to fall through to layout.Write, which replaces index.json with an empty index. Also copy the descriptor before annotating so remote images keep their own descriptor untouched.
|
thanks for the review. addressed in 4da048d:
|
chruffins
left a comment
There was a problem hiding this comment.
reviewed — thanks for the follow-up on the init guard, descriptor copy, and test wording. two items left:
lib/ocicache/layout.go:23— config and manifest blobs can be corrupted by concurrent writes of the same image.WriteImagesends them throughWriteBlob, which writes straight to the final digest path and skips any existing file regardless of size. a second writer can publish a descriptor while the first is still writing, and a blob truncated by a killed writer is never rewritten. concurrent pushes of the same digest hit this (lib/registry/registry.go:153spawns a conversion per manifest PUT). the 64-writer test uses random images, so it never shares a config blob. consider writing config and manifest via temp+rename in the helper, skipping only on a size match, and adding a case with one image under two tags. the "blob writes run concurrently" comment atlayout.go:20-22should also be narrowed to layers.lib/ocicache/layout.go:51—index.jsonis still written in place. agreed that temp+rename is the right follow-up for the unlocked-reader gap. the lock does not cover crash or ENOSPC mid-write, though: that leaves an empty index that never self-repairs. either do the rename here (it's a few lines) or note in the PR body that crash and ENOSPC during an append are not covered.
status: review complete; read-only; no tests run
chruffins
left a comment
There was a problem hiding this comment.
approving at 4da048d, with the two follow-up items from my last comment:
lib/ocicache/layout.go:23— config and manifest blobs go throughWriteBlob, which writes to the final digest path and skips any existing file regardless of size. concurrent pushes of the same image can publish a descriptor for a blob still being written. consider temp+rename for config and manifest in the helper, skipping only on size match, and a test with one image under two tags. also narrow the "blob writes run concurrently" comment atlayout.go:20-22to layers.lib/ocicache/layout.go:51—index.jsonis still written in place. the lock doesn't cover crash or ENOSPC mid-write. either do the rename here or note in the PR body that those cases aren't covered.
status: review complete; read-only; no tests run
Write every cache file to a temp file and rename it into place. index.json no longer ends up empty or truncated after a crash or ENOSPC mid-append, and unlocked readers never see a partial index. Config and manifest blobs previously went straight to their final path and were skipped on any existing file; a blob truncated by a killed writer was trusted forever. Blobs are now skipped only when the existing size matches.
|
both taken in 5767a6e rather than deferred:
temp names are |
chruffins
left a comment
There was a problem hiding this comment.
approving at 5767a6e. remaining items, all nits:
- nit:
lib/ocicache/layout.go:184-187—writeFilerenames withoutSync(). rename is safe against process crash, but after power loss or a host reset the renamedindex.jsonor blob can come back empty. considertmp.Sync()before close, and a directory sync after the rename if you want the same guarantee for the rename itself. - nit:
lib/registry/registry.go:174—storeManifestBlobstill writes the manifest blob withos.WriteFileon the final path, outside the helper. a second PUT of the same digest can truncate the manifest while the conversion from the first PUT (registry.go:153) is reading it. consider routing it through the same atomic writer. - nit:
lib/ocicache/layout.go:27-28— the doc comment says an interrupted write "leaves nothing behind." a killed process does leave.<name>.tmp-*files, and the GC only sweeps 64-hex names, so they accumulate. either reword the comment or have GC reap stale temp files.
status: review complete; read-only; no tests run
fsync the temp file so a renamed index or blob survives power loss on filesystems without flush-on-rename. Route the registry's Docker manifest blob through the same writer instead of os.WriteFile on the final path.
|
all three in 0c19f67:
|
waitForInstanceState returns a nil instance on timeout and the tests reassign inst from it, so the cleanup registered earlier dereferenced nil and panicked the whole package instead of reporting one failed test.
Summary
layout.AppendImagedoes an unsynchronized read-modify-write ofindex.json. Registry pushes, remote image pulls, and builder imports could all append at the same time, which lost descriptors or left the index malformed.Add
ocicache.AppendImageand point the three callers at it. Blob writes run concurrently; the index append holds a package mutex.Every cache file (layers, config, manifest,
oci-layout,index.json) is written to a temp file, fsynced, and renamed into place. A crash or ENOSPC mid-write never replaces a good file, a renamed file survives power loss on XFS, and readers ofindex.json, which take no lock, never see a partial file.Blobs are skipped only when the existing file has the expected size. The library wrote config and manifest straight to their final path and skipped any existing file regardless of size, so a blob truncated by a killed writer was trusted forever.
The registry's Docker manifest blob goes through the same writer. It was an
os.WriteFileon the final path, which a second PUT of the same digest could truncate while the first PUT's conversion was reading it.Initialize the layout only when it does not exist. The old call sites fell through to
layout.Writeon anyFromPatherror, which replaces the index with an empty one.Copy the descriptor before annotating it.
partial.Descriptoron a remote image returns the image's own descriptor.lib/instances/qemu_test.go: capture the instance ID before registering cleanup. The tests reassigninstfromwaitForInstanceState, which returns nil on timeout, so one boot timeout panicked the whole package. Surfaced by a CI run on ansforunner where the QEMU microvm tests time out; unrelated to this change.Tests: 64 distinct images appended concurrently, one image appended under 16 tags concurrently, a truncated config blob rewritten on the next append, and a reader that never observes an invalid
index.jsonduring appends.The same 64-writer scenario run against
layout.AppendImagedirectly fails every time, either with lost descriptors (22 or 23 of 64) or withinvalid character '}' after top-level value, the error seen in production.One hypeman process owns a cache directory, so an in-process mutex is sufficient. Temp file names (
.<name>.tmp-*) never match a blob digest, so the cache GC ignores them. A killed process can leave them behind; they are small and only accumulate on crashes.Existing malformed indexes are not repaired by this change.
Validation
go test -race -count=5 ./lib/ocicache,go test -race ./lib/registry ./lib/ocicachegc,go test ./lib/builds.go vetonlib/ocicache,lib/registry,lib/images,lib/builds,lib/ocicachegc.lib/images: the OCI cache import tests exercise the new append path through to a cache hit. Three tests fail in my environment for unrelated reasons (mkfs.erofsnot installed, a 60s registry timeout) and fail identically on main.Note
Medium Risk
Changes how the shared OCI image cache is written under concurrency; incorrect behavior could break pulls, pushes, or builder imports, but the change targets known index corruption and non-atomic blob writes.
Overview
Introduces
lib/ocicacheso registry pushes, remote pulls, and builder imports no longer call unsynchronizedlayout.AppendImageon the shared OCI cache.AppendImagewrites layers/config/manifest concurrently, then appends toindex.jsonunder a package mutex;WriteBlobis used for registry manifest blobs. All cache files are written via temp file, fsync, and rename, and existing blobs are reused only when on-disk size matches (so truncated writes get fixed instead of trusted forever). Layout initialization no longer overwrites the index on arbitraryFromPatherrors.Call sites in
lib/builds,lib/images, andlib/registryswitch to these helpers. QEMU integration tests captureinstanceIDin cleanup closures to avoid racing on a reassignedinst.Reviewed by Cursor Bugbot for commit 1679ab7. Bugbot is set up for automated code reviews on this repo. Configure here.