Skip to content

fix(middleware): don't re-run a coalesced flight in the caller that executed it - #887

Open
ashinpaugh wants to merge 2 commits into
darkweak:masterfrom
ashinpaugh:fix/coalesced-rerun
Open

ashinpaugh wants to merge 2 commits into
darkweak:masterfrom
ashinpaugh:fix/coalesced-rerun

Conversation

@ashinpaugh

Copy link
Copy Markdown

Fixes #886.

singleflight reports shared=true to every caller of a coalesced flight, including the caller that executed it. Upstream() re-runs the handler for shared && sfWriter.disableCoalescing so that waiters don't reuse a private / Set-Cookie result, but the same branch also fired for the executing caller, which then ran next a second time into its own CustomWriter. Since CustomWriter.Header() is the client's header map, one response ended up with every upstream header twice (and sometimes a second body), and the upstream was called N+1 times.

This records whether the current caller executed the flight (set inside the DoChan callback; the channel receive orders that write before the read) and skips the re-run for it. Waiters behave as before.

Test: plugins/caddy/doubled_body_test.go runs Caddy with the cache and an upstream that adds its headers, stamps a unique X-Request-Id, sleeps 80ms and returns one JSON document. 8 concurrent identical requests per case, asserting one Content-Type, one X-Request-Id, a single JSON body and one upstream call per request, for a private response and a public one.

  • With the fix: go test -race -count=1 -run TestConcurrent ./plugins/caddy passes.
  • Without it (condition forced back): fails with want exactly one Content-Type, got ["application/ld+json" "application/ld+json"], the origin ran 2 times into ONE response (X-Request-Id ["exec-1" "exec-8"]), and want the origin called once per request (8), got 9.

Not included: the global disable_coalescing option is not copied into the handler config by FromApp (noted in #886), left for a separate change.

…xecuted it

singleflight reports shared=true to every caller of a flight that had
waiters, including the one that executed it, so a private (or Set-Cookie)
result made the executing caller run the handler a second time into its own
response: duplicated headers, sometimes a second body, N+1 upstream calls.
Only callers that waited now re-run.

Fixes darkweak#886
@netlify

netlify Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for teal-sprinkles-4c7f14 canceled.

Name Link
🔨 Latest commit 23f54e2
🔍 Latest deploy log https://app.netlify.com/projects/teal-sprinkles-4c7f14/deploys/6ae0d49943d32e4d64f3ef9f

Each upstream now runs on its own httptest server, closed when the test ends. The fixed ports were never released, so a repeated run (-count) reached the previous run's upstream and the call counter read 0.
@ashinpaugh

Copy link
Copy Markdown
Author

Verification run against master (7f62bbb), mirroring this repo's CI (golangci-lint run, root go test, plugins/caddy go test) plus -race:

  • TestConcurrent*: 5/5 green with the fix; with the guard reverted, TestConcurrentPrivateResponsesAreNotDoubled fails 5/5 on its assertions (two Content-Type, two upstream request ids, 9 upstream calls for 8 requests).
  • Full plugins/caddy suite: 5/5 green on master and on this branch.
  • go test -race ./pkg/... ./configurationtypes/...: green on both.
  • golangci-lint (v2.15, go1.27): one finding, pkg/middleware/middleware_test.go:156 errcheck, identical on master and this branch.
  • Already on master, unrelated to this change: under -race the full plugins/caddy suite fails on master with data races in the admin API (pkg/api Initialize/HandleRequest vs adminAPI.Provision) surfacing in TestAPIPlatformInvalidation/TestMustRevalidate; they only appear when other tests ran first in the process. TestRange, TestExpires and TestCacheableStatusCode also fail intermittently on master on timing (Age header off by a second).

Follow-up commit: the test's upstreams now run on httptest servers so the test is safe under -count.

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.

Coalesced private response: the executing request runs the handler twice, duplicating headers (and sometimes the body) in one response

1 participant