Skip to content

fetch: cancelled coalescing waiters no longer retain goroutines (Fixes #53) - #60

Merged
Coldwings merged 1 commit into
mainfrom
fix/issue-53-coalescing-waiters
Aug 26, 2026
Merged

fetch: cancelled coalescing waiters no longer retain goroutines (Fixes #53)#60
Coldwings merged 1 commit into
mainfrom
fix/issue-53-coalescing-waiters

Conversation

@Coldwings

Copy link
Copy Markdown
Collaborator

fetch: cancelled coalescing waiters no longer retain goroutines

Fixes #53

What

Coalescing.Fetch started one goroutine per caller, and that goroutine waited
inside the singleflight group even after the caller's context was cancelled.
Cancelled callers returned promptly, but their goroutines stayed blocked until
the shared flight finished or hit the 10-minute MaxFlight bound — a
cancellation storm on one stalled key retained one goroutine per abandoned
caller.

The singleflight group.Do is split into acquire/finish: whoever leads a
flight starts its single worker goroutine; every joiner waits inline on
the flight's done channel with its own ctx. A caller that stops waiting now
retains no goroutine at all; a flight costs exactly one worker no matter how
many callers join or abandon it.

Triage evidence

Verified real against the reviewed commit 84aac4e:

  • git show 84aac4e:internal/fetch/fetch.goFetch allocates a result
    channel and spawns one goroutine per caller; that goroutine blocks in the
    non-cancellable group.Do wait while the outer select returns on
    ctx.Done(). The mechanism exists exactly as the issue describes.
  • Minimal repro (.work/verify/issue53/repro_test.go): stalled flight
    (MaxFlight: time.Minute), 64 callers with 50 ms contexts. All 64 callers
    returned on deadline while 64 goroutines remained blocked until the
    flight was released — matching the issue's claim precisely.
  • Classification: implementation (bounded resource retention /
    availability). Not a deliberate simplification — the design docs never claim
    per-waiter goroutine retention as a stance, and the fix is cheap and provably
    correct.

Fixes

  • group.Dogroup.acquire + group.finish. acquire joins a live flight
    or creates/leads a replacement; finish publishes the result (writes
    happen-before the done close) and deletes only its own map entry, so a
    late-finishing stale leader still never evicts a replacement — the
    TestLateStaleLeaderKeepsReplacement invariant is unchanged.
  • The flight worker's context deadline is exactly the flight's end, and a
    flight becomes evictable only after end + evictGrace(maxFlight) (clamped to
    [25 ms, 30 s]). This ordering guarantee matters: without it, the waiter's
    deadline timer fires epsilon before the ctx-respecting worker delivers, and a
    still-waiting leader would evict its own flight and livelock (caught in
    self-review: TestFlightContextBoundsStalledOrigin spun 22 s under -cover
    with the naive version). The stale path now only ever fires on a worker that
    ignores its context.
  • Deliberate contract change, documented in docs/fetch.md: a flight everyone
    abandoned is not respawned at its deadline (nobody is left wanting the
    bytes, so firing a fresh origin fetch would be pure waste); the stale entry
    is evicted lazily by the next real caller. This replaces the
    TestAbandonedWaitersReleasedAtDeadline behavior pinned during [Design Gap] fetch: unbounded background flight — one stalled origin poisons the cache key permanently (+ goroutine leak) #4.

Regression tests

  • TestCancelledWaitersRetainNoGoroutines: 64 callers with 20 ms contexts on a
    stalled 1-minute flight. After all callers return, at most 4 goroutines may
    remain (the flight's single worker plus slack), and exactly 1 origin call
    must have occurred. Verified to fail on the old code ("goroutines
    retained after all 64 callers returned = 64, want <= 4").
  • TestStaleFlightReplacedOnlyByALiveCaller: after the sole caller times out,
    no replacement flight is led past the deadline (calls == 1); the next real
    caller evicts the stale entry and leads the replacement (calls == 2).
  • All pre-existing coalescing tests unchanged and green, including
    TestStaleFlightEvictedAfterMaxFlight, TestLateStaleLeaderKeepsReplacement,
    TestJoinerRetrySkippedWhenCallerGone, TestCoalescedResultIsMarked.

Verification

All run with the repo toolchain (go1.27.0, GOTOOLCHAIN=local, workspace-local
caches):

  • go vet ./... — clean
  • go test ./... -race -count=1 — all 13 packages ok
  • go test ./... -cover -count=1 — all ok; internal/fetch 91.7%
  • go test ./internal/fetch/ -race -count=20 -run 'Flight|Stale|Cancelled|Joiner|Coalescing'
    — 20 consecutive green rounds on the timing-sensitive tests
  • gofmt -l internal/ docs/ — clean

Docs: docs/fetch.md §3.5 rewritten for the one-worker-per-flight contract and
lazy eviction; the test inventory table replaces
TestAbandonedWaitersReleasedAtDeadline with the two new pins.

Every Coalescing.Fetch started a per-caller goroutine that waited inside
the singleflight group; a caller whose context was cancelled returned
while its goroutine stayed blocked until the flight finished or hit
MaxFlight. A cancellation storm on one stalled key retained one goroutine
per abandoned caller for the whole flight lifetime (issue #53).

Split group.Do into acquire/finish: the leader starts the flight's single
worker goroutine, and joiners wait inline on the flight's done channel
with their own ctx. A caller that stops waiting now retains nothing; a
flight costs one worker no matter how many callers come and go.

Two contract corollaries, both documented:

- The flight worker's context deadline is exactly the flight's end, and
  eviction happens a grace margin later (evictGrace), so a ctx-respecting
  worker's deadline delivery always wins over eviction; the stale path
  only fires on a worker that ignores its context.
- A flight everyone abandoned is no longer respawned at its deadline
  (nobody is left wanting the bytes); the stale entry is evicted lazily
  by the next real caller.

Regression tests: TestCancelledWaitersRetainNoGoroutines fails on the old
code (64 goroutines retained after 64 cancelled callers) and passes now;
TestStaleFlightReplacedOnlyByALiveCaller pins the lazy-eviction contract.

Fixes #53
@Coldwings

Copy link
Copy Markdown
Collaborator Author

@copilot please review this PR. Focus points: (1) the new singleflight split (group.acquire/group.finish in internal/fetch/fetch.go) — happens-before via done close, stale-entry eviction, and that a late stale leader never deletes a replacement's entry; (2) the eviction-grace ordering: flight ctx deadline == flight.end, evictable at end+evictGrace, guaranteeing a ctx-respecting worker's delivery always wins over eviction (a naive version livelocked here); (3) the deliberate contract change: fully-abandoned flights are no longer respawned at the deadline, eviction is lazy on the next caller; (4) the two new regression tests really pin the new behavior. Please do NOT push commits to this branch; leave review comments only.

Copilot AI commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@copilot please review this PR. Focus points: (1) the new singleflight split (group.acquire/group.finish in internal/fetch/fetch.go) — happens-before via done close, stale-entry eviction, and th...

Reviewed. I don’t see a defect requiring code changes.

  • group.acquire/group.finish is correct on ordering and ownership: result writes happen-before done close, and finish only deletes when g.m[key] == c, so a late stale leader cannot delete a replacement flight.
  • Eviction ordering is correct: worker context deadline is flight.end, waiters only evict at flight.stale = end + evictGrace(maxFlight), so a ctx-respecting worker’s deadline completion wins over eviction.
  • The contract change is implemented as described: fully abandoned flights are not respawned at deadline; stale eviction is lazy on the next caller.
  • The new regression pins are meaningful and passing (TestCancelledWaitersRetainNoGoroutines, TestStaleFlightReplacedOnlyByALiveCaller), and related timing-sensitive tests also pass under -race.

@Coldwings
Coldwings merged commit e787d5c into main Aug 26, 2026
8 checks passed
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.

[Design Gap] fetch: cancelled coalescing waiters retain goroutines until the flight bound

2 participants