feat(remote): add single-flight Fetch coalescing (S13 Step 8, part 1/2)
Split into 2 stacked MRs to keep each part within a reviewable size (~600 LoC ideal; both parts land just under the 1000 hard cap): each part targets the previous one (part 1 targets main), and the stack is reviewed and merged bottom-up.
Part 1 of 2 of S13 virtual and remote foundation — Step 8: single-flight coalescing.
📦 What this part delivers
The in-process single-flight from the spec's Single-flight on Fetch section, wrapping the Step 7 Fetch cache-fill pipeline:
SingleFlight(internal/remote/singleflight.go) — per-repository coalescing keyed by path (theremote_repository_idhalf of the spec key is structural: one long-lived instance per remote repository, shared by every flow that fetches from it). The first caller per path leads the upstream GET and cache fill; concurrent callers follow.flight+ body wrappers (internal/remote/flight.go) — the flight state machine: raw-fetch followers joining before the leader's first byte tee live from the leader's read-driven fan-out (plainio.Pipe, constant memory); later joiners and every follower of a transformed fetch wait for the commit and read the committed blob back (force-streamed). Leader failure — including an abandoned fill — propagates to both follower models immediately.max_concurrent_fills_per_repocaps leaders only, acquired after the collapse decision so N coalesced callers hold one slot; at the cap a new fill blocks, bounded by the caller's context andsingle_flight_wait_timeout, surfaced as the newremote.ErrSingleFlightTimeoutsentinel.- Committed-read serving reuses the leader's Status/ETag/safe headers with a fresh body from
BlobStore.OpenBlob(..., WithForceStream()); read-back failures are classified underremote.ErrCacheFill(the upstream response was usable — the failure is cache-side).
This part carries the two anchor tests: the step's acceptance criterion (N concurrent identical Fetches produce exactly one upstream GET and one cache write, and all N receive the streamed body) and distinct-path fill concurrency. The remaining follower-model and orchestration coverage is part 2.
🔍 Notes for reviewers
x/sync/singleflight.Group(the npm inline-build precedent) hands every waiter one shared value; followers here each need their own byte stream, so the registry is a hand-rolled keyed map overflight.- The flight is keyed by path, so the leader's
FetchOptionswin for the whole flight; a follower with a differentIfNoneMatchcan receive an outcome its own validator would not have earned. Documented onSingleFlight.Fetch. - The coalescing boundary is a usage contract: whichever step first wires
SingleFlightinto a composed flow (Step 11/12) must add the end-to-end test proving N concurrent requests through the real flow issue one upstream GET. - Beyond the plan's Files list for this step, the change touches
errors.go(the new sentinel + anErrCacheFilldoc extension), splits the production code intosingleflight.go+flight.gofor readability, and extends thefetch_test.gofakeBlobStorewith an additiveOpenBlob(panic default preserved). - Cross-instance coordination for cold multi-GB pulls stays out of scope (work item
#375); this layer is in-process by design.
📖 References
🗺️ E2E scenarios
No scenario catalog update: S13 is a format-agnostic library layer with no HTTP endpoints; client-visible e2e scenarios land with the format slices (S14–S16) that compose it.
Related to #329 (closed)