Repository navigation
Fixed validation, planning and execution time growing quadratically and exponentially - #640
Open
xperiandri wants to merge 5 commits into
Open
xperiandri wants to merge 5 commits into
xperiandri wants to merge 5 commits into
Conversation
…ially
Planning a field under an interface or a union planned its selection set again for every possible type of every abstract field above it, so the work grew exponentially with the nesting: `{ node { child { … } } }` nested 30 deep under an interface with three implementations never finished. Several validation rules were quadratic or exponential in the size of the document as well.
Planning:
- The plan of a field's selection set is reused for the same field, return type and includer
- The includer of a selection is built once for its directives and the includer of its parent, so that covariant implementations reuse the plans under them too
- Fragments are looked up by name, and the fields of a selection set are collected and merged through an index by identifier
Validation:
- The field selection merging rule compares each pair of fields once, as graphql-js does, and stops after the error limit
- The unused variable rule collects the variables of an operation in one iterative pass
- The operation name uniqueness rule counts operations in one pass and ignores fragments
- The fragment spread target rule and the incremental delivery rules look fragments up by name
- The subscription root field rule follows only the fragments that validation inlines
- `SelectionInfoContext.FragmentDefinitions` is replaced by `Fragments`, by name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ution and validation The review of the previous commit found paths it left exponential or quadratic, two behaviour changes it should not have made, and tests that did not guard their fixes. Planning and execution: - The plans of a field selected more than once are merged all at once and remembered by the plans merged. Merging two copies of a selection nested under an interface walked the shared plans once for every possible type at every level, and merging a field selected thousands of times copied its growing selection set every time - Executing an object inlines the fragments a variable disables through the same merge, and collects its fields linearly - A reused plan gets its deferred fragment entries copied from the field reusing it, so it is the same as planning again. QueryWeightMiddleware counted the field of another implementation before - QueryWeightMiddleware and ObjectListFilterMiddleware measure each shared plan once instead of once for every path to it, with the same results. ObjectListFilterMiddleware no longer drops the filters of the list fields before the last one Validation: - The input value rule checks a value once per document and only the variables used in it once per operation, looks the variables up by name, and stops after the error limit - The unused variable rule finds the variables a fragment uses once per document - The field merging rule compares unordered pairs, so it reports a conflict once, and stops after the error limit validation was asked for - The subscription root field rule counts the fields of fragments that form a cycle again, and walks the spreads with a stack Tests: - Each fix has a regression test, and the correctness tests were checked to fail with the previous behaviour - The quadratic planning tests plan without validation, beyond its limits, so the old code cannot pass them by luck Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ning * ObjectListFilterMiddleware walks the plan from the root, each plan once per path of response names, with interned paths: hashing the whole relative path of every filter at every level took time quadratic in the nesting, and plans that differ under covariant implementations lead to the same paths * A path filtered differently under different types of an abstract field keeps the last filter instead of failing the request with a duplicate key * ObjectListFilterMiddleware no longer reads the field of a deferred fragment entry at the root, which has none * The deferred fragments a variable disables are inlined once per request and selection set instead of once per object of a list * Validation reports a variable in the default value of a variable instead of following it forever, as in query ($a: Int = $a) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…types ObjectListFilterMiddleware walks a shared plan once per path, so a later walk of the same plan under the path is skipped, and the last filter it assigned depended on which walks were skipped. The first filter of a path never comes from a skipped walk, so keeping it gives the same result as walking the plan as a tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Field merging misses nonadjacent conflicts, and list filters inside deferred fragments are omitted.
2 open findings
What changed in this PR
Optimizes validation, planning, execution, and middleware traversal to prevent super-linear behavior and denial-of-service risks.
Changes:
- Adds shared-plan caching and efficient field merging.
- Makes validation rules linear or bounded.
- Adds adversarial performance and behavior tests.
| File | Description |
|---|---|
ValidationDoSTests.fs |
Adds validation regression tests. |
PlanningDoSTests.fs |
Adds planning, execution, and middleware stress tests. |
MiddlewareTests.fs |
Tests multiple filter metadata entries. |
FSharp.Data.GraphQL.Tests.fsproj |
Includes the new test file. |
Validation.fs |
Optimizes validation traversals and lookups. |
Planning.fs |
Introduces plan reuse and indexed merging. |
Execution.fs |
Caches request-level deferred-fragment inlining. |
MiddlewareDefinitions.fs |
Optimizes middleware plan traversal. |
RELEASE_NOTES.md |
Documents fixes and breaking changes. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| else | ||
| let mergedSet = fieldA.SelectionSet @ fieldB.SelectionSet | ||
| hasSameShape @@ (fieldsInSetCanMerge mergedSet) | ||
| if state.IsOverBudget || not (state.ComparedFields.Add (struct (fieldA, fieldB))) then |
| let path = parent.Child field.Ast.AliasOrName | ||
| fieldFilters item | ||
| |> Result.map (List.iter (fun filter -> filters.TryAdd (path, filter) |> ignore)) | ||
| | _ -> Ok () |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Stacked on
parser-dos-fixes, which is stacked onvalidation-dos-fixes; review those first. This PR has four commits: the last three fix what two rounds of adversarial review found.Problem
Within the limits of the first PR (25 000 selections, nesting 128), some paths were still super-linear:
{ node { child { … } } }nested 30 deep never finished;@defer(if: $var)disables fragments;QueryWeightMiddlewareandObjectListFilterMiddleware, which walked plans as trees;query ($a: Int = $a)made the input value rule loop forever.Changes
QueryWeightMiddlewaremeasures each shared plan once, as its total weight and the peak of the running sum, so it makes the same decisions and reports the same weights as before.ObjectListFilterMiddlewarewalks each plan once per path of response names, with interned paths. It now:@defer.PairSetdoes, so it reports each conflict once;SelectionInfoContext.FragmentDefinitionsis replaced byFragments, a dictionary by name.Tests
The tests are xUnit, in
ValidationDoSTests.fs,PlanningDoSTests.fsandMiddlewareTests.fs. Each runs on a 1 MiB stack with a 10 s timeout.f9077f98query Q($a: Int = $a) { … }f9077f98, with 8 800 documents for planning and execution and 8 000 for validation. The results were identical apart from the changes above.Left for later
Each of these predates this PR:
@defer#0;@defer,@streamor@live, cannot be merged;{ node { id } node { __typename } }loses its second selection;... on Aapplies to every type.Stacked PRs get no CI run, because the workflow only runs for PRs into
masteranddev.🤖 Generated with Claude Code