Skip to content

fix(content/oci): make the local OCI store survive a partial write - #1301

Open
cwedgwood wants to merge 2 commits into
oras-project:mainfrom
cwedgwood:fix/oci-metadata-atomic-write
Open

fix(content/oci): make the local OCI store survive a partial write#1301
cwedgwood wants to merge 2 commits into
oras-project:mainfrom
cwedgwood:fix/oci-metadata-atomic-write

Conversation

@cwedgwood

Copy link
Copy Markdown

index.json and oci-layout are written with os.WriteFile, which opens the file
O_TRUNC. The truncation succeeds even when the file system is full, so a write that fails
part-way leaves the file empty — and loadIndexFile treats a zero-length file as a decode
error rather than the missing-file state it already recovers from. The store then fails to
open for as long as the file is there, and nothing rebuilds it:

invalid OCI Image Index: failed to decode index file: EOF

This is an edge case, but we have hit it several times in production. A node fills up,
index.json is left at 0 bytes, and because the cache directory outlives the container,
restarting does not help. blobs/ is unaffected, since blobs already go through
ingest-then-rename.

os.WriteFile(filepath.Join(dir, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0666)
os.WriteFile(filepath.Join(dir, "index.json"), nil, 0666)
_, err := oci.New(dir) // invalid OCI Image Index: failed to decode index file: EOF

Commit 1 writes both metadata files to a temporary file in the same directory, flushes
it, and renames it over the target — the same thing content/oci/storage.go already does
for blobs and registry/remote/config does for the config file. The flush is load-bearing:
with delayed allocation a write is accepted against space that is never allocated and
Close does not report it, so rename alone would not fix the ENOSPC case. A zero-length
metadata file is now treated as missing and rewritten; malformed-but-not-empty still fails,
since that may be real corruption whose tags are worth keeping for a human to look at.

Commit 2 fixes ingest returning return "", err, which clears the named return path
before the deferred cleanup runs, so it calls os.Remove("") and leaks the ingest file —
the same bug #1185 fixed in registry/remote/internal/ioutil. GC now also reclaims that
residue, which nothing did before. Happy to split this commit into its own PR.

Worth knowing, all documented on writeFileAtomic: replacing a file rather than writing
through it means symlinks and hard links are replaced, and a 0444 or 0000 target is
replaced where os.WriteFile returned EACCES, though its mode is preserved. Ownership,
ACLs and xattrs are not carried over. Permissions are otherwise unchanged — os.CreateTemp
ignores the umask, so the temporary file is created with O_EXCL at 0666, or at the
target's own mode when replacing, and set exactly before the rename. The directory flush
after the rename is best-effort and returns nothing, since by then the rename has already
taken effect.

Five of the new tests fail against current main, including TestStore_EmptyIndexFile with
the exact error above and TestStorage_BadPush_NoIngestFileLeftBehind with
len(ingest entries) = 1, want 0. make test passes under both matrix Go versions (1.25
and 1.26), coverage 85.3%, and TestStore_BadIndex and TestStore_BadLayout still pass.

Not in scope: the blob write is not flushed either, and its directory entry is not synced
after the rename. Both are worth fixing, but separately from this.

We still depend on the v2 line in a number of places, so we would value a backport if this
is accepted. The two commits cherry-pick onto v2 cleanly and the suite passes there; happy
to open that PR whenever you want it.

Comment thread content/oci/oci.go
@cwedgwood
cwedgwood force-pushed the fix/oci-metadata-atomic-write branch from 58339e4 to fd8e0a9 Compare August 19, 2026 21:05
@cwedgwood
cwedgwood requested a review from TerryHowe August 20, 2026 00:54
@cwedgwood

Copy link
Copy Markdown
Author

Suggestion applied and pushed — this is ready for another look.

  • leftoverExpiry, olderThan() and the reworked gcLeftovers are in as written; removeFiles now takes func(fs.DirEntry) bool.
  • TestStore_GC_Leftovers covers both directions: entries backdated past the expiry are reclaimed, a freshly written ingest file and metadata temporary file survive. Against the previous unconditional sweep that case fails, so it holds the behaviour rather than describing it. Test_olderThan covers the matcher, including an entry whose Info() cannot be read.
  • The commit message asserted the assumption you corrected; it now describes the expiry instead.
  • Rebased onto main. make test passes on both matrix Go versions, and lint, build and the license check pass on a fork run of these commits — the workflow approval here is still pending, so the checks on this PR have not run yet.

One note in case it saves you a broken build: the inline suggestion still shows as applicable, but it is anchored to what is now the closing brace of gcLeftovers, so committing it would splice a second copy of all three declarations into the function. It is already applied by hand.

@cwedgwood
cwedgwood force-pushed the fix/oci-metadata-atomic-write branch from dd38d3a to a022b71 Compare August 24, 2026 07:52
@cwedgwood

Copy link
Copy Markdown
Author

@TerryHowe — this has your suggestion applied and is rebased on main; the checks here still need a workflow approval to run. Anything else you'd like changed?

@TerryHowe

Copy link
Copy Markdown
Member

I'm seeing some performance issues in testing, but I need to investigate the finding

@cwedgwood

Copy link
Copy Markdown
Author

Confirmed, and thank you for catching it. I reproduced it, and it is worse than I would have
guessed. I benchmarked the blob path when I was looking at #1203 and never benchmarked my own
change, which is the thing I should have done before opening this.

The cause is that AutoSaveIndex is on by default, so index.json is rewritten on every push and
every tag, and I made each of those rewrites pay an fsync on the file plus an fsync on the
directory.

Measured on ext4 on an enterprise SSD (fsync ~0.09 ms), 30 iterations x 3 runs, medians. "50
pushes" is 50 config+manifest pairs into one store; Tag is one tag on an existing manifest:

variant 50 pushes vs base Tag vs base
main today 7.80 ms 1.00x 0.041 ms 1.00x
temp file + rename, no fsync 8.15 ms 1.05x 0.065 ms 1.58x
+ fsync the file 13.93 ms 1.79x 0.152 ms 3.74x
+ fsync the directory (this PR) 18.44 ms 2.36x 0.228 ms 5.61x

The ratio gets worse on faster storage, because the baseline shrinks faster than the fsync does
— on a slower machine the same comparison was 1.93x / 1.73x. The numbers above are not a worst case.

The two fsync calls are not equally worth their cost

I would like to drop the directory fsync and keep the one on the file, if that seems reasonable
to you.

The directory fsync only makes the rename durable. Losing it means a crash leaves the previous
index.json in place, which is still a valid index, so the failure is benign. It costs 2.36x ->
1.79x on pushes and 5.61x -> 3.74x on tags, which I do not think it earns.

The one on the file is doing more work. Without it there is no guarantee that the contents reach
the disk before the rename does, so a crash can leave the file present and empty — the same
unusable store this PR is about, arriving by a different route. That is the part I would rather
not give up, because it would leave the symptom reachable while the code reads as though it were
handled.

The frequency may matter more than the per-call cost

The overhead scales with how often index.json is rewritten, and the store already has a knob for
that. The same 50-manifest workload with AutoSaveIndex = false and one SaveIndex() at the end:

50 pushes vs base
main today 4.90 ms 1.00x
this PR, directory fsync dropped 5.19 ms 1.06x
this PR as it stands 5.41 ms 1.10x

So a caller doing bulk work can already pay one fsync instead of fifty for about 6%. If that
seems useful I will add a sentence to the AutoSaveIndex docs pointing at it.

That leaves 1.79x on the default path. My instinct is that the safe default is the right one here,
partly because it is not symmetric: a caller who wants throughput has AutoSaveIndex today,
whereas a caller who wants the flush cannot add it from outside. But you have a much better view
of how people actually use this than I do, and if 1.79x is too much for the common case I am happy
to put it behind an option on Store, or to take a different approach entirely if you have one in
mind — I may well be missing a cheaper way to get the same guarantee.

Happy to push whichever shape you prefer. Worth knowing that the checks here have never run — they
are still waiting on a workflow approval — so the numbers above are from local runs and a run on
my fork.

@cwedgwood
cwedgwood force-pushed the fix/oci-metadata-atomic-write branch from a022b71 to ea7295b Compare August 26, 2026 22:56
`index.json` and `oci-layout` are written with `os.WriteFile`, which opens
the file with `O_TRUNC`. The truncation succeeds even when the file system
is full, so a write that fails part-way replaces a good file with an empty
one, or with only the part of the content that was written before the
failure.

A store whose `index.json` has been emptied this way fails to open with
"failed to decode index file: EOF" for as long as the file is there, and
nothing rebuilds it, so a transient disk-full condition becomes permanent.

Write both files to a temporary file in the same directory and rename that
file over the target, which is what the package already does for blobs in
`content/oci/storage.go`, and what `registry/remote/config` does for the
configuration file. The content is flushed before the rename: with delayed
allocation a write can be accepted against space that is never allocated,
and the resulting failure is reported neither by the write nor by `Close`,
so a rename without a flush can still publish content that never reached
the disk. The directory entry is flushed afterwards on a best-effort basis,
since by then the rename has taken effect and reporting a failure would
describe an operation that did happen.

Treat a zero-length `index.json` or `oci-layout` as a missing one and
rewrite it. The store already recovers from a missing index, and an empty
file carries no information and describes the same state. The recovery is
deliberately limited to zero-length files: one that is malformed but not
empty still fails, since discarding it would silently lose the tags of the
store and hide a problem that is not a partial write. Recovering the index
does lose the tags it held, which is documented on New.

The permission that `os.WriteFile` produced is preserved. A file being
created is created with 0666, so the umask applies to it as before, and the
permission of an existing file is restored onto the temporary file before
the rename. Replacing a file rather than writing through it differs from
`os.WriteFile` in ways that are documented on the function.

Signed-off-by: Chris Wedgwood <cw@f00f.org>
`ingest` names its return values so that the deferred cleanup can remove
the ingest file when a write fails, but its error paths return with
`return "", err`, which clears the named return `path` before the deferred
function runs. The cleanup therefore calls `os.Remove("")` and the ingest
file is left behind, so every failed push costs the disk space of the
content written before the failure. Set the named error and return, as
`ioutil.Ingest` has done since oras-project#1185.

Nothing reclaimed those files afterwards either. `GC` sweeps `blobs/` only,
so the residue of interrupted blob and metadata writes accumulates for the
life of the store, which makes the store a contributor to the full file
system that produced the residue in the first place. Remove them in `GC`,
where the other unreachable content of the store is already collected.

Only entries that have not been modified for an hour are reclaimed. A write
removes its own temporary file when it fails, so anything still present is
either the residue of a process that died mid-write or a write that another
`Store` on the same directory is performing right now, which the lock of
this `Store` does not serialize against. The modification time of a write in
progress keeps advancing, so a live write is never eligible while the
residue of a dead one always becomes eligible.

The two directories are otherwise treated differently on purpose. `ingest/`
is created and owned by this package, so any stale entry in it is residue.
The root of the store may hold files that belong to whoever put them there,
which the image layout specification permits, so only the names a temporary
file of this package can actually have are considered there.

Signed-off-by: Chris Wedgwood <cw@f00f.org>
@cwedgwood

Copy link
Copy Markdown
Author

Hi Terry — just checking back when you have a chance. We’ve rebased onto current main. We’re happy to drop the directory fsync and keep the file fsync, put the durability behavior behind an option, or follow another approach you prefer. Thanks again for looking into the performance tradeoff.

@cwedgwood
cwedgwood force-pushed the fix/oci-metadata-atomic-write branch from ea7295b to 8212f10 Compare September 2, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants