perf(graphql): reuse filtered variables across resolve spans#9225
Conversation
Overall package sizeSelf size: 6.63 MB Dependency sizes| name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.3.1 | 122.62 kB | 437.94 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |🤖 This report was automatically generated by heaviest-objects-in-the-universe |
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: c6dbf6a | Docs | Datadog PR Page | Give us feedback! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26eec4e180
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
BenchmarksBenchmark execution time: 2026-07-07 20:50:13 Comparing candidate commit c6dbf6a in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 2248 metrics, 38 unstable metrics.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9225 +/- ##
==========================================
+ Coverage 93.56% 96.53% +2.97%
==========================================
Files 900 911 +11
Lines 52690 120348 +67658
Branches 12411 20719 +8308
==========================================
+ Hits 49300 116183 +66883
- Misses 3390 4165 +775 Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
4a06136 to
b225685
Compare
The graphql.resolve variable tags ran the user-supplied config.variables
filter once for every instrumented field that carries arguments, even though
graphql coerces variableValues once per execute and hands the same object to
every resolver. Memoize the filtered result against the last-seen
variableValues object so the filter runs once per operation.
The cache is a single { key, value } slot compared by identity, not a WeakMap.
graphql hands every resolver in one execute the same variableValues object, so
the common case is a bare `===` hit with no per-operation allocation. A nested
execute() sharing the same object contextValue reuses the outer rootCtx but
carries its own variableValues; the identity compare recomputes for it and the
later inner fields reuse the slot, so each field's tags stay correct. A
regression test covers that nested case.
Microbenchmark (Node v26.3.1, V8 14.6, n=2M x 7 trials, drop best+worst),
per-operation cost of rootCtx setup + N arg-bearing field filters:
1 field, 1 object: baseline 53 ns weakmap 73 ns (+37%) slot 54 ns (+2%)
3 fields, 1 object: baseline 163 ns weakmap 82 ns (-49%) slot 56 ns (-65%)
10 fields,1 object: baseline 519 ns weakmap 113 ns(-78%) slot 61 ns (-88%)
3 fields, 2 objects: baseline 160 ns weakmap 147 ns(-8%) slot 114 ns(-29%)
10 fields,2 objects: baseline 506 ns weakmap 177 ns(-65%) slot 124 ns(-75%)
A WeakMap regresses the single-field common case (its allocation costs more
than one filter call); the identity slot wins in every scenario.
b225685 to
febfaad
Compare
…fields A single operation selecting two argument-bearing fields is the only shape that reaches the cached return in #filterVariables; every prior variables test picks one field and only ever hits the miss path. Assert both sibling resolve spans draw their own tags from the one shared filtered object and that the filter runs once for the resolve path rather than per field.
* perf(graphql): reuse filtered variables across resolve spans
The graphql.resolve variable tags ran the user-supplied config.variables
filter once for every instrumented field that carries arguments, even though
graphql coerces variableValues once per execute and hands the same object to
every resolver. Memoize the filtered result against the last-seen
variableValues object so the filter runs once per operation.
The cache is a single { key, value } slot compared by identity, not a WeakMap.
graphql hands every resolver in one execute the same variableValues object, so
the common case is a bare `===` hit with no per-operation allocation. A nested
execute() sharing the same object contextValue reuses the outer rootCtx but
carries its own variableValues; the identity compare recomputes for it and the
later inner fields reuse the slot, so each field's tags stay correct. A
regression test covers that nested case.
Microbenchmark (Node v26.3.1, V8 14.6, n=2M x 7 trials, drop best+worst),
per-operation cost of rootCtx setup + N arg-bearing field filters:
1 field, 1 object: baseline 53 ns weakmap 73 ns (+37%) slot 54 ns (+2%)
3 fields, 1 object: baseline 163 ns weakmap 82 ns (-49%) slot 56 ns (-65%)
10 fields,1 object: baseline 519 ns weakmap 113 ns(-78%) slot 61 ns (-88%)
3 fields, 2 objects: baseline 160 ns weakmap 147 ns(-8%) slot 114 ns(-29%)
10 fields,2 objects: baseline 506 ns weakmap 177 ns(-65%) slot 124 ns(-75%)
A WeakMap regresses the single-field common case (its allocation costs more
than one filter call); the identity slot wins in every scenario.
* test(graphql): exercise the memoized variables filter across sibling fields
A single operation selecting two argument-bearing fields is the only shape that
reaches the cached return in #filterVariables; every prior variables test picks
one field and only ever hits the miss path. Assert both sibling resolve spans
draw their own tags from the one shared filtered object and that the filter runs
once for the resolve path rather than per field.
* perf(graphql): reuse filtered variables across resolve spans
The graphql.resolve variable tags ran the user-supplied config.variables
filter once for every instrumented field that carries arguments, even though
graphql coerces variableValues once per execute and hands the same object to
every resolver. Memoize the filtered result against the last-seen
variableValues object so the filter runs once per operation.
The cache is a single { key, value } slot compared by identity, not a WeakMap.
graphql hands every resolver in one execute the same variableValues object, so
the common case is a bare `===` hit with no per-operation allocation. A nested
execute() sharing the same object contextValue reuses the outer rootCtx but
carries its own variableValues; the identity compare recomputes for it and the
later inner fields reuse the slot, so each field's tags stay correct. A
regression test covers that nested case.
Microbenchmark (Node v26.3.1, V8 14.6, n=2M x 7 trials, drop best+worst),
per-operation cost of rootCtx setup + N arg-bearing field filters:
1 field, 1 object: baseline 53 ns weakmap 73 ns (+37%) slot 54 ns (+2%)
3 fields, 1 object: baseline 163 ns weakmap 82 ns (-49%) slot 56 ns (-65%)
10 fields,1 object: baseline 519 ns weakmap 113 ns(-78%) slot 61 ns (-88%)
3 fields, 2 objects: baseline 160 ns weakmap 147 ns(-8%) slot 114 ns(-29%)
10 fields,2 objects: baseline 506 ns weakmap 177 ns(-65%) slot 124 ns(-75%)
A WeakMap regresses the single-field common case (its allocation costs more
than one filter call); the identity slot wins in every scenario.
* test(graphql): exercise the memoized variables filter across sibling fields
A single operation selecting two argument-bearing fields is the only shape that
reaches the cached return in #filterVariables; every prior variables test picks
one field and only ever hits the miss path. Assert both sibling resolve spans
draw their own tags from the one shared filtered object and that the filter runs
once for the resolve path rather than per field.
Summary
The
graphql.resolvevariable tags run the user-suppliedconfig.variablesfilter once for every instrumented field that carries arguments, even though graphql coercesvariableValuesonce per execute and hands the same object to every resolver. Caching the filtered result on the execute context runs the filter once per operation instead.Why
The reduction (once-per-argument-bearing-field to once-per-execute) is structural and safe by inspection: the same filter runs against the same coerced
variableValuesobject, so the produced tags are identical. Draft until I run a microbenchmark on a representative deep query to confirm the win is worth keeping.