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
10 times, most recently
from
September 8, 2026 02:08
8c113ee to
db9b3aa
Compare
igooch
force-pushed
the
fix/ensurelayer-retire-flight-join
branch
from
September 9, 2026 17:54
db9b3aa to
bad54ac
Compare
…light key ensureLayer and retireLayer used to share one singleflight key per layer. That sharing was the retire/reuse interlock, but it made the pull path depend on something singleflight cannot express: a caller whose Do joins a flight rather than leading one learns nothing about what it joined. When the flight was a retirement, ensureLayer still returned the layer path, which the rename had just moved aside. The pull recorded a layer with nothing behind it and its re-verify failed a healthy pull with "layer dir vanished during pull". The window is microseconds wide, so it surfaced only as a rare failure under load. Give the two operations a lock instead, and leave the singleflight to do only what it is good at. ensureLayer holds the layer's interlock across its stat and refresh-or-unpack, retireLayer across its stat and rename, so the two still cannot interleave — but a waiter now waits, and then looks at the pool again, rather than inheriting a verdict it cannot interpret. Retirement takes the lock with TryAcquire so a GC pass never blocks behind a download, which is what the old join-is-a-veto rule achieved. The pull path acquires with its context but falls back to TryAcquire when that fails: the semaphore prefers the context error over a free slot, and a cancelled pull leading the flight for a layer already on disk must not fail the lookup for the healthy pulls joined behind it. Only the cache hit is served on that path: the layer streams are bound to the pull's parent context, so an unpack there would run a whole download for a doomed pull. The interlock is per layer, not shared between them. It is held for the length of an unpack, so two layers on one lock would serialize unrelated downloads and let a retirement veto — and so re-date, for a whole min-age window — a layer it could have taken. Nothing bounds concurrent pulls node-wide, so that would not be rare. What remains of the singleflight is deduplication, where joining is unambiguous: the leader's outcome is the joiner's, one download per herd, failure included, as it was before eviction existed. The presence check the three call sites shared is now layerFSPresent, with the semantics it always had: any stat failure counts the layer absent.
igooch
force-pushed
the
fix/ensurelayer-retire-flight-join
branch
from
September 9, 2026 18:08
bad54ac to
9618ddd
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. Overlaps #1079 — both fix the same flake, only one should merge.
The bug
ensureLayerandretireLayershared one singleflight key per layer. That sharing was the retire/reuse interlock, but it made the pull path depend on something singleflight cannot express: a caller whoseDojoins a flight rather than leading one learns nothing about what it joined. When the flight was a retirement,ensureLayerreturned the layer path anyway — and the rename had just moved it aside.pullrecorded a layer with nothing behind it, and its re-verify failed a healthy pull withlayer dir vanished during pull (evicted?).#1076 attributes this to an eviction cutoff postdating
ensureLayer's mtime refresh. That can't fire at the defaultminAge = 2m:cutoff = now - 2mwhile a refreshed dir has mtime ≈now, soretireLayeralways vetoes. That window is real only atminAge ≈ 0.The fix
Use a lock for the interlock, and leave the singleflight to do only what it's good at.
semaphore.Weighted, held byensureLayeracross its stat and refresh-or-unpack, and byretireLayeracross its stat and rename. The two still can't interleave, but a waiter now waits and then re-reads the pool, instead of inheriting a verdict it can't interpret. Retirement usesTryAcquire, so a GC pass never blocks behind a download — same effect as the old join-is-a-veto rule.The interlock is per layer, never shared between them: it is held for the length of an unpack, so two layers on one lock would serialize unrelated downloads and let a retirement veto — and so re-date for a whole min-age window — a layer it could have taken.
semaphore(already a dep, same module assingleflight) rather thansync.Mutexbecause retirement needsTryAcquireand the pull path gets a context-awareAcquire;k8s.io/utils/keymutexhas no try-variant. TheAcquirefalls back toTryAcquireon a context error, serving only the cache hit: the semaphore prefers the context error over a free slot, and a cancelled pull leading the flight for an already-cached layer must not fail the lookup for healthy joiners — but with the layer absent, the context error is returned rather than starting a download the layer streams (bound to the pull's parent context) would never cancel. Entries are created on first use and never removed — the one piece of per-layer bookkeeping eviction does not reclaim — because releasing one safely while a caller waits on it needs refcounting.No behavior change outside the interlock: the layer-presence check keeps the semantics it always had (any stat failure counts the layer absent), now shared by its three call sites as
layerFSPresent.Testing
The flake never reproduced naturally (~49,000 iterations, zero failures). With a throwaway instrumented build that widened the retire window and forced the evictor to win the race, joins-that-found-no-dir matched failures 1:1 in every run. Against this branch the same harness gives 0 failures at every head start, versus 66/182/186 before.
Five committed tests pin the interlock:
The three that race release the held lock only once the call under test is observably blocked, so they cannot pass without exercising the contended path.
Full package
-race: 74 pass, 0 fail.TestConcurrentEnsureImageAndEvict300× race-clean,TestRetireLayerVsEnsureImageRace200× race-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.Deliberately not fixed here
A joiner inherits a cancelled leader's
context.Canceled, so pull B can fail on pull A's caller going away. That predates this PR —ensureLayeronmainalready returns the leader's error unconditionally — and the interlock neither causes nor worsens it. Undoing it means deciding whether shared work should follow any one caller's context at all, which is wider than this change;ensureLayer's doc comment now records the decision.One caveat on the usual justification: I could not find a server-side retry on the Run/Restore path that would absorb it.
EnsureImagefailures reachmaybeCrashActor, are not crash-tagged, and come back as a plain error to the Resume RPC caller — there is no requeue. So the self-healing argument rests on the RPC client, which I have not traced.Follow-up (separate issue)
A pull whose last layer downloads for longer than
minAgewith no sibling completions stops touching its record, so eviction can legitimately remove its completed layers and the pull fails into a retry with the samevanished during pullstring. That is the documented wedge-detection design, not the race this PR fixes; a time-based record heartbeat during long downloads would be its own change.TestConcurrentEnsureImageAndEvicttests less than it appears: over 5,000 iterations it produced 33 eviction candidates and removed zero records, so the eviction-wins branch is almost never taken.