imagecache: Re-unpack a layer retired while joining its flight - #1509
Open
igooch wants to merge 1 commit into
Open
imagecache: Re-unpack a layer retired while joining its flight#1509igooch wants to merge 1 commit into
igooch wants to merge 1 commit into
Conversation
Collaborator
|
Can you clean up the PR description? It's very difficult to follow. |
igooch
force-pushed
the
fix/ensurelayer-retire-flight-join
branch
from
September 5, 2026 15:31
63b89d4 to
642f5b9
Compare
ensureLayer and retireLayer share one singleflight key so that the mtime
refresh on reuse and the mtime re-check before a retirement's rename
cannot interleave. The cost of sharing the key is that ensureLayer's Do
can join a flight instead of leading one, and the flight it joins may be
a retirement renaming the layer dir away. A joiner runs no closure, so it
learns nothing from the flight, but it returned the layer path anyway.
The pull went on to record a layer with nothing behind it, and its
re-verify failed the whole image with "layer dir vanished during pull".
The window is microseconds wide, so it surfaced only as a rare failure
under load.
Have both closures return a flightOp through the shared flight, so a
joiner knows what it joined:
- a joined ensure that succeeded settles the layer, and one that failed
settles it too, sharing the leader's error the way a single download
has always been shared by a herd of waiters;
- a joined ensure whose leader was cancelled settles nothing, because
that caller's own lifecycle says nothing about the layer or the
registry, so a joiner with a live context leads a fresh flight;
- a joined retirement settles nothing either, whether it renamed the dir
away, vetoed, or failed, so the call leads its own flight and reuses
or unpacks the layer as the pool dictates.
Re-entry leads a real flight, so the mtime refresh keeps happening inside
the interlock rather than through a stat taken outside it. It is bounded:
singleflight has no fairness, so "retry until we lead" has no termination
guarantee of its own, and maxEnsureLayerFlights derives how many flights
one call can need.
layerFSPresent now backs every layer-presence check. Reading a transient
stat failure as an absent layer sends the caller into an unpack that then
dies at the commit rename with the healthy dir still in place, so such a
failure is now reported instead of passing as a cache miss.
igooch
force-pushed
the
fix/ensurelayer-retire-flight-join
branch
from
September 7, 2026 21:22
133c938 to
9c8c31b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1076
The bug
ensureLayerandretireLayerdeliberately share one singleflight key per layer — that sharing is the reuse/retire interlock — so either can join a flight the other is leading. The interlock was one-sided about joins:retireLayertreats a join as a veto (its closure never ran, so it learned nothing), butensureLayerdrew the opposite conclusion from the same situation.Doreturned the other call'snilerror, andensureLayerreturned the layer path as if verified.When the flight it joined was a retirement, that path had just been renamed aside and nothing unpacked it back.
pullrecorded the layer anyway, and its re-verify failed a healthy pull withlayer dir vanished during pull (evicted?), surfacing as an RPC retry. The window is a few microseconds wide, hence CI-only.Correcting the diagnosis in #1076
The issue blames an eviction cutoff postdating
ensureLayer's mtime refresh. That can't fire here: with the defaultminAge = 2m,cutoff = now - 2mwhile a refreshed dir has mtime ≈now, soretireLayeralways vetoes. That window is real only atminAge ≈ 0.Evidence
Counting joins that leave no
fsdir behind matches the failures exactly (34→34, 149→149, 191→191, 1→1): no failure ever occurred without a join, the rate tracks the flight-window width, and guarding the join drops it to zero while the joins continue.A/B on this branch, same harness, 200 iterations per head start:
The fix
Make the flight say what it did. Every layer-flight closure now returns a
flightOpthrough the singleflight, so a caller that joined knows which operation it joined and treats the two cases differently:Re-entry is bounded by
maxEnsureLayerFlights— singleflight has no fairness, so "retry until we lead" has no bound of its own — with a ctx check between flights. No livelock:retireLayervetoes on the symmetric join.Presence checks now go through
layerFSPresent, which reads onlyErrNotExistas absence. A transient stat error (EIO and friends) is reported instead of being mistaken for a missing layer and triggering a re-download that would fail at the commit rename anyway.Unchanged on purpose
pull's re-verify andTestPullReverifyFailsCleanlyOnYankedLayerstay. The re-verify guards a dir removed outside the flight, which the singleflight can't see. Fixing the cause didn't require weakening the backstop.Testing
Three deterministic tests pin the three join cases. Each holds the layer flight open the way a real leader would and releases it only once the
ensureLayerunder test is observably parked joining it (waitForFlightJoinerscans the goroutine dump — releasing on a timer would let a loaded machine close the flight early and pass the test without ever exercising the join path):TestEnsureLayerJoiningRetireFlightRepacksLayer— joined a retirement that renamed the dir away: unpack it again. Fails onmain, passes here.TestEnsureLayerJoiningFailedFlightKeepsLiveLayer— joined a retirement whose rename failed: the layer is live and usable, so lead a flight and return it rather than failing the pull with an eviction-internal error.TestEnsureLayerJoiningFailedEnsureSharesError— joined a failed ensure: propagate the leader's error rather than piling on download attempts.Full package
-race: 70 pass, 0 fail.TestConcurrentEnsureImageAndEvict: 500 consecutive-raceruns clean.make verifyclean exceptmetrics.sh(needs Docker),proto-fmt.sh(needsclang-format),shellcheck.sh— the latter two fail identically onmain, and this touches no metrics, protos, or shell scripts.Follow-up (separate issue)
TestConcurrentEnsureImageAndEvicttests less than it appears: over 5,000 iterations it produced 33 candidates, skipped all as fresh, and removed zero records — theEnsureImagehit beats the evictor ~99.3% of the time, so the eviction-wins branch is almost never taken.