Skip to content

[Fusion] Add defer support#9506

Open
michaelstaib wants to merge 8 commits intomainfrom
mst/fusion-defer
Open

[Fusion] Add defer support#9506
michaelstaib wants to merge 8 commits intomainfrom
mst/fusion-defer

Conversation

@michaelstaib
Copy link
Copy Markdown
Member

No description provided.

Copilot AI review requested due to automatic review settings April 7, 2026 06:52
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds @defer support to the HotChocolate Fusion gateway by splitting deferred fragments during planning and streaming incremental payloads during execution.

Changes:

  • Introduces a defer-aware operation rewriter + planner path that produces DeferredExecutionGroups.
  • Adds an execution path that returns an incremental ResponseStream with pending / incremental / completed.
  • Registers @defer in the composite schema and adds planner/runtime tests + updated schema snapshots.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs New planner-level tests validating group creation, labels, nesting, and conditions.
src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/LookupTests.Require_Inaccessible_Data.graphql Snapshot updated to include @defer directive in schema output.
src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Not_Be_In_The_Schema.graphql Snapshot updated to include @defer directive.
src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Be_In_The_Schema.graphql Snapshot updated to include @defer directive.
src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs New end-to-end tests asserting incremental delivery payloads.
src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs New planner module to plan deferred fragments and build deferred execution nodes.
src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs Hooks defer splitting + deferred group planning into the main planning flow.
src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs Extends plan creation to carry deferred groups; strips @defer from subgraph operations.
src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs New rewriter that extracts deferred fragments into standalone operations.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs Routes requests with deferred groups through a new executor path; blocks variable batching with @defer.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs Implements streaming execution for deferred groups (pending/incremental/completed).
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/SelectionSet.cs Implements HasIncrementalParts for Fusion selection sets.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs Implements IsDeferred based on a defer mask.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs Stores deferred groups on the operation plan.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs Detects presence of @defer to set HasIncrementalParts on compiled operations.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs Implements HasIncrementalParts.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferredExecutionGroup.cs New execution model type representing one deferred fragment group.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOperationInfo.cs Broadens Reset visibility to enable cross-assembly usage.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs Broadens AddToBacklog visibility to enable cross-assembly usage.
src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs Registers the @defer directive in the gateway schema for validation.
src/HotChocolate/Core/src/Execution.Abstractions/HotChocolate.Execution.Abstractions.csproj Adds InternalsVisibleTo for HotChocolate.Fusion.Execution.
global.json Updates SDK version pin.
dotnet-install.sh Adds .NET install helper script (vendored).
.vscode/mcp.json Updates MCP NuGet command invocation (dotnet dnx ...).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +210 to +220
// Check if this group has children that should now start
var childGroups = new List<DeferredExecutionGroup>();
foreach (var candidate in deferredGroups)
{
if (candidate.Parent?.DeferId == group.DeferId)
{
childGroups.Add(candidate);
pendingCount++;
_ = ExecuteDeferredGroupInBackground(
requestContext, variables, operationPlan, candidate, channel.Writer, cancellationToken);
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Nested deferred groups are started/announced unconditionally when a parent group completes. This ignores the child group's @defer(if: $var) condition, so a child defer can execute and be reported as pending even when its condition evaluates to false. Filter candidate the same way as top-level groups (evaluate candidate.IfVariable against variables) before adding to childGroups, incrementing pendingCount, and starting execution.

Copilot uses AI. Check for mistakes.

context.Begin();

await ExecuteQueryAsync(context, deferPlan, executionCts.Token);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Deferred groups are always executed via ExecuteQueryAsync, even when the overall operation is a mutation and the deferred group's compiled operation may be a mutation selection set. This will break @defer on mutation results (the test is currently skipped for this). Consider switching based on deferPlan.Operation.Definition.Operation (query vs mutation) and executing with ExecuteMutationAsync when appropriate.

Suggested change
await ExecuteQueryAsync(context, deferPlan, executionCts.Token);
if (deferPlan.Operation.Definition.Operation is OperationType.Mutation)
{
await ExecuteMutationAsync(context, deferPlan, executionCts.Token);
}
else
{
await ExecuteQueryAsync(context, deferPlan, executionCts.Token);
}

Copilot uses AI. Check for mistakes.
}

return rootOperation
.WithOperation(OperationType.Query)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

BuildDeferredOperation forces the deferred fragment operation type to Query, which prevents @defer from working for mutations (and can lead to planning/execution failures for mutation-only root fields). It would be safer to preserve rootOperation.Operation when building the deferred operation, and then plan/execute the deferred group using the same operation type.

Suggested change
.WithOperation(OperationType.Query)
.WithOperation(rootOperation.Operation)

Copilot uses AI. Check for mistakes.

var index = SelectionSetIndexer.Create(deferredOperation);

var (node, selectionSet) = CreateQueryPlanBase(deferredOperation, "defer", index);
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Deferred fragments are planned using CreateQueryPlanBase unconditionally. If deferred operations are allowed to keep their original operation type (e.g., for mutation defers), this needs to switch on deferredOperation.Operation (query/mutation/subscription) just like the main planner does, otherwise mutation deferred groups will be planned incorrectly.

Suggested change
var (node, selectionSet) = CreateQueryPlanBase(deferredOperation, "defer", index);
var (node, selectionSet) = deferredOperation.Operation switch
{
OperationType.Query => CreateQueryPlanBase(deferredOperation, "defer", index),
OperationType.Mutation => CreateMutationPlanBase(deferredOperation, "defer", index),
OperationType.Subscription => CreateSubscriptionPlanBase(deferredOperation, "defer", index),
_ => throw new GraphQLException(
ErrorBuilder.New()
.SetMessage("Unsupported operation type.")
.Build())
};

Copilot uses AI. Check for mistakes.
Comment on lines +274 to +285
// Empty deferred group — all fields may have been conditional and excluded.
// Report a successful completion with no data.
// We use FromError to create a valid OperationResult, then clear
// top-level errors since this is a successful completion.
var placeholder = ErrorBuilder.New()
.SetMessage("placeholder")
.Build();
payload = OperationResult.FromError(placeholder);
payload.Completed = [new CompletedResult(group.DeferId)];
payload.Data = null;
payload.Errors = [];
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Creating an "empty but successful" deferred payload by constructing a placeholder error and then clearing Errors is fragile (the placeholder message could leak via logging/diagnostics or future formatter changes). Consider introducing an explicit way to create an incremental-only OperationResult (Completed/Pending/HasNext without Data/Errors) so this code path doesn't rely on a dummy error object.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,616 @@
using System.Text;
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

System.Text is not used in this test file and can be removed to avoid unnecessary usings/build warnings.

Suggested change
using System.Text;

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +80
// assert — parse the raw multipart body to verify the incremental delivery format.
// The transport OperationResult parser drops incremental fields, so we parse raw JSON.
var rawBody = await result.HttpResponseMessage.Content.ReadAsStringAsync();
var payloads = rawBody
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line => JsonDocument.Parse(line))
.ToList();
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

This assertion block parses the response as newline-delimited JSON (.Split('\n')), but the comment says "raw multipart body". Since the client’s default Accept header includes application/graphql-response+jsonl (JSON Lines) and not multipart/mixed, consider updating the comment (or assert the response Content-Type explicitly) to match the actual format under test.

Copilot uses AI. Check for mistakes.
# Conflicts:
#	src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
#	src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs
# Conflicts:
#	src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/__snapshots__/SerializeAsTests.SerializeAs_Will_Be_In_The_Schema.graphql
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants