Commit c416c52
authored
[TT-17921] Fix cross-request state leaks in subscriptions and the v2 execution engine (#449)
## Summary
Five fixes for state that was shared between unrelated GraphQL requests:
cached plans, pooled
execution contexts, upstream websocket connections and resolver
triggers. In each case one caller's
headers, credentials or subscription data could end up serving another
caller. Every fix is applied
to both the `pkg/` (v1) and `v2/pkg/` trees and landed as its own
commit.
The common shape: things that are per-request — upstream headers,
connection_init payloads, request
contexts, the operation itself — were written into, keyed on, or handed
to structures that outlive
the request (the plan cache, a pooled `resolve.Context`, a client-wide
field, a hash that doesn't
identify what it groups).
## What changed
### 1. move header modification to the execution phase
Upstream headers and header modifiers were applied by registering
post-processors on the plan
post-processor. `getCachedPlan` stores the post-processed plan in
`executionPlanCache`, so the first
request's headers were baked into the plan that every later request with
the same operation reuses.
`WithUpstreamHeaders` / `WithHeaderModifier` now write to the
per-request `resolve.Context`
(`UpstreamHeaders`, `HeaderModifier`) and are applied when the fetch
input is built.
### 2. isolate request specific execution state
Header work was split across `ApplyHeaderModifier` / `MergeInputHeader`,
applied late in the load
path and silently returning the input unchanged on any error; the pooled
`resolve.Context` also kept
`UpstreamHeaders` / `HeaderModifier` alive past `Free()`.
Consolidated into `httpclient.FinalizeInputHeaders(input, modifier,
upstreamHeaders)`, which reports
errors instead of no-op'ing, applied per fetch in `Loader.finalizeInput`
(single / entity / batch)
and in `Resolver.subscriptionInput`. `Context.Free()` clears the
request-scoped fields and `clone()`
deep-copies them.
### 3. preserve the subscription request context
`websocket.HandleWithOptions` called
`subscriptionHandler.Handle(context.Background())`, so a
websocket subscription's upstream calls were never tied to the incoming
request: no cancellation, no
deadline or trace propagation.
Adds `HandleOptions.Context` and `WithContext(ctx)` (nil-safe, defaults
to `context.Background()`).
The context reaches the outbound upstream HTTP request, which lets a
caller such as the gateway's
websocket handoff forward the incoming request's context.
### 4. context ownership and orphaned goroutines in SSE
Two defects:
- `gqlSSEConnectionHandler.StartBlocking` never returned when the
subscribe goroutine finished
without reporting an error — upstream `event: complete`, end of stream,
or a failed subscription
request. The goroutine leaked and, because teardown happens in its
deferred func, the client's
subscription was never completed and stayed open until the client
disconnected. A `done` channel
now ends the loop.
- `AsyncResolveGraphQLSubscription` handed the caller's pooled
`*resolve.Context` to the resolver's
event loop and returned immediately; the caller then freed it for the
next request, so the trigger
could be started with a freed or already-reused context. It now
snapshots the context
synchronously. `clone()` also copies `InitialPayload` / `Extensions`,
and `Free()` clears
`InitialPayload`.
### 5. make subscription grouping collision safe
Three groupings that merged unrelated work:
- **Upstream connection reuse** was keyed on `hash(URL, headers)`,
computed *before* the
`connection_init` message exists. That payload comes from
`OnWsConnectionInitCallback(reqCtx, …)`,
the hook used to forward the calling user's credentials, so two users
with identical upstream
headers were multiplexed onto the connection authenticated as the first
one. The key is now an
exact descriptor that includes the init message and, in v2, the resolved
forwarded client headers.
- **The negotiated sub-protocol** was written back onto the shared
client
(`c.wsSubProtocol = conn.Subprotocol()`), making the first upstream's
choice a client-wide default
that also narrowed the protocols offered on every later dial. It is a
local now.
- **Resolver triggers** were grouped purely by `triggerID`, which for
the graphql datasource hashes
the connection descriptor (URL, headers, forwarded headers, extensions)
and never the operation. A
second subscription with a different query was attached to the first
trigger, never started
upstream, and was fed the first subscription's messages. Triggers now
carry their `input` and
`source` and probe for an exact match before joining.
### 6. fix: compute the schema hash once at construction
Schema.Hash() filled in s.hash on first use. A schema is shared by every
request of an API, so
two concurrent requests validating against it raced on that write — both
callers,
Request.ValidateForSchema and Request.IsValidated, are on the request
path. The hash is now
computed while the schema is built (which is what the v2 module already
did) and Hash() is a pure
read; Normalize() carries the hash of the normalized document across, in
both trees.
### 7. fix: stop mutating the cached plan at resolve time
ResolveGraphQLResponse filled in response.Info on first use. A
GraphQLResponse is the cached
execution plan, shared by every concurrent request resolving that
operation, so the write raced with
the read two lines below and with Loader.LoadGraphQLResponseData. The
planner only populates
Info when Config.IncludeInfo is set, which nothing in the engine wrapper
or the gateway does, so
this fired for live traffic on every operation. Resolving now reads
through a shared immutable
default instead of writing.
The same pattern applied to request tracing: the loader wrote each
fetch's DataSourceLoadTrace
onto the plan's fetch structs, racing between concurrent traced
requests. Traces now live in a
per-request table on Resolvable; GetTrace's exported signature is
unchanged and the Trace
fields on the fetch structs are left in place but deprecated.
Behaviour note: with Info no longer filled in, a subscription update's
response falls back to
the shared default (OperationTypeQuery) where the loader previously saw
nil. Subgraph error
paths reported during subscription updates therefore gain a query prefix
(… at path 'query.field'). The query and mutation paths are unchanged.
### 8. `perf: cut the per-fetch cost of finalizing headers` (from
review)
Fix 2 moved header work out of the plan and onto the fetch path, so it
stopped being computed once
per cached plan and started being computed once per fetch. Same round
trip as before —
`ProcessModifyHeader` already did `jsonparser.Get` → `json.Unmarshal` →
modifier → `json.Marshal` →
`jsonparser.Set` — but no longer amortised, and the gateway installs a
header modifier on every
GraphQL request, so the early return in `FinalizeInputHeaders` never
fires in production.
Profiling the call split showed where it actually goes, per fetch with a
five-header input:
| stage | ns/op | allocs/op |
| --- | --- | --- |
| read the header object | 2877 | 54 |
| modifier callback (caller's code) | 400 | 6 |
| `json.Marshal` | 595 | 14 |
| `sjson.SetRawBytes` | 378 | 2 |
The read side is two thirds of it, and all of that is `encoding/json`
building an intermediate
`map[string]json.RawMessage` and a `[]string` per entry, only to throw
them away. It now walks the
object once with `jsonparser.ObjectEach`/`ArrayEach`. The write side
hand-rolls the object instead
of reflecting over `http.Header`, into a pooled buffer.
`sjson.SetRawBytes` stays a single call — it
copies the whole input document, so setting keys individually would have
cost one copy per header.
**4402 → 1947 ns/op, 77 → 27 allocs/op, 4163 → 1825 B/op** (Apple M4
Max, both trees). The
`nothing to do` early return stays allocation-free.
`http.Header` still has to be materialised — `HeaderModifier` is
`func(http.Header)` and the
gateway's implementation rewrites every entry — so the map itself is the
remaining floor.
## Tests
47 new test functions (28 distinct names, most present in both trees),
~1.5k lines. Each fix's tests
were written first and observed failing against the unpatched code.
Highlights:
| Area | Tests |
| --- | --- |
| Plan cache / header isolation |
`TestExecutionEngineV2_Execute_PlanCacheHeaderIsRequestScoped`,
`..._UpstreamHeadersAppliedPerRequest`,
`..._HeaderModifierAppliedPerRequest` |
| Header finalization | `TestFinalizeInputHeaders`,
`TestApplyHeaderModifier*`, `TestMergeInputHeader`,
`TestContextFreeClearsRequestHeaderOptions`,
`TestContextCloneIsolatesRequestHeaderOptions` |
| Subscription request context | `TestWithContext`,
`TestHandleWithOptions_ContextValuePropagatesToUpstreamRequest`,
`TestHandleWithOptions_CanceledContextAbortsOperation` |
| SSE lifetime & context ownership |
`TestSSEConnectionHandlerStartBlockingReturnsWhenUpstreamStops`,
`TestAsyncResolveGraphQLSubscriptionOwnsCallerContext`,
`...KeepsRequestContextAfterCallerFree`,
`TestContextCloneCopiesInitialPayloadAndExtensions`,
`TestContextFreeClearsInitialPayload` |
| Connection & trigger grouping |
`TestSubscriptionClientConnectionInitIsolation`,
`TestSubscriptionClientDoesNotPinNegotiatedSubProtocol`,
`TestResolver_SubscriptionsAreNotMergedByTriggerIDAlone`, plus direct
unit tests for the new helpers: `TestConnectionKey`,
`TestSameSubscriptionSource` |
Existing contracts were deliberately kept green:
`TestWebsocketSubscriptionClientDeDuplication` (v1)
and `TestWebsocketConnectionReuse` (v2) still yield one handler for
equivalent descriptors and two
when forwarded headers differ, and
`TestResolver_SubscriptionsAreNotMergedByTriggerIDAlone/identical_subscriptions_still_share_one_trigger`
guards that multiplexing survives the trigger split.
## Notes for reviewers
- Trigger reuse now additionally requires the same `Trigger.Source`
pointer. Identical operations
share a cached plan and therefore still multiplex; a plan-cache miss
would open a second upstream
connection instead of sharing one — more connections, never wrong data.
- Connection keys are exact strings instead of 64-bit hashes, so they
can only ever split connections
the hash used to merge. Map keys are larger.
- `FinalizeInputHeaders` returns an error where the previous helpers
silently returned the input
unchanged.
- No public API removed. `websocket.HandleOptions` gains `Context`, and
`WithContext` is additive.
## Related
Follows #446 (TT-17442, upstream websocket connection reuse ignoring
per-request auth headers) and
#447 (TT-17578, websocket request context leaking into engine fetches),
which fixed adjacent cases
in the same area.
Thanks to @chrisanderton and @LLe27 for initial research and patches for
this issue(s)1 parent 1330dc1 commit c416c52
46 files changed
Lines changed: 4545 additions & 278 deletions
File tree
- pkg
- engine
- datasource
- graphql_datasource
- httpclient
- resolve
- graphql
- subscription/websocket
- v2/pkg
- engine
- datasource
- graphql_datasource
- httpclient
- resolve
- graphql
- subscription/websocket
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| 46 | + | |
46 | 47 | | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
52 | | - | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
53 | 57 | | |
54 | 58 | | |
55 | 59 | | |
| |||
58 | 62 | | |
59 | 63 | | |
60 | 64 | | |
| 65 | + | |
| 66 | + | |
61 | 67 | | |
62 | 68 | | |
63 | 69 | | |
| |||
Lines changed: 119 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
499 | 499 | | |
500 | 500 | | |
501 | 501 | | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
Lines changed: 32 additions & 49 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
9 | 10 | | |
10 | 11 | | |
11 | 12 | | |
12 | | - | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
| 23 | + | |
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
29 | | - | |
30 | | - | |
| 29 | + | |
31 | 30 | | |
32 | 31 | | |
33 | 32 | | |
| |||
89 | 88 | | |
90 | 89 | | |
91 | 90 | | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
97 | | - | |
98 | | - | |
99 | | - | |
100 | | - | |
101 | | - | |
102 | | - | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
103 | 97 | | |
104 | 98 | | |
105 | 99 | | |
| |||
158 | 152 | | |
159 | 153 | | |
160 | 154 | | |
161 | | - | |
162 | | - | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
163 | 160 | | |
164 | 161 | | |
165 | 162 | | |
| |||
175 | 172 | | |
176 | 173 | | |
177 | 174 | | |
178 | | - | |
| 175 | + | |
179 | 176 | | |
180 | 177 | | |
181 | 178 | | |
182 | 179 | | |
183 | 180 | | |
184 | 181 | | |
185 | | - | |
| 182 | + | |
186 | 183 | | |
187 | 184 | | |
188 | 185 | | |
| |||
192 | 189 | | |
193 | 190 | | |
194 | 191 | | |
195 | | - | |
196 | | - | |
197 | | - | |
198 | | - | |
199 | | - | |
200 | | - | |
201 | | - | |
202 | | - | |
203 | | - | |
204 | | - | |
205 | | - | |
206 | | - | |
207 | | - | |
208 | | - | |
209 | | - | |
210 | | - | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
211 | 198 | | |
212 | | - | |
213 | | - | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
214 | 202 | | |
215 | 203 | | |
216 | | - | |
| 204 | + | |
217 | 205 | | |
218 | 206 | | |
219 | 207 | | |
| |||
235 | 223 | | |
236 | 224 | | |
237 | 225 | | |
238 | | - | |
239 | | - | |
240 | | - | |
241 | | - | |
242 | | - | |
243 | 226 | | |
244 | 227 | | |
245 | 228 | | |
246 | 229 | | |
247 | 230 | | |
248 | 231 | | |
249 | | - | |
250 | | - | |
251 | | - | |
252 | | - | |
253 | 232 | | |
254 | 233 | | |
255 | 234 | | |
256 | 235 | | |
257 | | - | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
258 | 241 | | |
259 | 242 | | |
260 | 243 | | |
261 | 244 | | |
262 | 245 | | |
263 | | - | |
| 246 | + | |
264 | 247 | | |
265 | 248 | | |
266 | 249 | | |
| |||
0 commit comments