Skip to content

Fixed mutation root fields not being executed serially - #635

Open
xperiandri wants to merge 3 commits into
devfrom
sequential-mutations
Open

xperiandri wants to merge 3 commits into
devfrom
sequential-mutations

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Problem

The spec requires the root fields of a mutation to be executed serially: a root field starts only once the previous one has completed. This affected mutations and any operation planned with ExecutionStrategy.Sequential.

  • Resolvers ran early. The executor called the resolvers of all root fields up front, and only awaited the resulting AsyncVals in order with AsyncVal.collectSequential. So a synchronous resolver of a later field ran, and a task started by a later resolver kept running, while an earlier field and its nested fields were still pending. The side effects of a mutation did not happen in document order.
  • Deferred root fragments. Validation rejected @defer and @stream on mutation root fields, but not @defer on an inline fragment or fragment spread at the root. A mutation could therefore defer a fragment at its root. The root fields of that fragment then ran after the rest of the mutation, and concurrently with each other.
  • Resolvers throwing synchronously. A resolver can throw before it returns its value, as an AsyncField resolver does when it throws instead of returning its Async.
    • For a root field, the exception escaped execution and failed the whole operation with a request error. The results of the mutation fields already executed were discarded, and the later fields never ran.
    • For a nested field, the parent field's resolveWith caught the exception, so the parent failed instead of the field.

Changes

  • AsyncVal.collectSequentialWith (new). It maps each item to an AsyncVal only once the previous item's AsyncVal has completed. It stays immediate while every item is, and stops at the first failure.
  • Serial execution. The Sequential strategy of executeQueryOrMutation starts each root field through collectSequentialWith. The Parallel strategy and subscriptions are unchanged.
    • Parts of a root field deferred with @defer or streamed with @stream are still delivered later, as the spec allows: the next root field does not wait for them.
  • Breaking: validation of deferred root fragments. Validation now rejects @defer on an inline fragment or fragment spread at the root of a mutation, as the incremental delivery spec requires (graphql-spec#1110, "Defer And Stream Directives Are Used On Valid Root Field").
    • As on a root field, a directive disabled with a literal if: false is still accepted, which the spec does not allow. The library already allowed it on root fields before this change.
  • Synchronous throws. resolveField turns an exception thrown by a resolver before it returns into a failed value of its own field. Such a field now gets a field error, with null propagation as usual.

Tests

The tests are xUnit.

  • MutationTests.fs. Resolvers record when they start and finish, behind gates that the test releases one at a time, so the order of events depends only on the order in which the engine starts resolvers, never on timing. The tests cover:
    • task, async and synchronous root resolvers with nested task fields, run serially;
    • mutation root fields with disabled @defer directives, run serially;
    • query root fields, still run concurrently;
    • a root resolver throwing before it returns: the earlier and later fields keep their results;
    • a nested resolver throwing before it returns: the parent keeps its other fields.
  • AsyncValTests.fs covers collectSequentialWith: the mapping order, staying immediate, and stopping after an immediate or an asynchronous failure.
  • AstValidationTests.fs covers @defer on fragments selecting mutation root fields.

The new tests fail with the previous Execution.fs and Validation.fs.

An independent review checked the following:

  • Resolver kinds: every resolver flavour at the root runs serially — synchronous, cold Async, hot Task, Define.CustomField and TaskSeqField.
  • collectSequentialWith:
    • it runs 1 000 000 immediate items without stack growth;
    • running the result twice gives independent arrays;
    • cancellation and exceptions surface correctly.
  • Performance: a mutation with 20 000 root fields takes as long as on dev.

Verification:

  • Unit tests: the whole project passes, 779 tests with 5 skipped.
  • FAKE: the full pipeline passes, including the integration tests.

Notes

  • Merge conflict: this PR and validation-planning-hardening both change rootIncrementalDirectiveUsages in Validation.fs. Whichever is merged second needs a rebase.
  • Not changed: after a non-null root field of a mutation fails and the data becomes null, the remaining root fields still run, as on dev. The spec says they may be cancelled; graphql-js stops there.

🤖 Generated with Claude Code

xperiandri and others added 2 commits October 3, 2026 13:09
The executor called the resolvers of all root fields up front and only
awaited the resulting AsyncVals in order with AsyncVal.collectSequential.
A synchronous resolver of a later mutation field therefore ran, and a task
started by a later resolver kept running, while an earlier field and its
nested fields were still pending, so the side effects of a mutation did
not happen in document order.

- Added AsyncVal.collectSequentialWith, which maps each item to an AsyncVal
  only once the AsyncVal of the previous item has completed, stays
  immediate while every item is, and stops at the first failure.
- The Sequential strategy of executeQueryOrMutation now starts each root
  field through it; the Parallel strategy and subscriptions are unchanged.
- Validation now rejects @defer on an inline fragment or fragment spread
  at the root of a mutation unless disabled with `if: false`: only root
  fields were checked, so a deferred root fragment was accepted and its
  root fields ran after the rest of the mutation and concurrently.
- Added regression tests with task, async and synchronous resolvers and
  nested fields that record start and finish events behind gates the test
  releases, a query test showing root fields still run concurrently, and
  tests of AsyncVal.collectSequentialWith and of the validation rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ration

A resolver can throw before it returns its value, as an AsyncField resolver
throwing instead of returning its Async does. The exception escaped
resolveField: for a root field it failed the whole operation with a request
error, discarding the results of the mutation fields already executed and
skipping the later ones, and for a nested field the resolveWith of its
parent caught it, failing the parent instead. It is now a failed value of
its own field, like a failure of the returned computation.

Also corrected the comment and the release notes of serial execution, which
do not wait for the parts of a root field deferred with @defer or streamed
with @stream, and of the @defer validation at the mutation root, which still
accepts a directive disabled with a literal `if: false` although the
specification does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 09:47

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.

🟢 Approval recommended

The execution and validation changes are well-scoped and comprehensively tested, with only minor non-blocking style feedback.

1 open finding
What changed in this PR

Fixes serial mutation execution and correctly scopes synchronous resolver failures to their fields.

Changes:

  • Adds lazy sequential AsyncVal collection for root fields.
  • Rejects deferred mutation-root fragments.
  • Adds execution, validation, and failure-path tests.
File Description
Execution.fs Serializes root execution and catches synchronous resolver throws.
AsyncVal.fs Adds collectSequentialWith.
Validation.fs Validates deferred mutation-root fragments.
MutationTests.fs Tests ordering, concurrency, and resolver failures.
AsyncValTests.fs Tests lazy sequential collection.
AstValidationTests.fs Tests fragment validation.
RELEASE_NOTES.md Documents behavior and breaking validation change.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1652 to +1653
usagesOf fragment.Directives []
@ rootIncrementalDirectiveUsages fragmentDefinitions visitedFragments fragment.SelectionSet
… in the mutation tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 28s ⏱️
  887 tests   882 ✅  5 💤 0 ❌
2 661 runs  2 646 ✅ 15 💤 0 ❌

Results for commit 239d6fa.

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