Skip to content

Fixed validation, planning and execution time growing quadratically and exponentially - #640

Open
xperiandri wants to merge 5 commits into
parser-dos-fixesfrom
validation-planning-hardening
Open

xperiandri wants to merge 5 commits into
parser-dos-fixesfrom
validation-planning-hardening

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Stacked on parser-dos-fixes, which is stacked on validation-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:

  • Exponential:
    • planning fields under interfaces and unions; { node { child { … } } } nested 30 deep never finished;
    • merging such selections, including when @defer(if: $var) disables fragments;
    • QueryWeightMiddleware and ObjectListFilterMiddleware, which walked plans as trees;
    • the field merging rule.
  • Quadratic:
    • planning many fields, spreads or deferred fragments;
    • executing an object with many fields;
    • the operation name uniqueness, unused variable and spread rules.
  • Multiplicative:
    • the input value rule checked the values of a fragment for every operation spreading it: a 387 KB document took 71 s;
    • disabled deferred fragments were merged again for every object of a list.
  • Never finishing: query ($a: Int = $a) made the input value rule loop forever.

Changes

  • Planning:
    • reuses the plan of a selection set for the same field, return type and includer;
    • merges the plans of a field selected more than once all at once, remembered by the plans merged;
    • collects fields through an index by identifier.
  • Execution inlines the fragments a variable disables once per request and selection set.
  • QueryWeightMiddleware measures 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.
  • ObjectListFilterMiddleware walks each plan once per path of response names, with interned paths. It now:
    • reports the filters of every list field, instead of only the last one;
    • keeps the first filter of a path that is filtered differently under different types, instead of failing; this is the same result as a tree walk;
    • no longer throws on a root @defer.
  • Validation:
    • field merging compares each unordered pair once, as graphql-js's PairSet does, so it reports each conflict once;
    • the input value rule checks each value once per document and its variables once per operation, never follows a variable inside a default value, and stops at the error limit;
    • a new rule reports a variable in a default value;
    • the operation name uniqueness, unused variable and spread rules are linear.
  • Breaking: SelectionInfoContext.FragmentDefinitions is replaced by Fragments, a dictionary by name.

Tests

The tests are xUnit, in ValidationDoSTests.fs, PlanningDoSTests.fs and MiddlewareTests.fs. Each runs on a 1 MiB stack with a 10 s timeout.

Case f9077f98 This PR
Planning fields nested 30 deep under an interface never finishes ms
Middlewares on fields nested 25 deep exponential < 1 s
Planning 100 000 aliases or 50 000 deferred fragments quadratic < 1.5 s
12 000 operations spreading a fragment with a 4 000-integer list 71.2 s 1.9 s
2 000 operations spreading 2 000 invalid values 8.3 s 0.13 s
query Q($a: Int = $a) { … } never finishes ms
1 000 list objects with two disabled deferred fragments of 2 000 fields timeout 1.5 s
  • Behaviour tests: each behaviour fix has a test that fails with the previous code.
  • Independent review: a reviewer ran differential fuzzers against f9077f98, with 8 800 documents for planning and execution and 8 000 for validation. The results were identical apart from the changes above.
  • Unit tests: the whole project passes, 849 tests with 5 skipped as before. No existing expectation changed.
  • FAKE: the full pipeline passes.

Left for later

Each of these predates this PR:

  • Planning:
    • planning throws when a field is selected twice and both selections hold a deferred fragment, since both are numbered @defer#0;
    • a field selected twice under one name, with and without a field-level @defer, @stream or @live, cannot be merged;
    • { node { id } node { __typename } } loses its second selection;
    • an inline fragment without a type condition inside ... on A applies to every type.
  • Validation:
    • the merging rule skips the selection set of a field selected once;
    • the input value rule checks root fields only.
  • Other:
    • LINQ tracking still walks plans as trees;
    • a token limit comes with the new parser.

Stacked PRs get no CI run, because the workflow only runs for PRs into master and dev.

🤖 Generated with Claude Code

xperiandri and others added 5 commits October 3, 2026 03:50
…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>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 10:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

No deployments
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.

2 participants