Skip to content

Fix duplicate fetches and 502s on concurrent cache misses - #329

Open
montehurd wants to merge 2 commits into
git-pkgs:mainfrom
montehurd:coalesce-concurrent-cache-misses
Open

Fix duplicate fetches and 502s on concurrent cache misses#329
montehurd wants to merge 2 commits into
git-pkgs:mainfrom
montehurd:coalesce-concurrent-cache-misses

Conversation

@montehurd

Copy link
Copy Markdown

The problem

A cache miss goes from checkCache straight to an upstream fetch with nothing tracking in-flight work, so N concurrent requests for one uncached artifact produce N upstream fetches and N stores to the same storage key. That is the CI shape: parallel jobs installing overlapping dependencies against a cold cache.

The duplicate stores also fail requests, racing fileblob's per-key .attrs sidecar into a partial read served as a 502.

Over 12 runs of 8 simultaneous requests for one uncached tarball, against bb2205a:

before after
upstream fetches per run 8 1
runs with a non-200 5 of 12 0 of 12
non-200 responses 12 of 96 0 of 96

The fix

Both miss paths route through a shared in-flight map keyed on the artifact. The key includes the download URL and upstream-declared hash, so callers expecting different bytes never share a fetch.

x/sync/singleflight would be the obvious tool and is already used for ECR tokens, but neither mode fits: Do gives waiters no way to leave, while DoChan lets the caller running the fetch abandon it on its own cancellation, which breaks the scan-on-disconnect contract in storeArtifact. Deciding the roles under a mutex makes both behaviours available.

Two commits. The first returns the stored artifact from storeArtifact instead of an open reader, a mechanical refactor with no behaviour change. The second adds the coalescing. The reproduction tests fail at both main and the refactor commit, so a bisect lands on the right one.

Scope

  • Artifacts only. cacheMetadataBlob, storeContainerMetadata and the Gradle build cache PUT reach storage without coming through here.
  • Coalescing is in-process and does nothing across replicas.
  • This removes the sidecar trigger on this path rather than fixing the race itself, which is fileblob's and is addressed separately in Stop writing fileblob's .attrs sidecar #328.

Green on ubuntu, macOS and Windows.

storeArtifact returned a CacheResult holding an open file handle. A
handle has one read position, so it can only ever serve a single caller,
which is what blocks sharing one fetch between concurrent requests.

Return the artifact and its storage path instead, and let each caller
open its own reader through openStoredArtifact. Threading that type
through fetchAndCache, fetchAndCacheFromURL and their error paths is
mechanical; behaviour is unchanged.
A cache miss went from checkCache straight to an upstream fetch with
nothing tracking in-flight work, so N concurrent requests for one
uncached artifact produced N upstream fetches and N stores to the same
key. That is the CI shape: parallel jobs installing overlapping
dependencies against a cold cache. The duplicate stores also fail
requests, racing fileblob's per-key ".attrs" sidecar into a partial read
served as a 502. Over 12 runs of 8 simultaneous requests for one
uncached tarball, against bb2205a: before, 8 fetches per run and 12 of
96 responses were 502; after, 1 fetch per run and none failed.

Route both miss paths through a shared in-flight map keyed on the
artifact, including the download URL and upstream-declared hash so
callers expecting different bytes never share a fetch.

singleflight does not fit: Do gives waiters no way to leave, while
DoChan lets the caller running the fetch abandon it, breaking
storeArtifact's scan-on-disconnect contract. Deciding roles under a
mutex gives both behaviours. The fetch runs on the first caller's
context and is seen through; waiters leave when their own clients do.

This removes the sidecar trigger on this path. The race is in fileblob
and three writers bypass this path entirely, so it is fixed separately.

Fewer failures now reach the circuit breaker, so it trips later.

Sixteen concurrent callers against real file:// storage fail 10 of 10
runs on main and pass 10 of 10 here. Other tests pin key discrimination,
failure propagation, resolver-path coalescing, per-caller readers,
waiter cancellation, key release and panic safety. allocs/op is
unchanged. mockStorage gains a mutex so concurrent tests can use it.
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.

1 participant