diff --git a/.gitignore b/.gitignore
index b5ee7a08d72..d719535568e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -330,3 +330,4 @@ Directory.Build.user.props
.claude/
src/Mocha/benchmarks/Mocha.Mediator.Benchmarks/Generated/
+.work/
diff --git a/CLAUDE.md b/CLAUDE.md
index 4e29fe1e529..28965373875 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -53,6 +53,7 @@ dotnet test src/HotChocolate/Fusion
- No vacuous assertions (`Assert.NotNull` alone is not a test)
- If you need 8 stubs + reflection, you're at the wrong test tier
- Do not use em dash style sentences in docs, comments, or XML documentation. Use commas, periods, parentheses, or colons instead.
+- XML docs should describe the contract and concepts, not internals like pooling, iteration mechanics or leak other implementation detail.
### Testing
diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs
index 6da8fc09558..af5da59f496 100644
--- a/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs
+++ b/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs
@@ -48,22 +48,11 @@ public Path Append(string name)
///
/// Returns the new path segment.
///
- ///
- /// Appending an indexer on the root segment is not allowed.
- ///
///
/// The index must be greater than or equal to zero.
///
public Path Append(int index)
- {
- if (this is RootPathSegment)
- {
- throw new InvalidOperationException(
- "Appending an indexer on the root segment is not allowed.");
- }
-
- return new IndexerPathSegment(this, index);
- }
+ => new IndexerPathSegment(this, index);
///
/// Appends another path to this path.
diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/VariableMergingBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/VariableMergingBenchmark.cs
new file mode 100644
index 00000000000..3f35ff0a5b8
--- /dev/null
+++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/VariableMergingBenchmark.cs
@@ -0,0 +1,204 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using BenchmarkDotNet.Attributes;
+using HotChocolate.Execution;
+using HotChocolate.Fusion.Execution;
+using HotChocolate.Fusion.Execution.Nodes;
+using HotChocolate.Fusion.Execution.Results;
+using HotChocolate.Fusion.Language;
+using HotChocolate.Fusion.Text.Json;
+using HotChocolate.Language;
+using FusionNameNode = HotChocolate.Fusion.Language.NameNode;
+
+namespace Fusion.Execution.Benchmarks;
+
+///
+/// Compares the cost of the defer-only snapshot variable merge path
+/// () against
+/// the existing non-defer variable creation path. The wholesale Case D return
+/// is included to confirm it stays allocation-free.
+///
+[MemoryDiagnoser]
+[InProcess]
+public class VariableMergingBenchmark
+{
+ private static readonly IReadOnlyList s_oneForwardedVariable =
+ [new ObjectFieldNode("limit", new IntValueNode(10))];
+
+ private static readonly IReadOnlyList s_noForwardedVariables = [];
+
+ private static readonly OperationRequirement[] s_singleRequirement =
+ [Requirement("__fusion_1_id")];
+
+ private static readonly HashSet s_oneImportedKey =
+ new(["__fusion_1_id"], StringComparer.Ordinal);
+
+ private static readonly HashSet s_twoImportedKeys =
+ new(["__fusion_1_id", "__fusion_2_sku"], StringComparer.Ordinal);
+
+ private FetchResultStore _baselineStore = null!;
+ private FetchResultStore _snapshotStore = null!;
+
+ private ImmutableArray _singleEntrySnapshot;
+ private ImmutableArray _subsetEntrySnapshot;
+ private ImmutableArray _list10Snapshot;
+ private ImmutableArray _list100Snapshot;
+ private ImmutableArray _list1000Snapshot;
+
+ private ImmutableArray _caseDSnapshot;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ _baselineStore = new FetchResultStore();
+ _snapshotStore = new FetchResultStore();
+
+ // The "source" store mints VariableValues entries that the snapshot
+ // merge consumes as imported parent rows. A separate store mirrors how
+ // a deferred incremental plan imports values from its parent before resolving.
+ var source = new FetchResultStore();
+
+ _singleEntrySnapshot =
+ [
+ CreateImportedEntry(
+ source,
+ CompactPath.Root,
+ Field("__fusion_1_id", new StringValueNode("1")))
+ ];
+
+ _subsetEntrySnapshot =
+ [
+ CreateImportedEntry(
+ source,
+ CompactPath.Root,
+ Field("__fusion_1_id", new StringValueNode("1")),
+ Field("__fusion_2_sku", new StringValueNode("sku-1")))
+ ];
+
+ _list10Snapshot = BuildListSnapshot(source, count: 10);
+ _list100Snapshot = BuildListSnapshot(source, count: 100);
+ _list1000Snapshot = BuildListSnapshot(source, count: 1000);
+
+ // Case D returns the full imported snapshot wholesale. Use a populated
+ // entry so the field read is the only cost being measured.
+ _caseDSnapshot = _singleEntrySnapshot;
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _baselineStore.Dispose();
+ _snapshotStore.Dispose();
+ }
+
+ ///
+ /// Existing non-defer per-entry write path through the same
+ /// writer used by the general overloads.
+ /// Acts as the per-entry baseline against which the snapshot merge cost is
+ /// compared. The full non-defer overload also walks the result document via
+ /// CollectTargetElements, which depends on a populated CompositeResultDocument
+ /// and is intentionally outside the scope of this benchmark.
+ ///
+ [Benchmark(Baseline = true)]
+ public VariableValues NonDefer_Baseline()
+ => _baselineStore.CreateVariableValueSets(CompactPath.Root, s_oneForwardedVariable);
+
+ ///
+ /// Case D: all requested keys are imported, no forwarded variables, and
+ /// the requested set equals the imported set. The runtime returns the
+ /// imported snapshot wholesale, so the only cost is the field read.
+ ///
+ [Benchmark]
+ public ImmutableArray Defer_CaseD_WholesaleReturn()
+ => _caseDSnapshot;
+
+ ///
+ /// Case E with one forwarded variable and one requested requirement.
+ ///
+ [Benchmark]
+ public ImmutableArray Defer_Snapshot_OneForwardedOneRequirement()
+ => _snapshotStore.CreateVariableValueSetsFromSnapshot(
+ _singleEntrySnapshot,
+ s_oneImportedKey,
+ s_oneForwardedVariable,
+ s_singleRequirement);
+
+ ///
+ /// Case E strict subset filter. The imported snapshot carries two keys but
+ /// only one is requested, so the merge writes a smaller variable object.
+ ///
+ [Benchmark]
+ public ImmutableArray Defer_Snapshot_StrictSubset()
+ => _snapshotStore.CreateVariableValueSetsFromSnapshot(
+ _subsetEntrySnapshot,
+ s_twoImportedKeys,
+ s_noForwardedVariables,
+ s_singleRequirement);
+
+ [Benchmark]
+ public ImmutableArray Defer_Snapshot_List_N10()
+ => _snapshotStore.CreateVariableValueSetsFromSnapshot(
+ _list10Snapshot,
+ s_oneImportedKey,
+ s_noForwardedVariables,
+ s_singleRequirement);
+
+ [Benchmark]
+ public ImmutableArray Defer_Snapshot_List_N100()
+ => _snapshotStore.CreateVariableValueSetsFromSnapshot(
+ _list100Snapshot,
+ s_oneImportedKey,
+ s_noForwardedVariables,
+ s_singleRequirement);
+
+ [Benchmark]
+ public ImmutableArray Defer_Snapshot_List_N1000()
+ => _snapshotStore.CreateVariableValueSetsFromSnapshot(
+ _list1000Snapshot,
+ s_oneImportedKey,
+ s_noForwardedVariables,
+ s_singleRequirement);
+
+ private static ImmutableArray BuildListSnapshot(
+ FetchResultStore source,
+ int count)
+ {
+ var builder = ImmutableArray.CreateBuilder(count);
+
+ for (var i = 0; i < count; i++)
+ {
+ builder.Add(
+ CreateImportedEntry(
+ source,
+ Path(i),
+ Field("__fusion_1_id", new StringValueNode(i.ToString()))));
+ }
+
+ return builder.MoveToImmutable();
+ }
+
+ private static VariableValues CreateImportedEntry(
+ FetchResultStore store,
+ CompactPath path,
+ params ObjectFieldNode[] fields)
+ => store.CreateVariableValueSets(path, fields);
+
+ private static ObjectFieldNode Field(string name, IValueNode value)
+ => new(name, value);
+
+ private static OperationRequirement Requirement(string key)
+ => new(
+ key,
+ new NamedTypeNode("String"),
+ SelectionPath.Root,
+ new PathNode(new PathSegmentNode(new FusionNameNode(key))));
+
+ private static CompactPath Path(params int[] segments)
+ {
+ var buffer = new int[segments.Length + 1];
+ buffer[0] = segments.Length;
+ segments.CopyTo(buffer.AsSpan(1));
+ return new CompactPath(buffer);
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs
index dd563a65411..5710667d47f 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs
@@ -72,7 +72,7 @@ public bool IsNodeSkipped(int nodeId)
&& (_failedOrSkippedBitset[index] & (1UL << (nodeId & 63))) != 0;
}
- public void FillBacklog(OperationPlan plan)
+ public void FillBacklog(IOperationPlan plan)
{
_ready.Clear();
_backlogCount = 0;
@@ -180,7 +180,7 @@ public void CancelProcessing()
}
public void CompleteNode(
- OperationPlan plan,
+ IOperationPlan plan,
ExecutionNode node,
ExecutionNodeResult result)
{
@@ -279,7 +279,7 @@ public void CompleteNode(
}
}
- public void SkipNode(OperationPlan plan, ExecutionNode node)
+ public void SkipNode(IOperationPlan plan, ExecutionNode node)
{
_stack.Clear();
_stack.Push(node);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs
deleted file mode 100644
index 3e5b908c22c..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeferUsage.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using HotChocolate.Execution;
-
-namespace HotChocolate.Fusion.Execution.Nodes;
-
-///
-/// Represents a usage of the @defer directive. This is the plan-level identity
-/// of a single deferred fragment (what the GraphQL Incremental Delivery spec calls a
-/// "delivery group"). One instance per @defer occurrence in an operation;
-/// instances form a parent chain to model nested defer scopes and are referenced
-/// by identity from 's active defer-usage set.
-///
-///
-/// The optional label from @defer(label: "..."), used to identify the deferred
-/// payload in the incremental delivery response.
-///
-///
-/// The parent defer usage when this @defer is nested inside another deferred fragment,
-/// or null if this is a top-level defer.
-///
-///
-/// The index into the for the if condition
-/// associated with this defer directive. This index maps to a bit position in the
-/// runtime defer flags bitmask.
-///
-public sealed record DeferUsage(
- string? Label,
- DeferUsage? Parent,
- byte DeferConditionIndex)
-{
- ///
- /// A plan-stable numeric identifier for this defer usage, assigned when the
- /// is built. Serialized as the delivery group's
- /// identity and used as the id in pending, incremental,
- /// and completed entries of the incremental delivery response.
- ///
- public int Id { get; init; } = -1;
-
- ///
- /// The selection path to the object in the response tree whose selection set
- /// contains this @defer. Used as the path of the corresponding
- /// pending entry on the wire.
- ///
- public SelectionPath? Path { get; init; }
-
- ///
- /// The variable name from @defer(if: $var), or null when this
- /// defer is unconditional. Runtime activation of this defer uses this variable
- /// together with .
- ///
- public string? IfVariable { get; init; }
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeliveryGroup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeliveryGroup.cs
new file mode 100644
index 00000000000..21737785870
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/DeliveryGroup.cs
@@ -0,0 +1,44 @@
+using HotChocolate.Execution;
+
+namespace HotChocolate.Fusion.Execution.Nodes;
+
+///
+/// Represents one delivery group introduced by a @defer directive.
+/// Delivery groups form a parent chain for nested deferred fragments and are
+/// referenced by selections when computing active delivery group sets.
+///
+///
+/// The optional label from @defer(label: "...").
+///
+///
+/// The enclosing delivery group when this @defer is nested inside
+/// another deferred fragment, or null for a top-level defer.
+///
+///
+/// The index into the for the if condition
+/// associated with this defer directive. This index maps to a bit position in the
+/// runtime defer flags bitmask.
+///
+public sealed record DeliveryGroup(
+ string? Label,
+ DeliveryGroup? Parent,
+ byte DeferConditionIndex)
+{
+ ///
+ /// A plan-stable numeric identifier for this delivery group.
+ ///
+ public int Id { get; init; } = -1;
+
+ ///
+ /// The selection path to the object whose selection set contains this
+ /// @defer.
+ ///
+ public SelectionPath? Path { get; init; }
+
+ ///
+ /// The variable name from @defer(if: $var), or null when this
+ /// defer is unconditional. Runtime activation of this defer uses this variable
+ /// together with .
+ ///
+ public string? IfVariable { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionNode.cs
index af619b2cc0f..3efe13e768a 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionNode.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionNode.cs
@@ -11,9 +11,11 @@ public abstract class ExecutionNode : IOperationPlanNode, IEquatable
/// The unique id of this execution node.
@@ -54,6 +56,15 @@ public abstract class ExecutionNode : IOperationPlanNode, IEquatable
public ReadOnlySpan OptionalDependencies => _optionalDependencies;
+ ///
+ /// Gets the identifiers of steps in the enclosing plan scope that must
+ /// complete before this node can run.
+ ///
+ public ReadOnlySpan ParentDependencies => _parentDependencies;
+
+ internal ReadOnlySpan BufferedParentDependencies
+ => _parentDependencies.AsSpan(0, _parentDependencyCount);
+
#pragma warning disable CA2012
public void BeginExecute(
OperationPlanContext context,
@@ -172,6 +183,23 @@ internal void AddDependent(IOperationPlanNode node)
_dependents[_dependentCount++] = node;
}
+ internal void AddParentDependency(int parentNodeId)
+ {
+ ExpectMutable();
+
+ if (_parentDependencies.Length == 0)
+ {
+ _parentDependencies = new int[4];
+ }
+
+ if (_parentDependencyCount == _parentDependencies.Length)
+ {
+ Array.Resize(ref _parentDependencies, _parentDependencyCount * 2);
+ }
+
+ _parentDependencies[_parentDependencyCount++] = parentNodeId;
+ }
+
internal void AddOptionalDependency(IOperationPlanNode node)
{
ExpectMutable();
@@ -215,9 +243,15 @@ protected internal void Seal()
Array.Resize(ref _optionalDependencies, _optionalDependencyCount);
}
+ if (_parentDependencies.Length > _parentDependencyCount)
+ {
+ Array.Resize(ref _parentDependencies, _parentDependencyCount);
+ }
+
Array.Sort(_dependencies, static (a, b) => a.Id.CompareTo(b.Id));
Array.Sort(_dependents, static (a, b) => a.Id.CompareTo(b.Id));
Array.Sort(_optionalDependencies, static (a, b) => a.Id.CompareTo(b.Id));
+ Array.Sort(_parentDependencies);
OnSealingNode();
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionSubPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionSubPlan.cs
deleted file mode 100644
index 03a0285b13b..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionSubPlan.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System.Collections.Immutable;
-
-namespace HotChocolate.Fusion.Execution.Nodes;
-
-///
-/// Represents a plan for executing the fields that belong to a specific
-/// DeferUsageSet. One is emitted per
-/// unique non-empty active defer usage set in the operation. Its data is
-/// delivered to every in
-/// when the subplan completes.
-///
-public sealed class ExecutionSubPlan
-{
- ///
- /// Initializes a new instance of .
- ///
- ///
- /// The compiled operation for this subplan's result mapping.
- ///
- ///
- /// The root execution nodes that serve as entry points for this subplan.
- ///
- ///
- /// All execution nodes belonging to this subplan.
- ///
- ///
- /// The set that keys this subplan, sorted ascending
- /// by . Every element is a delivery group that
- /// receives this subplan's data on the wire when the subplan completes.
- ///
- public ExecutionSubPlan(
- Operation operation,
- ImmutableArray rootNodes,
- ImmutableArray allNodes,
- ImmutableArray deliveryGroups)
- {
- Operation = operation;
- RootNodes = rootNodes;
- AllNodes = allNodes;
- DeliveryGroups = deliveryGroups;
- }
-
- ///
- /// Gets the compiled operation for this subplan.
- /// This is a standalone operation compiled from the rewritten subplan AST,
- /// used for result mapping during execution.
- ///
- public Operation Operation { get; }
-
- ///
- /// Gets the root execution nodes that serve as entry points for this subplan.
- ///
- public ImmutableArray RootNodes { get; }
-
- ///
- /// Gets all execution nodes belonging to this subplan.
- ///
- public ImmutableArray AllNodes { get; }
-
- ///
- /// Gets the set that keys this subplan, sorted
- /// ascending by . When this subplan completes,
- /// every in this set receives the subplan's data
- /// as an incremental payload on the wire.
- ///
- public ImmutableArray DeliveryGroups { get; }
-
- ///
- /// Gets the in the owning plan (the main plan
- /// for top-level subplans, the parent subplan's plan for nested subplans)
- /// whose fetch resolves the selection set where this subplan was anchored.
- /// Always populated for a sealed plan; query plan visualizers can use this
- /// to attach the subplan to the node that produces its enclosing data.
- /// Set during plan construction.
- ///
- public int ParentNodeId { get; internal set; }
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs
index 29ca8d9724f..fa6042b5a35 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldSelectionNode.cs
@@ -3,7 +3,7 @@
namespace HotChocolate.Fusion.Execution.Nodes;
///
-/// Represents a field selection node with its path include flags and defer usage.
+/// Represents a field selection node with its path include flags and delivery group.
///
///
/// The syntax node that represents the field selection.
@@ -11,11 +11,11 @@ namespace HotChocolate.Fusion.Execution.Nodes;
///
/// The flags that must be all set for this selection to be included.
///
-///
-/// The defer usage context this field was collected under, or null if the field
-/// is not inside a deferred fragment.
+///
+/// The delivery group context this field was collected under, or null
+/// if the field is not inside a deferred fragment.
///
public sealed record FieldSelectionNode(
FieldNode Node,
ulong PathIncludeFlags,
- DeferUsage? DeferUsage = null);
+ DeliveryGroup? DeliveryGroup = null);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IOperationPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IOperationPlan.cs
new file mode 100644
index 00000000000..869ce85142a
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IOperationPlan.cs
@@ -0,0 +1,67 @@
+using System.Collections.Immutable;
+
+namespace HotChocolate.Fusion.Execution.Nodes;
+
+///
+/// Represents the common contract for operation plans.
+///
+public interface IOperationPlan
+{
+ ///
+ /// Gets the unique identifier for this plan.
+ ///
+ string Id { get; }
+
+ ///
+ /// Gets the GraphQL operation associated with this plan.
+ ///
+ Operation Operation { get; }
+
+ ///
+ /// Gets the root execution nodes that serve as entry points for this plan.
+ ///
+ ImmutableArray RootNodes { get; }
+
+ ///
+ /// Gets all execution nodes belonging to this plan, including both root and
+ /// nested nodes.
+ ///
+ ImmutableArray AllNodes { get; }
+
+ ///
+ /// Gets every (delivery group) this plan uses, in
+ /// ascending order. Empty if the plan has no
+ /// @defer directives in scope.
+ ///
+ ImmutableArray DeliveryGroups { get; }
+
+ ///
+ /// Gets the incremental plans associated with this plan.
+ ///
+ ImmutableArray IncrementalPlans { get; }
+
+ ///
+ /// Gets the highest plan node identifier that can be resolved by this plan.
+ ///
+ int MaxNodeId { get; }
+
+ ///
+ /// Retrieves the execution node associated with a plan node identifier.
+ ///
+ ///
+ /// The identifier of an execution node, or of an operation definition inside
+ /// a batch.
+ ///
+ /// The execution node associated with the specified identifier.
+ ExecutionNode GetNodeById(int id);
+
+ ///
+ /// Returns the responsible for executing the
+ /// given plan node. If the plan node is already an execution node it is
+ /// returned directly; if it is a child operation (such as an operation
+ /// definition inside a batch) the containing execution node is returned.
+ ///
+ /// The plan node to resolve.
+ /// The execution node that owns the given plan node.
+ ExecutionNode GetExecutionNode(IOperationPlanNode planNode);
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs
new file mode 100644
index 00000000000..65a83fae2fb
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IncrementalPlan.cs
@@ -0,0 +1,186 @@
+using System.Collections.Immutable;
+
+namespace HotChocolate.Fusion.Execution.Nodes;
+
+///
+/// Represents a plan for fields associated with a delivery group set.
+///
+public sealed class IncrementalPlan : IOperationPlan
+{
+ private readonly ExecutionNode?[] _nodesById;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The GraphQL operation associated with this incremental plan.
+ ///
+ ///
+ /// The root execution nodes that serve as entry points for this
+ /// incremental plan.
+ ///
+ ///
+ /// All execution nodes belonging to this incremental plan.
+ ///
+ ///
+ /// The delivery group set associated with this incremental plan, sorted
+ /// ascending by .
+ ///
+ ///
+ /// The requirements that this plan resolves from its enclosing plan scope
+ /// before it executes.
+ ///
+ public IncrementalPlan(
+ Operation operation,
+ ImmutableArray rootNodes,
+ ImmutableArray allNodes,
+ ImmutableArray deliveryGroups,
+ ImmutableArray requirements)
+ {
+ Operation = operation;
+ RootNodes = rootNodes;
+ AllNodes = allNodes;
+ DeliveryGroups = deliveryGroups;
+ Requirements = requirements.IsDefault ? [] : requirements;
+ _nodesById = CreateNodeLookup(allNodes);
+ }
+
+ ///
+ /// Gets the unique identifier for this incremental plan.
+ ///
+ public string Id { get; internal set; } = string.Empty;
+
+ ///
+ /// Gets the GraphQL operation associated with this incremental plan.
+ ///
+ public Operation Operation { get; }
+
+ ///
+ /// Gets the root execution nodes that serve as entry points for this
+ /// incremental plan.
+ ///
+ public ImmutableArray RootNodes { get; }
+
+ ///
+ /// Gets all execution nodes belonging to this incremental plan.
+ ///
+ public ImmutableArray AllNodes { get; }
+
+ ///
+ /// Gets the delivery group set associated with this incremental plan,
+ /// sorted ascending by .
+ ///
+ public ImmutableArray DeliveryGroups { get; }
+
+ ///
+ /// Gets the requirements that this plan resolves from its enclosing plan
+ /// scope before it executes. Each requirement maps a variable used by this
+ /// plan to a selection in the enclosing scope's result.
+ ///
+ public ImmutableArray Requirements { get; }
+
+ ///
+ /// Gets the of the node that produces the
+ /// result object where this incremental plan is anchored. The identifier is
+ /// scoped to the root for top-level plans, or
+ /// to the enclosing for nested plans.
+ ///
+ public int ParentNodeId { get; internal set; }
+
+ ///
+ /// Gets the highest plan node identifier that can be resolved by this plan.
+ ///
+ public int MaxNodeId => _nodesById.Length > 0 ? _nodesById.Length - 1 : 0;
+
+ ///
+ /// Gets the child incremental plans for this plan. Incremental plans do not
+ /// contain child plan objects; the root exposes
+ /// the flat collection, and deferred fragment nesting is represented by
+ /// .
+ ///
+ public ImmutableArray IncrementalPlans => [];
+
+ ///
+ /// Retrieves the execution node associated with a plan node identifier.
+ ///
+ ///
+ /// The identifier of an execution node, or of an operation definition inside
+ /// a batch.
+ ///
+ /// The execution node associated with the specified identifier.
+ /// Thrown when no node with the specified ID exists.
+ public ExecutionNode GetNodeById(int id)
+ {
+ if ((uint)id < (uint)_nodesById.Length
+ && _nodesById[id] is { } node)
+ {
+ return node;
+ }
+
+ throw ThrowHelper.NodeNotFound(id);
+ }
+
+ ///
+ /// Returns the responsible for executing the
+ /// given plan node. If the plan node is already an execution node it is
+ /// returned directly; if it is an operation definition inside a batch, the
+ /// containing batch node is returned.
+ ///
+ public ExecutionNode GetExecutionNode(IOperationPlanNode planNode)
+ {
+ if (planNode is ExecutionNode executionNode)
+ {
+ return executionNode;
+ }
+
+ if ((uint)planNode.Id < (uint)_nodesById.Length
+ && _nodesById[planNode.Id] is { } node)
+ {
+ return node;
+ }
+
+ throw ThrowHelper.NodeNotFound(planNode.Id);
+ }
+
+ private static ExecutionNode?[] CreateNodeLookup(ImmutableArray allNodes)
+ {
+ if (allNodes.IsDefaultOrEmpty)
+ {
+ return [];
+ }
+
+ var maxId = 0;
+
+ foreach (var node in allNodes)
+ {
+ maxId = Math.Max(maxId, node.Id);
+
+ if (node is OperationBatchExecutionNode batchNode)
+ {
+ foreach (var op in batchNode.Operations)
+ {
+ maxId = Math.Max(maxId, op.Id);
+ }
+ }
+ }
+
+ var nodesById = new ExecutionNode?[maxId + 1];
+
+ foreach (var node in allNodes)
+ {
+ nodesById[node.Id] = node;
+
+ // Map each operation definition ID to the containing batch node,
+ // so GetNodeById can resolve definition IDs to execution nodes.
+ if (node is OperationBatchExecutionNode batchNode)
+ {
+ foreach (var op in batchNode.Operations)
+ {
+ nodesById[op.Id] = batchNode;
+ }
+ }
+ }
+
+ return nodesById;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs
index 757287347ad..e6a837457aa 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs
@@ -21,7 +21,7 @@ public sealed class Operation : IOperation
private readonly OperationCompiler _compiler;
private readonly IncludeConditionCollection _includeConditions;
private readonly DeferConditionCollection _deferConditions;
- private readonly IReadOnlyDictionary _deferUsageByFragment;
+ private readonly IReadOnlyDictionary _deliveryGroupByFragment;
private readonly OperationFeatureCollection _features;
private readonly bool _hasIncrementalParts;
private object[] _elementsById;
@@ -37,7 +37,7 @@ internal Operation(
OperationCompiler compiler,
IncludeConditionCollection includeConditions,
DeferConditionCollection deferConditions,
- IReadOnlyDictionary deferUsageByFragment,
+ IReadOnlyDictionary deliveryGroupByFragment,
bool hasIncrementalParts,
int lastId,
object[] elementsById)
@@ -51,7 +51,7 @@ internal Operation(
ArgumentNullException.ThrowIfNull(compiler);
ArgumentNullException.ThrowIfNull(includeConditions);
ArgumentNullException.ThrowIfNull(deferConditions);
- ArgumentNullException.ThrowIfNull(deferUsageByFragment);
+ ArgumentNullException.ThrowIfNull(deliveryGroupByFragment);
ArgumentNullException.ThrowIfNull(elementsById);
Id = id;
@@ -63,7 +63,7 @@ internal Operation(
_compiler = compiler;
_includeConditions = includeConditions;
_deferConditions = deferConditions;
- _deferUsageByFragment = deferUsageByFragment;
+ _deliveryGroupByFragment = deliveryGroupByFragment;
_hasIncrementalParts = hasIncrementalParts;
_lastId = lastId;
_elementsById = elementsById;
@@ -176,7 +176,7 @@ public SelectionSet GetSelectionSet(Selection selection, IObjectTypeDefinition t
selection,
(FusionObjectTypeDefinition)typeContext,
_includeConditions,
- _deferUsageByFragment,
+ _deliveryGroupByFragment,
ref _elementsById,
ref _lastId);
selectionSet.Seal(this);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs
index b95692b9afe..12742573734 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationCompiler.cs
@@ -50,10 +50,10 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati
IncludeConditionVisitor.Instance.Visit(operationDefinition, includeConditions);
// Scans the operation for @defer fragments and creates one
- // DeferUsage object for each. Also fills deferConditions with any
+ // DeliveryGroup object for each. Also fills deferConditions with any
// @defer(if: ...) expressions found along the way.
//
- // Important: each @defer must always map to the same DeferUsage
+ // Important: each @defer must always map to the same DeliveryGroup
// instance. Runtime matching checks if two are the same object,
// not just equal in content, so handing back a fresh copy with
// equal field values would silently break it.
@@ -76,7 +76,7 @@ public Operation Compile(string id, string hash, OperationDefinitionNode operati
fields,
includeConditions,
partitioning.ByFragment,
- parentDeferUsage: null);
+ parentDeliveryGroup: null);
var hasIncrementalParts = HasDeferDirective(operationDefinition);
@@ -113,7 +113,7 @@ internal SelectionSet CompileSelectionSet(
Selection selection,
FusionObjectTypeDefinition objectType,
IncludeConditionCollection includeConditions,
- IReadOnlyDictionary deferUsageByFragment,
+ IReadOnlyDictionary deliveryGroupByFragment,
ref object[] elementsById,
ref int lastId)
{
@@ -132,8 +132,8 @@ internal SelectionSet CompileSelectionSet(
objectType,
fields,
includeConditions,
- deferUsageByFragment,
- parentDeferUsage: first.DeferUsage);
+ deliveryGroupByFragment,
+ parentDeliveryGroup: first.DeliveryGroup);
if (nodes.Length > 1)
{
@@ -147,8 +147,8 @@ internal SelectionSet CompileSelectionSet(
objectType,
fields,
includeConditions,
- deferUsageByFragment,
- parentDeferUsage: nodes[i].DeferUsage);
+ deliveryGroupByFragment,
+ parentDeliveryGroup: nodes[i].DeliveryGroup);
}
}
@@ -169,8 +169,8 @@ private void CollectFields(
IObjectTypeDefinition typeContext,
OrderedDictionary> fields,
IncludeConditionCollection includeConditions,
- IReadOnlyDictionary deferUsageByFragment,
- DeferUsage? parentDeferUsage)
+ IReadOnlyDictionary deliveryGroupByFragment,
+ DeliveryGroup? parentDeliveryGroup)
{
for (var i = 0; i < selections.Count; i++)
{
@@ -193,7 +193,7 @@ private void CollectFields(
pathIncludeFlags |= 1ul << index;
}
- nodes.Add(new FieldSelectionNode(fieldNode, pathIncludeFlags, parentDeferUsage));
+ nodes.Add(new FieldSelectionNode(fieldNode, pathIncludeFlags, parentDeliveryGroup));
}
else if (selection is InlineFragmentNode inlineFragmentNode
&& DoesTypeApply(inlineFragmentNode.TypeCondition, typeContext))
@@ -206,13 +206,13 @@ private void CollectFields(
pathIncludeFlags |= 1ul << index;
}
- // Look up the canonical DeferUsage from the pre-computed
+ // Look up the canonical DeliveryGroup from the pre-computed
// partitioning. The partitioner created one instance per
// `... @defer` occurrence; using it here guarantees downstream
// set-identity comparisons work correctly.
- var newDeferUsage = deferUsageByFragment.TryGetValue(inlineFragmentNode, out var canonical)
+ var deliveryGroup = deliveryGroupByFragment.TryGetValue(inlineFragmentNode, out var canonical)
? canonical
- : parentDeferUsage;
+ : parentDeliveryGroup;
CollectFields(
pathIncludeFlags,
@@ -220,8 +220,8 @@ private void CollectFields(
typeContext,
fields,
includeConditions,
- deferUsageByFragment,
- newDeferUsage);
+ deliveryGroupByFragment,
+ deliveryGroup);
}
}
}
@@ -237,26 +237,26 @@ private SelectionSet BuildSelectionSet(
var isConditional = false;
var hasIncrementalParts = false;
var includeFlags = new List();
- var deferUsages = new List();
+ var deliveryGroups = new List();
var selectionSetId = ++lastId;
foreach (var (responseName, nodes) in fieldMap)
{
includeFlags.Clear();
- deferUsages.Clear();
+ deliveryGroups.Clear();
var first = nodes[0];
var isInternal = IsInternal(first.Node);
- var hasNonDeferredNode = first.DeferUsage is null;
+ var hasImmediateNode = first.DeliveryGroup is null;
if (first.PathIncludeFlags > 0)
{
includeFlags.Add(first.PathIncludeFlags);
}
- if (first.DeferUsage is not null)
+ if (first.DeliveryGroup is not null)
{
- deferUsages.Add(first.DeferUsage);
+ deliveryGroups.Add(first.DeliveryGroup);
}
if (nodes.Count > 1)
@@ -276,13 +276,13 @@ private SelectionSet BuildSelectionSet(
includeFlags.Add(next.PathIncludeFlags);
}
- if (next.DeferUsage is null)
+ if (next.DeliveryGroup is null)
{
- hasNonDeferredNode = true;
+ hasImmediateNode = true;
}
- else if (!hasNonDeferredNode)
+ else if (!hasImmediateNode)
{
- deferUsages.Add(next.DeferUsage);
+ deliveryGroups.Add(next.DeliveryGroup);
}
if (isInternal)
@@ -300,21 +300,21 @@ private SelectionSet BuildSelectionSet(
// If any field node is not inside a deferred fragment, the selection
// is not deferred, so it must be included in the initial response.
ulong deferMask = 0;
- DeferUsage[]? selectionDeferUsages = null;
+ DeliveryGroup[]? selectionDeliveryGroups = null;
- if (!hasNonDeferredNode && deferUsages.Count > 0)
+ if (!hasImmediateNode && deliveryGroups.Count > 0)
{
- // Remove child defer usages when their parent is also in the set.
+ // Remove child delivery groups when their parent is also in the set.
// A field should be delivered with the outermost (earliest) defer
// that contains it.
- for (var j = deferUsages.Count - 1; j >= 0; j--)
+ for (var j = deliveryGroups.Count - 1; j >= 0; j--)
{
- var parent = deferUsages[j].Parent;
+ var parent = deliveryGroups[j].Parent;
while (parent is not null)
{
- if (deferUsages.Contains(parent))
+ if (deliveryGroups.Contains(parent))
{
- deferUsages.RemoveAt(j);
+ deliveryGroups.RemoveAt(j);
break;
}
@@ -322,14 +322,14 @@ private SelectionSet BuildSelectionSet(
}
}
- foreach (var usage in deferUsages)
+ foreach (var deliveryGroup in deliveryGroups)
{
- deferMask |= 1ul << usage.DeferConditionIndex;
+ deferMask |= 1ul << deliveryGroup.DeferConditionIndex;
}
// Preserve the pruned list on the Selection so the runtime can
- // answer GetActiveDeferUsages and HasActiveDeferUsage.
- selectionDeferUsages = deferUsages.ToArray();
+ // answer GetActiveDeliveryGroups and HasActiveDeliveryGroup.
+ selectionDeliveryGroups = deliveryGroups.ToArray();
hasIncrementalParts = true;
}
@@ -346,7 +346,7 @@ private SelectionSet BuildSelectionSet(
includeFlags.ToArray(),
isInternal,
deferMask,
- selectionDeferUsages);
+ selectionDeliveryGroups);
// Register the selection in the elements array
compilationContext.Register(selection, selection.Id);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs
index 907a2b927ec..101a65b0ea0 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs
@@ -8,8 +8,10 @@ internal abstract class OperationDefinition : IOperationPlanNode
private readonly string[] _forwardedVariables;
private readonly ExecutionNodeCondition[] _conditions;
private readonly ulong _operationHash;
+ private int[] _parentDependencies = [];
private IOperationPlanNode[] _dependents = [];
private IOperationPlanNode[] _dependencies = [];
+ private int _parentDependencyCount;
private int _dependentCount;
private int _dependencyCount;
@@ -48,8 +50,7 @@ protected OperationDefinition(
public OperationSourceText Operation { get; }
///
- /// Gets the xxhash64 of the operation source text. Precomputed during
- /// construction for use as a cache key by connectors.
+ /// Gets the xxhash64 of the operation source text.
///
public ulong OperationHash => _operationHash;
@@ -94,6 +95,12 @@ protected OperationDefinition(
///
public bool RequiresFileUpload { get; }
+ ///
+ /// Gets the identifiers of steps in the parent plan scope that must
+ /// complete before this operation definition can run.
+ ///
+ public ReadOnlySpan ParentDependencies => _parentDependencies.AsSpan(0, _parentDependencyCount);
+
///
/// Gets the nodes that cannot start until this operation definition has
/// completed.
@@ -128,6 +135,21 @@ internal void AddDependency(IOperationPlanNode node)
_dependencies[_dependencyCount++] = node;
}
+ internal void AddParentDependency(int parentNodeId)
+ {
+ if (_parentDependencies.Length == 0)
+ {
+ _parentDependencies = new int[4];
+ }
+
+ if (_parentDependencyCount == _parentDependencies.Length)
+ {
+ Array.Resize(ref _parentDependencies, _parentDependencyCount * 2);
+ }
+
+ _parentDependencies[_parentDependencyCount++] = parentNodeId;
+ }
+
internal void AddDependent(IOperationPlanNode node)
{
if (_dependents.Length == 0)
@@ -155,7 +177,13 @@ internal void Seal()
Array.Resize(ref _dependents, _dependentCount);
}
+ if (_parentDependencies.Length > _parentDependencyCount)
+ {
+ Array.Resize(ref _parentDependencies, _parentDependencyCount);
+ }
+
Array.Sort(_dependencies, static (a, b) => a.Id.CompareTo(b.Id));
Array.Sort(_dependents, static (a, b) => a.Id.CompareTo(b.Id));
+ Array.Sort(_parentDependencies, static (a, b) => a.CompareTo(b));
}
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs
index 5db55ca97c2..b5da1d62de9 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationPlan.cs
@@ -10,7 +10,7 @@ namespace HotChocolate.Fusion.Execution.Nodes;
/// Represents a GraphQL operation execution plan in Hot Chocolate Fusion, containing
/// the structured nodes and metadata required for distributed query execution.
///
-public sealed record OperationPlan
+public sealed record OperationPlan : IOperationPlan
{
private static readonly JsonOperationPlanFormatter s_formatter = new();
private readonly ExecutionNode?[] _nodesById = [];
@@ -20,8 +20,8 @@ private OperationPlan(
Operation operation,
ImmutableArray rootNodes,
ImmutableArray allNodes,
- ImmutableArray deliveryGroups,
- ImmutableArray deferredSubPlans,
+ ImmutableArray deliveryGroups,
+ ImmutableArray incrementalPlans,
int searchSpace,
int expandedNodes)
{
@@ -32,7 +32,7 @@ private OperationPlan(
SearchSpace = searchSpace;
ExpandedNodes = expandedNodes;
DeliveryGroups = deliveryGroups;
- DeferredSubPlans = deferredSubPlans;
+ IncrementalPlans = incrementalPlans;
_nodesById = CreateNodeLookup(allNodes);
MaxNodeId = _nodesById.Length > 0 ? _nodesById.Length - 1 : 0;
}
@@ -79,33 +79,35 @@ public IReadOnlyList VariableDefinitions
public int ExpandedNodes { get; }
///
- /// Gets every (delivery group) this plan uses, in
- /// ascending order. One element per @defer
+ /// Gets every (delivery group) this plan uses, in
+ /// ascending order. One element per @defer
/// occurrence in the operation. Empty if the operation has no @defer
/// directives.
///
- public ImmutableArray DeliveryGroups { get; }
+ public ImmutableArray DeliveryGroups { get; }
///
- /// Gets the deferred execution subplans for this plan. Each subplan is
- /// keyed by a unique DeferUsageSet and fetches the fields whose
- /// active defer usage set matches that key. The subplan's data is
- /// delivered to every in its
- /// when it completes.
+ /// Gets the incremental plans for this plan. This is a flat collection;
+ /// deferred fragment nesting is represented by .
+ /// Each plan carries its delivery group set on
+ /// .
/// Empty if the operation has no @defer directives.
///
- public ImmutableArray DeferredSubPlans { get; }
+ public ImmutableArray IncrementalPlans { get; }
///
- /// Gets the maximum node identifier across all nodes in this plan.
+ /// Gets the highest plan node identifier that can be resolved by this plan.
///
public int MaxNodeId { get; }
///
- /// Retrieves an execution node by its unique identifier.
+ /// Retrieves the execution node associated with a plan node identifier.
///
- /// The unique identifier of the execution node is unique within this plan.
- /// The execution node with the specified identifier.
+ ///
+ /// The identifier of an execution node, or of an operation definition inside
+ /// a batch.
+ ///
+ /// The execution node associated with the specified identifier.
/// Thrown when no node with the specified ID exists.
public ExecutionNode GetNodeById(int id)
{
@@ -119,16 +121,10 @@ public ExecutionNode GetNodeById(int id)
}
///
- ///
- /// Returns the that is responsible for executing
- /// the given plan node.
- ///
- ///
- /// If the plan node is already an execution node it is returned directly.
- /// If it is a child operation plan node (such as an
- /// inside a batch) this method returns the parent execution node that is
- /// responsible for its execution.
- ///
+ /// Returns the responsible for executing the
+ /// given plan node. If the plan node is already an execution node it is
+ /// returned directly; if it is an operation definition inside a batch, the
+ /// containing batch node is returned.
///
public ExecutionNode GetExecutionNode(IOperationPlanNode planNode)
{
@@ -154,26 +150,28 @@ public ExecutionNode GetExecutionNode(IOperationPlanNode planNode)
/// The root execution nodes.
/// All execution nodes in the plan.
///
- /// Every (delivery group) this plan uses, in ascending
- /// order.
+ /// Every (delivery group) this plan uses, in ascending
+ /// order.
///
- ///
- /// The deferred execution subplans for @defer support, one per unique
- /// DeferUsageSet.
+ ///
+ /// The incremental plans for @defer support. The collection is flat;
+ /// deferred fragment nesting is represented by .
+ /// Each plan carries its delivery group set on
+ /// .
///
/// A number specifying how many possible plans were considered during planning.
/// The number of expanded nodes during planner search.
/// A new instance.
/// Thrown when is null or empty.
/// Thrown when is null.
- /// Thrown when node arrays have negative length.
+ /// Thrown when node collections are invalid.
public static OperationPlan Create(
string id,
Operation operation,
ImmutableArray rootNodes,
ImmutableArray allNodes,
- ImmutableArray deliveryGroups,
- ImmutableArray deferredSubPlans,
+ ImmutableArray deliveryGroups,
+ ImmutableArray incrementalPlans,
int searchSpace,
int expandedNodes)
{
@@ -183,35 +181,36 @@ public static OperationPlan Create(
ArgumentOutOfRangeException.ThrowIfLessThan(allNodes.Length, 0);
return new OperationPlan(
- id, operation, rootNodes, allNodes, deliveryGroups, deferredSubPlans, searchSpace, expandedNodes);
+ id, operation, rootNodes, allNodes, deliveryGroups, incrementalPlans, searchSpace, expandedNodes);
}
///
- /// Creates a new operation plan with an auto-generated identifier based on the operation's content.
- /// The identifier is generated by computing a SHA256 hash of the serialized operation plan.
+ /// Creates a new operation plan with an identifier derived from the plan content.
///
/// The GraphQL operation.
/// The root execution nodes.
/// All execution nodes in the plan.
///
- /// Every (delivery group) this plan uses, in ascending
- /// order.
+ /// Every (delivery group) this plan uses, in ascending
+ /// order.
///
- ///
- /// The deferred execution subplans for @defer support, one per unique
- /// DeferUsageSet.
+ ///
+ /// The incremental plans for @defer support. The collection is flat;
+ /// deferred fragment nesting is represented by .
+ /// Each plan carries its delivery group set on
+ /// .
///
/// A number specifying how many possible plans were considered during planning.
/// The number of expanded nodes during planner search.
/// A new instance with a content-based identifier.
/// Thrown when is null.
- /// Thrown when node arrays have negative length.
+ /// Thrown when node collections are invalid.
public static OperationPlan Create(
Operation operation,
ImmutableArray rootNodes,
ImmutableArray allNodes,
- ImmutableArray deliveryGroups,
- ImmutableArray deferredSubPlans,
+ ImmutableArray deliveryGroups,
+ ImmutableArray incrementalPlans,
int searchSpace,
int expandedNodes)
{
@@ -236,7 +235,14 @@ public static OperationPlan Create(
#endif
return new OperationPlan(
- id, operation, rootNodes, allNodes, deliveryGroups, deferredSubPlans, searchSpace, expandedNodes);
+ id,
+ operation,
+ rootNodes,
+ allNodes,
+ deliveryGroups,
+ incrementalPlans,
+ searchSpace,
+ expandedNodes);
}
private static ExecutionNode?[] CreateNodeLookup(ImmutableArray allNodes)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs
index b8a9b9afa7f..e5f5a6d7c08 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs
@@ -11,13 +11,13 @@ namespace HotChocolate.Fusion.Execution.Nodes;
///
public sealed class Selection : ISelection
{
- private static readonly DeferUsage[] s_emptyDeferUsages = [];
+ private static readonly DeliveryGroup[] s_emptyDeliveryGroups = [];
private readonly FieldSelectionNode[] _syntaxNodes;
private readonly ulong[] _includeFlags;
private readonly byte[] _utf8ResponseName;
private readonly ulong _deferMask;
- private readonly DeferUsage[] _deferUsages;
+ private readonly DeliveryGroup[] _deliveryGroups;
private Flags _flags;
public Selection(
@@ -28,7 +28,7 @@ public Selection(
ulong[] includeFlags,
bool isInternal,
ulong deferMask = 0,
- DeferUsage[]? deferUsages = null)
+ DeliveryGroup[]? deliveryGroups = null)
{
ArgumentNullException.ThrowIfNull(field);
@@ -45,7 +45,7 @@ public Selection(
_syntaxNodes = syntaxNodes;
_includeFlags = includeFlags;
_deferMask = deferMask;
- _deferUsages = deferUsages ?? s_emptyDeferUsages;
+ _deliveryGroups = deliveryGroups ?? s_emptyDeliveryGroups;
_flags = isInternal ? Flags.Internal : Flags.None;
if (field.Type.NamedType().IsLeafType())
@@ -87,6 +87,27 @@ public Selection(
///
ISelectionSet ISelection.DeclaringSelectionSet => DeclaringSelectionSet;
+ ///
+ /// Gets the child selection set for this selection's named return type.
+ ///
+ ///
+ /// The child selection set, or null when this selection has no child
+ /// selection set.
+ ///
+ public SelectionSet? GetSelectionSet()
+ => IsLeaf ? null : DeclaringSelectionSet.DeclaringOperation.GetSelectionSet(this);
+
+ ///
+ /// Gets the child selection set for this selection and the specified
+ /// .
+ ///
+ ///
+ /// The child selection set, or null when this selection has no child
+ /// selection set.
+ ///
+ public SelectionSet? GetSelectionSet(IObjectTypeDefinition typeContext)
+ => IsLeaf ? null : DeclaringSelectionSet.DeclaringOperation.GetSelectionSet(this, typeContext);
+
///
/// Gets the syntax nodes that contributed to this selection.
///
@@ -174,31 +195,30 @@ internal void Seal(SelectionSet selectionSet)
public bool IsDeferred(ulong deferFlags) => (_deferMask & deferFlags) != 0;
///
- /// Returns the active defer usages for this selection given the runtime
- /// , after resolving inactive defers to their
- /// nearest active ancestor and applying parent-child pruning (ancestors win).
- /// Returns null when any occurrence of the field falls outside an
- /// active defer chain (meaning the field belongs in the initial response).
+ /// Returns the active delivery groups for this selection after resolving
+ /// each occurrence to its nearest active ancestor and pruning child groups
+ /// whose parent is also active. Returns null when the selection
+ /// belongs to the initial result.
///
- public DeferUsage[]? GetActiveDeferUsages(ulong deferFlags)
+ public DeliveryGroup[]? GetActiveDeliveryGroups(ulong deferFlags)
{
- if (_deferUsages.Length == 0)
+ if (_deliveryGroups.Length == 0)
{
return null;
}
- if (_deferUsages.Length == 1)
+ if (_deliveryGroups.Length == 1)
{
- var active = ResolveActiveAncestor(_deferUsages[0], deferFlags);
+ var active = ResolveActiveAncestor(_deliveryGroups[0], deferFlags);
return active is null ? null : [active];
}
- DeferUsage[]? result = null;
+ DeliveryGroup[]? result = null;
var count = 0;
- for (var i = 0; i < _deferUsages.Length; i++)
+ for (var i = 0; i < _deliveryGroups.Length; i++)
{
- var effective = ResolveActiveAncestor(_deferUsages[i], deferFlags);
+ var effective = ResolveActiveAncestor(_deliveryGroups[i], deferFlags);
if (effective is null)
{
@@ -221,7 +241,7 @@ internal void Seal(SelectionSet selectionSet)
if (!duplicate)
{
- result ??= new DeferUsage[_deferUsages.Length];
+ result ??= new DeliveryGroup[_deliveryGroups.Length];
result[count++] = effective;
}
}
@@ -269,23 +289,23 @@ internal void Seal(SelectionSet selectionSet)
}
///
- /// Determines whether is among this selection's
- /// active defer usages under the runtime
- /// (using the same parent-chain walk and parent-child pruning as
- /// ).
+ /// Determines whether is the nearest active
+ /// delivery group for any occurrence of this selection under the specified
+ /// . Returns false if any occurrence
+ /// belongs to the initial result.
///
- public bool HasActiveDeferUsage(ulong deferFlags, DeferUsage target)
+ public bool HasActiveDeliveryGroup(ulong deferFlags, DeliveryGroup target)
{
- if (_deferUsages.Length == 0)
+ if (_deliveryGroups.Length == 0)
{
return false;
}
var found = false;
- for (var i = 0; i < _deferUsages.Length; i++)
+ for (var i = 0; i < _deliveryGroups.Length; i++)
{
- var effective = ResolveActiveAncestor(_deferUsages[i], deferFlags);
+ var effective = ResolveActiveAncestor(_deliveryGroups[i], deferFlags);
if (effective is null)
{
@@ -302,24 +322,21 @@ public bool HasActiveDeferUsage(ulong deferFlags, DeferUsage target)
return found;
}
- // Walks up the @defer parent chain and returns the first one that is
- // actually turned on for this request (its bit in deferFlags is set).
- // A nested @defer whose own `if:` is false falls back to its enclosing
- // @defer. If none on the chain are active, returns null, meaning the
- // field is not deferred at this occurrence.
+ // Returns the nearest active delivery group in the @defer parent chain.
+ // If none are active, the field occurrence belongs to the initial result.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static DeferUsage? ResolveActiveAncestor(DeferUsage start, ulong deferFlags)
+ private static DeliveryGroup? ResolveActiveAncestor(DeliveryGroup start, ulong deferFlags)
{
- var usage = start;
+ var deliveryGroup = start;
- while (usage is not null)
+ while (deliveryGroup is not null)
{
- if ((deferFlags & (1UL << usage.DeferConditionIndex)) != 0)
+ if ((deferFlags & (1UL << deliveryGroup.DeferConditionIndex)) != 0)
{
- return usage;
+ return deliveryGroup;
}
- usage = usage.Parent;
+ deliveryGroup = deliveryGroup.Parent;
}
return null;
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs
index 52551385c67..79f5acd2f77 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanFormatter.cs
@@ -85,7 +85,7 @@ public void Format(IBufferWriter writer, OperationPlan plan, OperationPlan
WriteNodes(jsonWriter, plan.Operation, plan.AllNodes, trace);
WriteDeliveryGroups(jsonWriter, plan.DeliveryGroups);
- WriteDeferredSubPlans(jsonWriter, plan.DeferredSubPlans);
+ WriteIncrementalPlans(jsonWriter, plan.IncrementalPlans);
jsonWriter.WriteEndObject();
}
@@ -172,7 +172,7 @@ private static void WriteNodes(
private static void WriteDeliveryGroups(
JsonWriter jsonWriter,
- ImmutableArray deliveryGroups)
+ ImmutableArray deliveryGroups)
{
if (deliveryGroups.IsDefaultOrEmpty)
{
@@ -192,7 +192,7 @@ private static void WriteDeliveryGroups(
private static void WriteDeliveryGroup(
JsonWriter jsonWriter,
- DeferUsage deliveryGroup)
+ DeliveryGroup deliveryGroup)
{
jsonWriter.WriteStartObject();
@@ -223,36 +223,36 @@ private static void WriteDeliveryGroup(
jsonWriter.WriteEndObject();
}
- private static void WriteDeferredSubPlans(
+ private static void WriteIncrementalPlans(
JsonWriter jsonWriter,
- ImmutableArray deferredSubPlans)
+ ImmutableArray incrementalPlans)
{
- if (deferredSubPlans.IsDefaultOrEmpty)
+ if (incrementalPlans.IsDefaultOrEmpty)
{
return;
}
- jsonWriter.WritePropertyName("deferredSubPlans");
+ jsonWriter.WritePropertyName("incrementalPlans");
jsonWriter.WriteStartArray();
- foreach (var subPlan in deferredSubPlans)
+ foreach (var incrementalPlan in incrementalPlans)
{
- WriteDeferredSubPlan(jsonWriter, subPlan);
+ WriteIncrementalPlan(jsonWriter, incrementalPlan);
}
jsonWriter.WriteEndArray();
}
- private static void WriteDeferredSubPlan(
+ private static void WriteIncrementalPlan(
JsonWriter jsonWriter,
- ExecutionSubPlan subPlan)
+ IncrementalPlan incrementalPlan)
{
jsonWriter.WriteStartObject();
jsonWriter.WritePropertyName("deliveryGroupIds");
jsonWriter.WriteStartArray();
- foreach (var deliveryGroup in subPlan.DeliveryGroups)
+ foreach (var deliveryGroup in incrementalPlan.DeliveryGroups)
{
jsonWriter.WriteNumberValue(deliveryGroup.Id);
}
@@ -260,20 +260,52 @@ private static void WriteDeferredSubPlan(
jsonWriter.WriteEndArray();
jsonWriter.WritePropertyName("parentNodeId");
- jsonWriter.WriteNumberValue(subPlan.ParentNodeId);
+ jsonWriter.WriteNumberValue(incrementalPlan.ParentNodeId);
+
+ if (!incrementalPlan.Requirements.IsDefaultOrEmpty)
+ {
+ jsonWriter.WritePropertyName("requirements");
+ jsonWriter.WriteStartArray();
+
+ foreach (var requirement in incrementalPlan.Requirements)
+ {
+ WriteRequirement(jsonWriter, requirement);
+ }
+
+ jsonWriter.WriteEndArray();
+ }
jsonWriter.WritePropertyName("operation");
- WriteOperation(jsonWriter, subPlan.Operation);
+ WriteOperation(jsonWriter, incrementalPlan.Operation);
- if (!subPlan.AllNodes.IsDefaultOrEmpty)
+ if (!incrementalPlan.AllNodes.IsDefaultOrEmpty)
{
jsonWriter.WritePropertyName("nodes");
- WriteNodes(jsonWriter, subPlan.Operation, subPlan.AllNodes, null);
+ WriteNodes(jsonWriter, incrementalPlan.Operation, incrementalPlan.AllNodes, null);
}
jsonWriter.WriteEndObject();
}
+ private static void WriteRequirement(JsonWriter jsonWriter, OperationRequirement requirement)
+ {
+ jsonWriter.WriteStartObject();
+
+ jsonWriter.WritePropertyName("name");
+ jsonWriter.WriteStringValue(requirement.Key);
+
+ jsonWriter.WritePropertyName("type");
+ jsonWriter.WriteStringValue(requirement.Type.ToString());
+
+ jsonWriter.WritePropertyName("path");
+ jsonWriter.WriteStringValue(requirement.Path.ToString());
+
+ jsonWriter.WritePropertyName("selectionMap");
+ jsonWriter.WriteStringValue(requirement.Map.ToString());
+
+ jsonWriter.WriteEndObject();
+ }
+
private static void WriteOperationNode(
JsonWriter jsonWriter,
Operation operation,
@@ -336,21 +368,7 @@ private static void WriteOperationNode(
foreach (var requirement in node.Requirements)
{
- jsonWriter.WriteStartObject();
-
- jsonWriter.WritePropertyName("name");
- jsonWriter.WriteStringValue(requirement.Key);
-
- jsonWriter.WritePropertyName("type");
- jsonWriter.WriteStringValue(requirement.Type.ToString());
-
- jsonWriter.WritePropertyName("path");
- jsonWriter.WriteStringValue(requirement.Path.ToString());
-
- jsonWriter.WritePropertyName("selectionMap");
- jsonWriter.WriteStringValue(requirement.Map.ToString());
-
- jsonWriter.WriteEndObject();
+ WriteRequirement(jsonWriter, requirement);
}
jsonWriter.WriteEndArray();
@@ -377,7 +395,7 @@ private static void WriteOperationNode(
jsonWriter.WriteBooleanValue(true);
}
- if (node.Dependencies.Length > 0)
+ if (node.Dependencies.Length > 0 || node.ParentDependencies.Length > 0)
{
jsonWriter.WritePropertyName("dependencies");
jsonWriter.WriteStartArray();
@@ -387,6 +405,11 @@ private static void WriteOperationNode(
jsonWriter.WriteNumberValue(dependency.Id);
}
+ foreach (var parentStepId in node.ParentDependencies)
+ {
+ WriteParentDependency(jsonWriter, parentStepId);
+ }
+
jsonWriter.WriteEndArray();
}
@@ -395,6 +418,14 @@ private static void WriteOperationNode(
jsonWriter.WriteEndObject();
}
+ private static void WriteParentDependency(JsonWriter jsonWriter, int parentStepId)
+ {
+ jsonWriter.WriteStartObject();
+ jsonWriter.WritePropertyName("parentNodeId");
+ jsonWriter.WriteNumberValue(parentStepId);
+ jsonWriter.WriteEndObject();
+ }
+
private static void WriteBatchExecutionNode(
JsonWriter jsonWriter,
Operation operation,
@@ -525,7 +556,7 @@ private static void WriteOperationDefinitionAsNode(
jsonWriter.WriteBooleanValue(true);
}
- if (operationDef.Dependencies.Length > 0)
+ if (operationDef.Dependencies.Length > 0 || operationDef.ParentDependencies.Length > 0)
{
jsonWriter.WritePropertyName("dependencies");
jsonWriter.WriteStartArray();
@@ -535,6 +566,11 @@ private static void WriteOperationDefinitionAsNode(
jsonWriter.WriteNumberValue(dependency.Id);
}
+ foreach (var parentStepId in operationDef.ParentDependencies)
+ {
+ WriteParentDependency(jsonWriter, parentStepId);
+ }
+
jsonWriter.WriteEndArray();
}
@@ -613,21 +649,7 @@ private static void WriteBatchOperationDefinitionAsNode(
foreach (var requirement in operationDef.Requirements)
{
- jsonWriter.WriteStartObject();
-
- jsonWriter.WritePropertyName("name");
- jsonWriter.WriteStringValue(requirement.Key);
-
- jsonWriter.WritePropertyName("type");
- jsonWriter.WriteStringValue(requirement.Type.ToString());
-
- jsonWriter.WritePropertyName("path");
- jsonWriter.WriteStringValue(requirement.Path.ToString());
-
- jsonWriter.WritePropertyName("selectionMap");
- jsonWriter.WriteStringValue(requirement.Map.ToString());
-
- jsonWriter.WriteEndObject();
+ WriteRequirement(jsonWriter, requirement);
}
jsonWriter.WriteEndArray();
@@ -654,7 +676,7 @@ private static void WriteBatchOperationDefinitionAsNode(
jsonWriter.WriteBooleanValue(true);
}
- if (operationDef.Dependencies.Length > 0)
+ if (operationDef.Dependencies.Length > 0 || operationDef.ParentDependencies.Length > 0)
{
jsonWriter.WritePropertyName("dependencies");
jsonWriter.WriteStartArray();
@@ -664,6 +686,11 @@ private static void WriteBatchOperationDefinitionAsNode(
jsonWriter.WriteNumberValue(dependency.Id);
}
+ foreach (var parentStepId in operationDef.ParentDependencies)
+ {
+ WriteParentDependency(jsonWriter, parentStepId);
+ }
+
jsonWriter.WriteEndArray();
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs
index cae359f0baa..36b7c97c72d 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/JsonOperationPlanParser.cs
@@ -52,18 +52,18 @@ public override OperationPlan Parse(ReadOnlyMemory planSourceText)
var nodes = ParseNodes(rootElement.GetProperty("nodes"), operation);
- var deliveryGroups = ImmutableArray.Empty;
- var deferredSubPlans = ImmutableArray.Empty;
- var deliveryGroupMap = new Dictionary();
+ var deliveryGroups = ImmutableArray.Empty;
+ var incrementalPlans = ImmutableArray.Empty;
+ var deliveryGroupMap = new Dictionary();
if (rootElement.TryGetProperty("deliveryGroups", out var deliveryGroupsElement))
{
deliveryGroups = ParseDeliveryGroups(deliveryGroupsElement, deliveryGroupMap);
}
- if (rootElement.TryGetProperty("deferredSubPlans", out var deferredSubPlansElement))
+ if (rootElement.TryGetProperty("incrementalPlans", out var incrementalPlansElement))
{
- deferredSubPlans = ParseDeferredSubPlans(deferredSubPlansElement, deliveryGroupMap);
+ incrementalPlans = ParseIncrementalPlans(incrementalPlansElement, deliveryGroupMap);
}
// Root nodes are the entry points of the execution plan. A node is a
@@ -75,19 +75,19 @@ public override OperationPlan Parse(ReadOnlyMemory planSourceText)
[.. nodes.Where(n => n.Dependencies.Length == 0 && n.OptionalDependencies.Length == 0)],
nodes,
deliveryGroups,
- deferredSubPlans,
+ incrementalPlans,
searchSpace,
expandedNodes);
}
- private static ImmutableArray ParseDeliveryGroups(
+ private static ImmutableArray ParseDeliveryGroups(
JsonElement deliveryGroupsElement,
- Dictionary deliveryGroupMap)
+ Dictionary deliveryGroupMap)
{
- // Phase 1: Construct every DeferUsage without resolving parent references.
+ // Phase 1: Construct every DeliveryGroup without resolving parent references.
// Parents are captured as numeric ids for the second pass because a parent
// may appear after its child in the serialized array.
- var ordered = new List<(DeferUsage Usage, int? ParentId)>();
+ var ordered = new List<(DeliveryGroup Usage, int? ParentId)>();
foreach (var groupElement in deliveryGroupsElement.EnumerateArray())
{
@@ -114,7 +114,7 @@ private static ImmutableArray ParseDeliveryGroups(
parentId = parentIdElement.GetInt32();
}
- var deliveryGroup = new DeferUsage(label, Parent: null, DeferConditionIndex: 0)
+ var deliveryGroup = new DeliveryGroup(label, Parent: null, DeferConditionIndex: 0)
{
Id = deferId,
Path = path,
@@ -125,11 +125,11 @@ private static ImmutableArray ParseDeliveryGroups(
deliveryGroupMap[deferId] = deliveryGroup;
}
- // Phase 2: Resolve every parent id against the map and rebuild the records
- // so their Parent references point at the final, canonical instances.
- // Update the map in place so downstream subplan parsing and the returned
- // array both observe the same DeferUsage instances.
- var builder = ImmutableArray.CreateBuilder(ordered.Count);
+ // Phase 2: Resolve every parent id against the map and rebuild the
+ // records so their Parent references point at the canonical instances.
+ // Update the map so incremental plans and the returned collection share
+ // the same DeliveryGroup instances.
+ var builder = ImmutableArray.CreateBuilder(ordered.Count);
foreach (var (usage, parentId) in ordered)
{
@@ -144,42 +144,66 @@ private static ImmutableArray ParseDeliveryGroups(
return builder.MoveToImmutable();
}
- private ImmutableArray ParseDeferredSubPlans(
- JsonElement deferredSubPlansElement,
- Dictionary deliveryGroupMap)
+ private ImmutableArray ParseIncrementalPlans(
+ JsonElement incrementalPlansElement,
+ Dictionary deliveryGroupMap)
{
- var builder = ImmutableArray.CreateBuilder();
+ var builder = ImmutableArray.CreateBuilder();
- foreach (var subPlanElement in deferredSubPlansElement.EnumerateArray())
+ foreach (var incrementalPlanElement in incrementalPlansElement.EnumerateArray())
{
- var deliveryGroupIdsElement = subPlanElement.GetProperty("deliveryGroupIds");
- var subPlanDeliveryGroupsBuilder = ImmutableArray.CreateBuilder();
+ var deliveryGroupIdsElement = incrementalPlanElement.GetProperty("deliveryGroupIds");
+ var incrementalPlanDeliveryGroupsBuilder = ImmutableArray.CreateBuilder();
foreach (var idElement in deliveryGroupIdsElement.EnumerateArray())
{
- subPlanDeliveryGroupsBuilder.Add(deliveryGroupMap[idElement.GetInt32()]);
+ incrementalPlanDeliveryGroupsBuilder.Add(deliveryGroupMap[idElement.GetInt32()]);
}
- var subPlanOperation = ParseOperation(subPlanElement.GetProperty("operation"));
+ var incrementalPlanOperation = ParseOperation(incrementalPlanElement.GetProperty("operation"));
- var subPlanNodes = subPlanElement.TryGetProperty("nodes", out var subPlanNodesElement)
- ? ParseNodes(subPlanNodesElement, subPlanOperation)
+ var incrementalPlanNodes = incrementalPlanElement.TryGetProperty("nodes", out var incrementalPlanNodesElement)
+ ? ParseNodes(incrementalPlanNodesElement, incrementalPlanOperation)
: [];
- var rootSubPlanNodes = subPlanNodes
+ var rootIncrementalPlanNodes = incrementalPlanNodes
.Where(n => n.Dependencies.Length == 0 && n.OptionalDependencies.Length == 0)
.ToImmutableArray();
- var subPlan = new ExecutionSubPlan(
- subPlanOperation,
- rootSubPlanNodes,
- subPlanNodes,
- subPlanDeliveryGroupsBuilder.ToImmutable())
+ var incrementalPlanRequirements = ImmutableArray.Empty;
+
+ if (incrementalPlanElement.TryGetProperty("requirements", out var requirementsElement))
{
- ParentNodeId = subPlanElement.GetProperty("parentNodeId").GetInt32()
+ var requirementsBuilder = ImmutableArray.CreateBuilder();
+
+ foreach (var requirementElement in requirementsElement.EnumerateArray())
+ {
+ var requirementName = requirementElement.GetProperty("name").GetString()!;
+ var requirementType = requirementElement.GetProperty("type").GetString()!;
+ var requirementPath = requirementElement.GetProperty("path").GetString()!;
+ var selectionMap = requirementElement.GetProperty("selectionMap").GetString()!;
+
+ requirementsBuilder.Add(new OperationRequirement(
+ requirementName,
+ Utf8GraphQLParser.Syntax.ParseTypeReference(requirementType),
+ SelectionPath.Parse(requirementPath),
+ FieldSelectionMapParser.Parse(selectionMap)));
+ }
+
+ incrementalPlanRequirements = requirementsBuilder.ToImmutable();
+ }
+
+ var incrementalPlan = new IncrementalPlan(
+ incrementalPlanOperation,
+ rootIncrementalPlanNodes,
+ incrementalPlanNodes,
+ incrementalPlanDeliveryGroupsBuilder.ToImmutable(),
+ incrementalPlanRequirements)
+ {
+ ParentNodeId = incrementalPlanElement.GetProperty("parentNodeId").GetInt32()
};
- builder.Add(subPlan);
+ builder.Add(incrementalPlan);
}
return builder.ToImmutable();
@@ -464,7 +488,7 @@ private static ParsedOperationNodeInfo ParseOperationNodeInfo(
JsonElement nodeElement, int id, ISchemaDefinition schema)
{
var (schemaName, opSource, source, requirements, forwardedVariables,
- resultSelectionSet, dependencies, batchingGroupId, conditions,
+ resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions,
requiresFileUpload) = ParseCommonOperationFields(nodeElement, schema);
SelectionPath? target = null;
@@ -485,6 +509,7 @@ private static ParsedOperationNodeInfo ParseOperationNodeInfo(
ForwardedVariables = forwardedVariables ?? [],
ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema),
Dependencies = dependencies,
+ ParentDependencies = parentDependencies,
BatchingGroupId = batchingGroupId,
Conditions = conditions,
RequiresFileUpload = requiresFileUpload,
@@ -496,7 +521,7 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo(
JsonElement nodeElement, int id, ISchemaDefinition schema)
{
var (schemaName, opSource, source, requirements, forwardedVariables,
- resultSelectionSet, dependencies, batchingGroupId, conditions,
+ resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions,
requiresFileUpload) = ParseCommonOperationFields(nodeElement, schema);
var targets = nodeElement.TryGetProperty("targets", out var targetsElement)
@@ -514,6 +539,7 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo(
ForwardedVariables = forwardedVariables ?? [],
ResultSelectionSet = ResultSelectionSet.Create(resultSelectionSet!, schema),
Dependencies = dependencies,
+ ParentDependencies = parentDependencies,
BatchingGroupId = batchingGroupId,
Conditions = conditions,
RequiresFileUpload = requiresFileUpload,
@@ -523,8 +549,8 @@ private static ParsedOperationNodeInfo ParseOperationBatchNodeInfo(
private static (string? schemaName, OperationSourceText opSource, SelectionPath? source,
List? requirements, string[]? forwardedVariables,
- SelectionSetNode? resultSelectionSet, int[]? dependencies, int? batchingGroupId,
- ExecutionNodeCondition[] conditions, bool requiresFileUpload)
+ SelectionSetNode? resultSelectionSet, int[]? dependencies, int[]? parentDependencies,
+ int? batchingGroupId, ExecutionNodeCondition[] conditions, bool requiresFileUpload)
ParseCommonOperationFields(JsonElement nodeElement, ISchemaDefinition _)
{
string? schemaName = null;
@@ -546,6 +572,7 @@ private static (string? schemaName, OperationSourceText opSource, SelectionPath?
string[]? forwardedVariables = null;
SelectionSetNode? resultSelectionSet = null;
int[]? dependencies = null;
+ int[]? parentDependencies = null;
int? batchingGroupId = null;
if (nodeElement.TryGetProperty("source", out var sourceElement))
@@ -593,10 +620,31 @@ private static (string? schemaName, OperationSourceText opSource, SelectionPath?
if (nodeElement.TryGetProperty("dependencies", out var dependenciesElement))
{
- dependencies = dependenciesElement
- .EnumerateArray()
- .Select(e => e.GetInt32())
- .ToArray();
+ List? intDeps = null;
+ List? parentDeps = null;
+
+ foreach (var dependencyElement in dependenciesElement.EnumerateArray())
+ {
+ switch (dependencyElement.ValueKind)
+ {
+ case JsonValueKind.Number:
+ intDeps ??= [];
+ intDeps.Add(dependencyElement.GetInt32());
+ break;
+
+ case JsonValueKind.Object:
+ parentDeps ??= [];
+ parentDeps.Add(dependencyElement.GetProperty("parentNodeId").GetInt32());
+ break;
+
+ default:
+ throw new InvalidOperationException(
+ $"Unsupported dependency element kind: {dependencyElement.ValueKind}.");
+ }
+ }
+
+ dependencies = intDeps?.ToArray();
+ parentDependencies = parentDeps?.ToArray();
}
if (nodeElement.TryGetProperty("batchingGroupId", out var batchingGroupIdElement))
@@ -610,7 +658,7 @@ private static (string? schemaName, OperationSourceText opSource, SelectionPath?
&& requiresFileUploadElement.ValueKind == JsonValueKind.True;
return (schemaName, opSource, source, requirements, forwardedVariables,
- resultSelectionSet, dependencies, batchingGroupId, conditions, requiresFileUpload);
+ resultSelectionSet, dependencies, parentDependencies, batchingGroupId, conditions, requiresFileUpload);
}
private static ParsedNodeInfo ParseIntrospectionNodeInfo(
@@ -744,6 +792,7 @@ private abstract class ParsedOperationNodeInfo : ParsedNodeInfo
public string[] ForwardedVariables { get; init; } = [];
public required ResultSelectionSet ResultSelectionSet { get; init; }
public int? BatchingGroupId { get; init; }
+ public int[]? ParentDependencies { get; init; }
public ExecutionNodeCondition[] Conditions { get; init; } = [];
public bool RequiresFileUpload { get; init; }
public required ISchemaDefinition Schema { get; init; }
@@ -757,7 +806,7 @@ private sealed class ParsedSingleOperationNodeInfo : ParsedOperationNodeInfo
public override OperationDefinition ToOperationDefinition()
{
- return new SingleOperationDefinition(
+ var definition = new SingleOperationDefinition(
Id,
OperationSource,
SchemaName,
@@ -768,6 +817,16 @@ public override OperationDefinition ToOperationDefinition()
ResultSelectionSet,
Conditions,
RequiresFileUpload);
+
+ if (ParentDependencies is not null)
+ {
+ foreach (var parentId in ParentDependencies)
+ {
+ definition.AddParentDependency(parentId);
+ }
+ }
+
+ return definition;
}
public override (ExecutionNode, int[]?, Dictionary?, int?) ToExecutionNodeTuple()
@@ -784,6 +843,14 @@ public override (ExecutionNode, int[]?, Dictionary?, int?) ToExecut
Conditions,
RequiresFileUpload);
+ if (ParentDependencies is not null)
+ {
+ foreach (var parentId in ParentDependencies)
+ {
+ node.AddParentDependency(parentId);
+ }
+ }
+
return (node, Dependencies, null, null);
}
}
@@ -794,7 +861,7 @@ private sealed class ParsedBatchOperationNodeInfo : ParsedOperationNodeInfo
public override OperationDefinition ToOperationDefinition()
{
- return new BatchOperationDefinition(
+ var definition = new BatchOperationDefinition(
Id,
OperationSource,
SchemaName,
@@ -805,6 +872,16 @@ public override OperationDefinition ToOperationDefinition()
ResultSelectionSet,
Conditions,
RequiresFileUpload);
+
+ if (ParentDependencies is not null)
+ {
+ foreach (var parentId in ParentDependencies)
+ {
+ definition.AddParentDependency(parentId);
+ }
+ }
+
+ return definition;
}
public override (ExecutionNode, int[]?, Dictionary?, int?) ToExecutionNodeTuple()
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs
index 1e85910beba..f016736e5d8 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Serialization/YamlOperationPlanFormatter.cs
@@ -44,14 +44,14 @@ public override string Format(OperationPlan plan, OperationPlanTrace? trace = nu
writer.Unindent();
}
- if (!plan.DeferredSubPlans.IsDefaultOrEmpty)
+ if (!plan.IncrementalPlans.IsDefaultOrEmpty)
{
- writer.WriteLine("deferredSubPlans:");
+ writer.WriteLine("incrementalPlans:");
writer.Indent();
- foreach (var subPlan in plan.DeferredSubPlans)
+ foreach (var incrementalPlan in plan.IncrementalPlans)
{
- WriteDeferredSubPlan(subPlan, writer);
+ WriteIncrementalPlan(incrementalPlan, writer);
}
writer.Unindent();
@@ -82,7 +82,7 @@ private static void WriteNode(ExecutionNode node, ExecutionNodeTrace? nodeTrace,
}
}
- private static void WriteDeliveryGroup(DeferUsage deliveryGroup, CodeWriter writer)
+ private static void WriteDeliveryGroup(DeliveryGroup deliveryGroup, CodeWriter writer)
{
writer.WriteLine("- id: {0}", deliveryGroup.Id);
writer.Indent();
@@ -107,27 +107,32 @@ private static void WriteDeliveryGroup(DeferUsage deliveryGroup, CodeWriter writ
writer.Unindent();
}
- private static void WriteDeferredSubPlan(ExecutionSubPlan subPlan, CodeWriter writer)
+ private static void WriteIncrementalPlan(IncrementalPlan incrementalPlan, CodeWriter writer)
{
writer.WriteLine("- deliveryGroupIds:");
writer.Indent();
writer.Indent();
- foreach (var deliveryGroup in subPlan.DeliveryGroups)
+ foreach (var deliveryGroup in incrementalPlan.DeliveryGroups)
{
writer.WriteLine("- {0}", deliveryGroup.Id);
}
writer.Unindent();
- writer.WriteLine("parentNodeId: {0}", subPlan.ParentNodeId);
+ writer.WriteLine("parentNodeId: {0}", incrementalPlan.ParentNodeId);
- if (!subPlan.AllNodes.IsDefaultOrEmpty)
+ if (!incrementalPlan.Requirements.IsDefaultOrEmpty)
+ {
+ WriteRequirements(incrementalPlan.Requirements.AsSpan(), writer);
+ }
+
+ if (!incrementalPlan.AllNodes.IsDefaultOrEmpty)
{
writer.WriteLine("nodes:");
writer.Indent();
- foreach (var node in subPlan.AllNodes)
+ foreach (var node in incrementalPlan.AllNodes)
{
WriteNode(node, nodeTrace: null, writer);
}
@@ -269,7 +274,7 @@ private static void WriteOperationNode(OperationExecutionNode node, ExecutionNod
writer.WriteLine("requiresFileUpload: true");
}
- if (node.Dependencies.Length > 0)
+ if (node.Dependencies.Length > 0 || node.ParentDependencies.Length > 0)
{
writer.WriteLine("dependencies:");
writer.Indent();
@@ -278,6 +283,11 @@ private static void WriteOperationNode(OperationExecutionNode node, ExecutionNod
writer.WriteLine("- id: {0}", dependency.Id);
}
+ foreach (var parentStepId in node.ParentDependencies)
+ {
+ writer.WriteLine("- parentNodeId: {0}", parentStepId);
+ }
+
writer.Unindent();
}
@@ -351,7 +361,7 @@ private static void WriteOperationDefinitionAsNode(
writer.WriteLine("requiresFileUpload: true");
}
- WriteDependencies(opDef.Dependencies, writer);
+ WriteDependencies(opDef.Dependencies, opDef.ParentDependencies, writer);
TryWriteNodeTrace(writer, trace);
writer.Unindent();
@@ -411,7 +421,7 @@ private static void WriteBatchOperationDefinitionAsNode(
writer.WriteLine("requiresFileUpload: true");
}
- WriteDependencies(opDef.Dependencies, writer);
+ WriteDependencies(opDef.Dependencies, opDef.ParentDependencies, writer);
TryWriteNodeTrace(writer, trace);
writer.Unindent();
@@ -480,20 +490,30 @@ private static void WriteForwardedVariables(ReadOnlySpan forwardedVariab
}
}
- private static void WriteDependencies(ReadOnlySpan dependencies, CodeWriter writer)
+ private static void WriteDependencies(
+ ReadOnlySpan dependencies,
+ ReadOnlySpan parentDependencies,
+ CodeWriter writer)
{
- if (dependencies.Length > 0)
+ if (dependencies.Length == 0 && parentDependencies.Length == 0)
{
- writer.WriteLine("dependencies:");
- writer.Indent();
+ return;
+ }
- foreach (var dependency in dependencies)
- {
- writer.WriteLine("- id: {0}", dependency.Id);
- }
+ writer.WriteLine("dependencies:");
+ writer.Indent();
- writer.Unindent();
+ foreach (var dependency in dependencies)
+ {
+ writer.WriteLine("- id: {0}", dependency.Id);
}
+
+ foreach (var parentStepId in parentDependencies)
+ {
+ writer.WriteLine("- parentNodeId: {0}", parentStepId);
+ }
+
+ writer.Unindent();
}
private static void WriteIntrospectionNode(IntrospectionExecutionNode node, ExecutionNodeTrace? trace, CodeWriter writer)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs
index d42f2de3935..da65ce52907 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.Pooling.cs
@@ -23,7 +23,7 @@ internal OperationPlanContext(
internal void Initialize(
RequestContext requestContext,
IVariableValueCollection variables,
- OperationPlan operationPlan,
+ IOperationPlan operationPlan,
CancellationTokenSource cancellationTokenSource)
{
ArgumentNullException.ThrowIfNull(requestContext);
@@ -79,6 +79,8 @@ internal void Clean()
OperationPlan = default!;
DeferFlags = 0;
_clientScope = default!;
+ _requirementValues = default;
+ _requirementKeys = null;
Traces =
#if NET10_0_OR_GREATER
[];
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs
index b5e5d430be9..8ec0c2c9c9f 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs
@@ -33,6 +33,8 @@ public sealed partial class OperationPlanContext : IFeatureProvider, IAsyncDispo
private List?[] _skippedDefinitions = [];
private readonly IFusionExecutionDiagnosticEvents _diagnosticEvents;
private readonly FetchResultStore _resultStore;
+ private ImmutableArray _requirementValues;
+ private HashSet? _requirementKeys;
private readonly ExecutionState _executionState;
private readonly INodeIdParser _nodeIdParser;
private readonly IErrorHandler _errorHandler;
@@ -47,7 +49,7 @@ public sealed partial class OperationPlanContext : IFeatureProvider, IAsyncDispo
///
/// Gets the operation plan being executed.
///
- public OperationPlan OperationPlan { get; private set; } = default!;
+ public IOperationPlan OperationPlan { get; private set; } = default!;
///
/// Gets the coerced variable values for the current request.
@@ -260,8 +262,34 @@ internal ImmutableArray CreateVariableValueSets(
}
else
{
- var variableValues = GetPathThroughVariables(forwardedVariables);
- return _resultStore.CreateVariableValueSets(selectionSet, variableValues, requirements);
+ var importedMatchCount = CountImportedRequirementKeys(requirements);
+
+ if (importedMatchCount == 0)
+ {
+ var variableValues = GetPathThroughVariables(forwardedVariables);
+ return _resultStore.CreateVariableValueSets(selectionSet, variableValues, requirements);
+ }
+
+ if (importedMatchCount != requirements.Length)
+ {
+ // Planner-invariant guard. CollectParentScopeRequirements makes _requirementKeys
+ // the union of every parent-dependent node's full requirement list, so a single
+ // node's requirements are either all imported or none imported. A partial overlap
+ // means the planner produced a shape this routing layer is not built to handle.
+ throw CreateMixedScopeException(requirements);
+ }
+
+ if (forwardedVariables.Length == 0 && requirements.Length == _requirementKeys!.Count)
+ {
+ return _requirementValues;
+ }
+
+ var variableValuesFromSnapshot = GetPathThroughVariables(forwardedVariables);
+ return _resultStore.CreateVariableValueSetsFromSnapshot(
+ _requirementValues,
+ _requirementKeys!,
+ variableValuesFromSnapshot,
+ requirements);
}
}
@@ -282,9 +310,124 @@ internal ImmutableArray CreateVariableValueSets(
}
else
{
- var variableValues = GetPathThroughVariables(forwardedVariables);
- return _resultStore.CreateVariableValueSets(selectionSets, variableValues, requiredData);
+ var importedMatchCount = CountImportedRequirementKeys(requiredData);
+
+ if (importedMatchCount == 0)
+ {
+ var variableValues = GetPathThroughVariables(forwardedVariables);
+ return _resultStore.CreateVariableValueSets(selectionSets, variableValues, requiredData);
+ }
+
+ if (importedMatchCount != requiredData.Length)
+ {
+ // Planner-invariant guard. CollectParentScopeRequirements makes _requirementKeys
+ // the union of every parent-dependent node's full requirement list, so a single
+ // node's requirements are either all imported or none imported. A partial overlap
+ // means the planner produced a shape this routing layer is not built to handle.
+ throw CreateMixedScopeException(requiredData);
+ }
+
+ if (forwardedVariables.Length == 0 && requiredData.Length == _requirementKeys!.Count)
+ {
+ return _requirementValues;
+ }
+
+ var variableValuesFromSnapshot = GetPathThroughVariables(forwardedVariables);
+ return _resultStore.CreateVariableValueSetsFromSnapshot(
+ _requirementValues,
+ _requirementKeys!,
+ variableValuesFromSnapshot,
+ requiredData);
+ }
+ }
+
+ private InvalidOperationException CreateMixedScopeException(
+ ReadOnlySpan requirements)
+ {
+ var imported = new List();
+ var local = new List();
+
+ foreach (var requirement in requirements)
+ {
+ if (_requirementKeys!.Contains(requirement.Key))
+ {
+ imported.Add(requirement.Key);
+ }
+ else
+ {
+ local.Add(requirement.Key);
+ }
}
+
+ return new InvalidOperationException(
+ "A deferred incremental plan fetch references a mix of imported parent-sourced and local "
+ + "requirement keys. The planner is expected to keep these scopes separate so that "
+ + "each fetch sources its requirements from a single scope. Imported parent keys: ["
+ + string.Join(", ", imported)
+ + "]. Local requested keys: ["
+ + string.Join(", ", local)
+ + "].");
+ }
+
+ private int CountImportedRequirementKeys(ReadOnlySpan requirements)
+ {
+ if (_requirementKeys is null || _requirementValues.IsDefaultOrEmpty)
+ {
+ return 0;
+ }
+
+ var count = 0;
+
+ foreach (var requirement in requirements)
+ {
+ if (_requirementKeys.Contains(requirement.Key))
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// Sets parent-scope requirement values for this context. Values are copied
+ /// into this context and can be resolved without accessing the enclosing
+ /// plan scope.
+ ///
+ internal void SetRequirements(
+ ImmutableArray parentValues,
+ HashSet keys)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+
+ if (parentValues.IsDefaultOrEmpty || keys.Count == 0)
+ {
+ return;
+ }
+
+ _requirementValues = _resultStore.ImportVariableValues(parentValues);
+ _requirementKeys = keys;
+ }
+
+ ///
+ /// Gets the result store used to resolve requirements for child
+ /// incremental plans.
+ ///
+ internal FetchResultStore GetResultStoreForChildDefer() => _resultStore;
+
+ ///
+ /// Transfers retained result resources to the supplied
+ /// .
+ ///
+ internal void TransferRetainedMemoryTo(OperationResult operationResult)
+ {
+ var memoryOwners = _resultStore.MemoryOwners;
+ foreach (var disposable in memoryOwners)
+ {
+ operationResult.RegisterForCleanup(disposable);
+ }
+
+ memoryOwners.Clear();
}
private CompactPath ToResultPath(SelectionPath selectionSet)
@@ -418,7 +561,7 @@ internal void Begin(long? start = null, string? traceId = null)
}
}
- internal OperationResult Complete(bool reusable = false)
+ internal OperationResult Complete(bool reusable = false, bool retainMemoryForDefer = false)
{
_resultStore.FinalizePocketedErrors();
@@ -436,31 +579,39 @@ internal OperationResult Complete(bool reusable = false)
: null;
var resultDocument = _resultStore.Result;
+
+ // Deferred responses keep result resources available for incremental
+ // plans. If no delivery groups remain active, ownership is transferred
+ // to the completed result.
var operationResult = new OperationResult(
new OperationResultData(
resultDocument,
resultDocument.Data.IsNullOrInvalidated,
resultDocument,
- resultDocument),
+ retainMemoryForDefer ? null : resultDocument),
_resultStore.Errors?.ToImmutableList());
- // we take over the memory owners from the result context
- // and store them on the response so that the server can
- // dispose them when it disposes of the result itself.
- var memoryOwners = _resultStore.MemoryOwners;
- foreach (var disposable in memoryOwners)
+ if (!retainMemoryForDefer)
{
- operationResult.RegisterForCleanup(disposable);
- }
+ // we take over the memory owners from the result context
+ // and store them on the response so that the server can
+ // dispose them when it disposes of the result itself.
+ var memoryOwners = _resultStore.MemoryOwners;
+ foreach (var disposable in memoryOwners)
+ {
+ operationResult.RegisterForCleanup(disposable);
+ }
- memoryOwners.Clear();
+ memoryOwners.Clear();
+ }
operationResult.Features.Set(OperationPlan);
- if (RequestContext.ContextData.ContainsKey(ExecutionContextData.IncludeOperationPlan))
+ if (OperationPlan is OperationPlan rootPlan
+ && RequestContext.ContextData.ContainsKey(ExecutionContextData.IncludeOperationPlan))
{
var writer = new PooledArrayWriter();
- s_planFormatter.Format(writer, OperationPlan, trace);
+ s_planFormatter.Format(writer, rootPlan, trace);
var value = new RawJsonValue(writer.WrittenMemory);
operationResult.Extensions = operationResult.Extensions.SetItem(
"fusion",
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs
index 3e366ee9e9b..b65bdd9b92b 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs
@@ -4,6 +4,7 @@
using System.Threading.Channels;
using HotChocolate.Execution;
using HotChocolate.Fusion.Execution.Nodes;
+using HotChocolate.Fusion.Execution.Results;
using HotChocolate.Fusion.Text.Json;
using HotChocolate.Language;
using Microsoft.Extensions.DependencyInjection;
@@ -82,12 +83,13 @@ public static async Task ExecuteWithDeferAsync(
cancellationToken.ThrowIfCancellationRequested();
- // Build the initial result.
- var initialResult = context.Complete();
+ // Complete the initial result while retaining data needed by active
+ // incremental plans.
+ var initialResult = context.Complete(retainMemoryForDefer: true);
// Compute the active delivery groups (one per @defer occurrence whose
- // @defer(if:) evaluates to true) and the subplans that will actually run.
- // A subplan is active if at least one of its delivery groups is active.
+ // @defer(if:) evaluates to true) and the incremental plans that will actually run.
+ // An incremental plan is active if at least one of its delivery groups is active.
var activeDeliveryGroupIds = new HashSet();
foreach (var deliveryGroup in operationPlan.DeliveryGroups)
{
@@ -97,9 +99,9 @@ public static async Task ExecuteWithDeferAsync(
}
}
- // Announce every top-level active delivery group as pending on the
- // initial payload. Nested delivery groups are announced when their
- // parent's subplan completes.
+ // Mark top-level active delivery groups as pending on the initial
+ // result. Nested delivery groups are marked pending after their
+ // parent incremental plan completes.
var pendingResults = ImmutableList.CreateBuilder();
foreach (var deliveryGroup in operationPlan.DeliveryGroups)
{
@@ -124,13 +126,17 @@ public static async Task ExecuteWithDeferAsync(
if (pendingResults.Count == 0)
{
- // No active deferred subplans (all conditions were false).
+ // No active top-level delivery groups. Transfer retained
+ // result resources to the initial result.
+ context.TransferRetainedMemoryTo(initialResult);
executionCts.Dispose();
await context.DisposeAsync();
return initialResult;
}
- // Return a ResponseStream that yields the initial result then deferred results.
+ // Return a ResponseStream that yields the initial result followed
+ // by incremental results.
+ var rootContext = context;
var stream = new ResponseStream(
() => CreateIncrementalStream(
requestContext,
@@ -138,6 +144,7 @@ public static async Task ExecuteWithDeferAsync(
operationPlan,
initialResult,
activeDeliveryGroupIds,
+ rootContext,
cancellationToken),
ExecutionResultKind.DeferredResult);
@@ -164,26 +171,27 @@ private static async IAsyncEnumerable CreateIncrementalStream(
OperationPlan operationPlan,
OperationResult initialResult,
HashSet activeDeliveryGroupIds,
+ OperationPlanContext rootContext,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Yield the initial result first.
yield return initialResult;
- var deferredSubPlans = operationPlan.DeferredSubPlans;
+ var incrementalPlans = operationPlan.IncrementalPlans;
// Per-delivery-group completion tracking. A delivery group is considered
- // complete when every subplan whose DeliveryGroups contains it has
- // finished. We also track which subplans are "active" so an inactive
+ // complete when every incremental plan whose DeliveryGroups contains it has
+ // finished. We also track which incremental plans are "active" so an inactive
// @defer(if: false) group does not block completion accounting.
var pendingCountByDeliveryGroup = new Dictionary();
- foreach (var subPlan in deferredSubPlans)
+ foreach (var incrementalPlan in incrementalPlans)
{
- if (!IsSubPlanActive(subPlan, activeDeliveryGroupIds))
+ if (!IsIncrementalPlanActive(incrementalPlan, activeDeliveryGroupIds))
{
continue;
}
- foreach (var deliveryGroup in subPlan.DeliveryGroups)
+ foreach (var deliveryGroup in incrementalPlan.DeliveryGroups)
{
if (!activeDeliveryGroupIds.Contains(deliveryGroup.Id))
{
@@ -195,288 +203,403 @@ private static async IAsyncEnumerable CreateIncrementalStream(
}
}
- // A subplan starts running once every delivery group it depends on has
- // had its parent subplan dispatched. For now we keep the simpler rule
- // used previously: a subplan is top-level when its first delivery group
- // has no parent. Nested subplans are launched when their parent delivery
- // group's subplan completes.
- var started = new HashSet();
- var channel = Channel.CreateUnbounded<(ExecutionSubPlan SubPlan, OperationResult? Result, Exception? Error)>();
- var pendingSubPlanCount = 0;
+ // Active top-level incremental plans start immediately. Nested
+ // incremental plans start when their parent delivery group has completed.
+ //
+ // Keep completed parent contexts available while nested incremental
+ // plans are starting. Stored contexts are disposed when the iterator
+ // finishes.
+ var started = new HashSet();
+ var incrementalPlanContexts = new Dictionary();
+ var channel = Channel.CreateUnbounded();
+ var pendingIncrementalPlanCount = 0;
- foreach (var subPlan in deferredSubPlans)
+ try
{
- if (!IsSubPlanActive(subPlan, activeDeliveryGroupIds))
+ foreach (var incrementalPlan in incrementalPlans)
{
- continue;
- }
+ if (!IsIncrementalPlanActive(incrementalPlan, activeDeliveryGroupIds))
+ {
+ continue;
+ }
- if (subPlan.DeliveryGroups[0].Parent is not null)
- {
- continue;
- }
+ if (incrementalPlan.DeliveryGroups[0].Parent is not null)
+ {
+ continue;
+ }
- started.Add(subPlan);
- pendingSubPlanCount++;
- _ = ExecuteDeferredSubPlanInBackground(
+ started.Add(incrementalPlan);
+ pendingIncrementalPlanCount++;
+ BeginIncrementalPlan(
requestContext,
variables,
- operationPlan,
- subPlan,
+ incrementalPlan,
+ rootContext.GetResultStoreForChildDefer(),
channel.Writer,
cancellationToken);
- }
-
- // Track which delivery groups we have already announced as pending so
- // we do not re-announce nested groups multiple times when they belong
- // to more than one subplan.
- var announcedDeliveryGroupIds = new HashSet();
- foreach (var deliveryGroup in operationPlan.DeliveryGroups)
- {
- if (deliveryGroup.Parent is null && activeDeliveryGroupIds.Contains(deliveryGroup.Id))
- {
- announcedDeliveryGroupIds.Add(deliveryGroup.Id);
}
- }
-
- // Yield results as they complete.
- while (pendingSubPlanCount > 0 && !cancellationToken.IsCancellationRequested)
- {
- var (subPlan, result, error) = await channel.Reader.ReadAsync(cancellationToken);
- pendingSubPlanCount--;
- // Start nested subplans whose parent delivery group belongs to the
- // just-completed subplan, then announce their delivery groups as
- // pending. We collect announcements for the outgoing payload.
- var childPending = ImmutableList.CreateBuilder();
- foreach (var candidate in deferredSubPlans)
+ // Track which delivery groups we have already announced as pending so
+ // we do not re-announce nested groups multiple times when they belong
+ // to more than one incremental plan.
+ var announcedDeliveryGroupIds = new HashSet();
+ foreach (var deliveryGroup in operationPlan.DeliveryGroups)
{
- if (!IsSubPlanActive(candidate, activeDeliveryGroupIds))
+ if (deliveryGroup.Parent is null && activeDeliveryGroupIds.Contains(deliveryGroup.Id))
{
- continue;
+ announcedDeliveryGroupIds.Add(deliveryGroup.Id);
}
+ }
- if (started.Contains(candidate))
- {
- continue;
- }
+ // Yield results as they complete.
+ while (pendingIncrementalPlanCount > 0 && !cancellationToken.IsCancellationRequested)
+ {
+ var (incrementalPlan, incrementalPlanContext, result, error) = await channel.Reader.ReadAsync(cancellationToken);
+ pendingIncrementalPlanCount--;
- var candidateParent = candidate.DeliveryGroups[0].Parent;
- if (candidateParent is null)
+ // Register the completed incremental plan's context so any nested
+ // defer launched below can source its parent store from this
+ // immediately enclosing incremental plan rather than the root plan.
+ if (incrementalPlanContext is not null)
{
- continue;
+ incrementalPlanContexts[incrementalPlan] = incrementalPlanContext;
}
- var parentBelongsToJustCompleted = false;
- foreach (var deliveryGroup in subPlan.DeliveryGroups)
+ // Start nested incremental plans whose parent delivery group
+ // belongs to the just-completed incremental plan, then mark
+ // their delivery groups as pending on the outgoing result.
+ var childPending = ImmutableList.CreateBuilder();
+ foreach (var candidate in incrementalPlans)
{
- if (ReferenceEquals(deliveryGroup, candidateParent))
+ if (!IsIncrementalPlanActive(candidate, activeDeliveryGroupIds))
{
- parentBelongsToJustCompleted = true;
- break;
+ continue;
}
- }
-
- if (!parentBelongsToJustCompleted)
- {
- continue;
- }
- started.Add(candidate);
- pendingSubPlanCount++;
- _ = ExecuteDeferredSubPlanInBackground(
- requestContext,
- variables,
- operationPlan,
- candidate,
- channel.Writer,
- cancellationToken);
+ if (started.Contains(candidate))
+ {
+ continue;
+ }
- foreach (var deliveryGroup in candidate.DeliveryGroups)
- {
- if (!activeDeliveryGroupIds.Contains(deliveryGroup.Id))
+ var candidateParent = candidate.DeliveryGroups[0].Parent;
+ if (candidateParent is null)
{
continue;
}
- if (!announcedDeliveryGroupIds.Add(deliveryGroup.Id))
+ var parentBelongsToJustCompleted = false;
+ foreach (var deliveryGroup in incrementalPlan.DeliveryGroups)
+ {
+ if (ReferenceEquals(deliveryGroup, candidateParent))
+ {
+ parentBelongsToJustCompleted = true;
+ break;
+ }
+ }
+
+ if (!parentBelongsToJustCompleted)
{
continue;
}
- childPending.Add(new PendingResult(
- deliveryGroup.Id,
- BuildPath(deliveryGroup.Path ?? SelectionPath.Root),
- deliveryGroup.Label));
+ // Nested plans resolve requirements from the immediate
+ // enclosing plan when available.
+ var parentStore = incrementalPlanContext?.GetResultStoreForChildDefer()
+ ?? rootContext.GetResultStoreForChildDefer();
+
+ started.Add(candidate);
+ pendingIncrementalPlanCount++;
+ BeginIncrementalPlan(
+ requestContext,
+ variables,
+ candidate,
+ parentStore,
+ channel.Writer,
+ cancellationToken);
+
+ foreach (var deliveryGroup in candidate.DeliveryGroups)
+ {
+ if (!activeDeliveryGroupIds.Contains(deliveryGroup.Id))
+ {
+ continue;
+ }
+
+ if (!announcedDeliveryGroupIds.Add(deliveryGroup.Id))
+ {
+ continue;
+ }
+
+ childPending.Add(new PendingResult(
+ deliveryGroup.Id,
+ BuildPath(deliveryGroup.Path ?? SelectionPath.Root),
+ deliveryGroup.Label));
+ }
}
- }
- // Pick the best delivery group for this subplan's emission: the
- // one whose Path is the longest prefix of the data's actual path
- // (equivalently: produces the shortest subPath). This follows the
- // graphql-js `_getBestIdAndSubPath` rule. Ties are broken by the
- // smallest DeferUsage.Id for determinism, which matches the sorted
- // DeliveryGroups order.
- var bestDeliveryGroup = PickBestDeliveryGroup(subPlan);
-
- // Build the incremental payload following the GraphQL incremental
- // delivery spec. Deferred data goes in `incremental`; `completed`
- // signals a delivery group is done; `hasNext` indicates more
- // payloads follow. We compute completed entries by decrementing
- // each delivery group the subplan contributed to.
- var completed = ImmutableList.CreateBuilder();
- OperationResult payload;
-
- if (error is not null)
- {
- var errorObj = ErrorBuilder.New()
- .SetMessage(error.Message)
- .Build();
- payload = OperationResult.FromError(errorObj);
- CompleteDeliveryGroupsForSubPlan(
- subPlan,
- activeDeliveryGroupIds,
- pendingCountByDeliveryGroup,
- completed,
- errors: [errorObj]);
- }
- else if (result is not null)
- {
- payload = result;
-
- // Wrap the deferred result's data in IncrementalObjectResult
- // and clear top-level data/errors (per spec, subsequent payloads
- // use `incremental` array, not root `data`).
- //
- // The deferred plan executes against a standalone operation whose
- // result is rooted at Query (e.g. `{ user: { reviews: [...] } }`),
- // but the incremental delivery contract requires `incremental.data`
- // to be the delta at `pending.path`. We navigate down the best
- // delivery group's path and emit only the subtree at that location.
- if (result.Data.HasValue
- && !result.Data.Value.IsValueNull
- && TryCreateIncrementalData(
- result.Data.Value,
- bestDeliveryGroup,
- out var incrementalData))
+ // Use the deepest delivery group path for this incremental
+ // plan. Ties are broken by the smallest DeliveryGroup.Id.
+ var bestDeliveryGroup = PickBestDeliveryGroup(incrementalPlan);
+
+ // Build the result for this incremental plan and update
+ // delivery group completion state.
+ var completed = ImmutableList.CreateBuilder();
+ OperationResult payload;
+
+ if (error is not null)
{
- payload.Incremental =
- [
- new IncrementalObjectResult(
- bestDeliveryGroup.Id,
+ var errorObj = ErrorBuilder.New()
+ .SetMessage(error.Message)
+ .Build();
+ payload = OperationResult.FromError(errorObj);
+ CompleteDeliveryGroupsForIncrementalPlan(
+ incrementalPlan,
+ activeDeliveryGroupIds,
+ pendingCountByDeliveryGroup,
+ completed,
+ errors: [errorObj]);
+ }
+ else if (result is not null)
+ {
+ payload = result;
+
+ // Use the selected delivery group's path to produce the
+ // incremental result data for this plan.
+ if (result.Data.HasValue
+ && !result.Data.Value.IsValueNull
+ && TryCreateIncrementalResults(
+ result.Data.Value,
+ bestDeliveryGroup,
result.Errors.Count > 0 ? result.Errors : null,
- data: incrementalData)
- ];
+ out var incrementalResults))
+ {
+ payload.Incremental = incrementalResults;
+ }
+
+ CompleteDeliveryGroupsForIncrementalPlan(
+ incrementalPlan,
+ activeDeliveryGroupIds,
+ pendingCountByDeliveryGroup,
+ completed,
+ errors: result.Errors.Count > 0 && payload.Incremental.Count == 0
+ ? result.Errors
+ : null);
+ }
+ else
+ {
+ // Incremental plan with no execution result: all fields may
+ // have been conditional and excluded. Report a successful
+ // completion with no data.
+ var placeholder = ErrorBuilder.New()
+ .SetMessage("placeholder")
+ .Build();
+ payload = OperationResult.FromError(placeholder);
+ CompleteDeliveryGroupsForIncrementalPlan(
+ incrementalPlan,
+ activeDeliveryGroupIds,
+ pendingCountByDeliveryGroup,
+ completed,
+ errors: null);
}
- CompleteDeliveryGroupsForSubPlan(
- subPlan,
- activeDeliveryGroupIds,
- pendingCountByDeliveryGroup,
- completed,
- errors: result.Errors.Count > 0 && payload.Incremental.Count == 0
- ? result.Errors
- : null);
- }
- else
- {
- // Empty deferred subplan: 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);
- CompleteDeliveryGroupsForSubPlan(
- subPlan,
- activeDeliveryGroupIds,
- pendingCountByDeliveryGroup,
- completed,
- errors: null);
- }
+ // Set Completed before clearing top-level errors.
+ if (completed.Count > 0)
+ {
+ payload.Completed = completed.ToImmutable();
+ }
- // Set Completed first so the IncrementalDataFeature is established
- // before clearing the top-level Errors (which validates against it).
- if (completed.Count > 0)
- {
- payload.Completed = completed.ToImmutable();
- }
+ if (childPending.Count > 0)
+ {
+ payload.Pending = childPending.ToImmutable();
+ }
- if (childPending.Count > 0)
- {
- payload.Pending = childPending.ToImmutable();
- }
+ // Incremental results do not carry root data or top-level errors.
+ payload.Data = null;
+ if (payload.Errors.Count > 0)
+ {
+ payload.Errors = [];
+ }
- // Per spec: subsequent payloads use `incremental` array, not root
- // `data`. Clear top-level data/errors so the formatter only renders
- // incremental delivery fields.
- payload.Data = null;
- if (payload.Errors.Count > 0)
+ payload.HasNext = pendingIncrementalPlanCount > 0;
+ yield return payload;
+ }
+ }
+ finally
+ {
+ // Dispose completed incremental plan contexts after the stream
+ // finishes. The root context is owned by the surrounding stream.
+ foreach (var incrementalPlanContext in incrementalPlanContexts.Values)
{
- payload.Errors = [];
+ await incrementalPlanContext.DisposeAsync();
}
-
- payload.HasNext = pendingSubPlanCount > 0;
- yield return payload;
}
}
- private static async Task ExecuteDeferredSubPlanInBackground(
+ ///
+ /// Starts an incremental plan and publishes completion to the channel.
+ /// Plans without execution nodes complete without running work.
+ ///
+ private static void BeginIncrementalPlan(
RequestContext requestContext,
IVariableValueCollection variables,
- OperationPlan operationPlan,
- ExecutionSubPlan subPlan,
- ChannelWriter<(ExecutionSubPlan, OperationResult?, Exception?)> writer,
+ IncrementalPlan incrementalPlan,
+ FetchResultStore parentResultStore,
+ ChannelWriter completion,
CancellationToken cancellationToken)
{
- try
+ if (incrementalPlan.AllNodes.IsEmpty)
{
- if (subPlan.AllNodes.IsEmpty)
- {
- await writer.WriteAsync((subPlan, null, null), cancellationToken);
- return;
- }
+ completion.TryWrite(new IncrementalPlanResult(incrementalPlan, null, null, null));
+ return;
+ }
- var representative = subPlan.DeliveryGroups[0];
+ var executionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var context = requestContext.Schema.Services.GetRequiredService().Rent();
+
+ try
+ {
+ context.Initialize(requestContext, variables, incrementalPlan, executionCts);
- // Create a mini OperationPlan for the deferred subplan using the
- // subplan's own compiled Operation for correct result mapping.
- var deferPlan = OperationPlan.Create(
- operationPlan.Id + "#defer_" + representative.Id,
- subPlan.Operation,
- subPlan.RootNodes,
- subPlan.AllNodes,
- [],
- [],
- 0,
- 0);
+ // Copy parent-scope requirements into the child context.
+ CollectIncrementalPlanRequirements(parentResultStore, incrementalPlan, context);
+ }
+ catch
+ {
+ _ = context.DisposeAsync();
+ executionCts.Dispose();
+ throw;
+ }
- using var executionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ // Ownership of context and executionCts passes to ExecuteIncrementalPlan.
+ _ = ExecuteIncrementalPlan(context, incrementalPlan, executionCts, completion, cancellationToken);
+ }
- await using var context = requestContext.Schema.Services
- .GetRequiredService().Rent();
- context.Initialize(requestContext, variables, deferPlan, executionCts);
+ private static async Task ExecuteIncrementalPlan(
+ OperationPlanContext context,
+ IOperationPlan plan,
+ CancellationTokenSource executionCts,
+ ChannelWriter completion,
+ CancellationToken cancellationToken)
+ {
+ var incrementalPlan = (IncrementalPlan)plan;
+ try
+ {
context.Begin();
- await ExecuteQueryAsync(context, deferPlan, executionCts.Token);
+ await ExecuteQueryAsync(context, plan, executionCts.Token);
- var deferredResult = context.Complete();
- await writer.WriteAsync((subPlan, deferredResult, null), cancellationToken);
+ // Keep result resources available for nested incremental plans.
+ var deferredResult = context.Complete(retainMemoryForDefer: true);
+ await completion.WriteAsync(
+ new IncrementalPlanResult(incrementalPlan, context, deferredResult, null),
+ cancellationToken);
}
catch (OperationCanceledException)
{
- // Write a cancellation result so the consumer doesn't hang.
- await writer.WriteAsync((subPlan, null, null), CancellationToken.None);
+ // Signal cancellation so the consumer's counter balances; the
+ // context is handed back on the channel so the iterator can
+ // dispose it as part of its finally-block cleanup.
+ await completion.WriteAsync(
+ new IncrementalPlanResult(incrementalPlan, context, null, null),
+ CancellationToken.None);
}
catch (Exception ex)
{
- await writer.WriteAsync((subPlan, null, ex), CancellationToken.None);
+ await completion.WriteAsync(
+ new IncrementalPlanResult(incrementalPlan, context, null, ex),
+ CancellationToken.None);
+ }
+ finally
+ {
+ executionCts.Dispose();
}
}
- private static bool IsDeliveryGroupActive(DeferUsage deliveryGroup, IVariableValueCollection variables)
+ ///
+ /// Collects requirements satisfied by the enclosing plan scope and
+ /// installs their values on the child context.
+ ///
+ private static void CollectIncrementalPlanRequirements(
+ FetchResultStore parentResultStore,
+ IncrementalPlan incrementalPlan,
+ OperationPlanContext childContext)
+ {
+ var collected = CollectParentScopeRequirements(incrementalPlan);
+ if (collected.Count == 0)
+ {
+ return;
+ }
+
+ var anchor = collected[0].Path;
+ for (var i = 1; i < collected.Count; i++)
+ {
+ if (!collected[i].Path.Equals(anchor))
+ {
+ throw new InvalidOperationException(
+ "Deferred incremental plan has parent-sourced requirements at different anchor paths; "
+ + "one-time materialization assumes a single anchor.");
+ }
+ }
+
+ var requirementSpan = new OperationRequirement[collected.Count];
+ var keys = new HashSet(collected.Count, StringComparer.Ordinal);
+ for (var i = 0; i < collected.Count; i++)
+ {
+ requirementSpan[i] = collected[i];
+ keys.Add(collected[i].Key);
+ }
+
+ var parentValues = parentResultStore.CreateVariableValueSets(
+ anchor,
+ requestVariables: [],
+ requirementSpan);
+
+ childContext.SetRequirements(parentValues, keys);
+ }
+
+ private static List CollectParentScopeRequirements(IncrementalPlan incrementalPlan)
+ {
+ var collected = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (var node in incrementalPlan.AllNodes)
+ {
+ switch (node)
+ {
+ case OperationExecutionNode op when !op.ParentDependencies.IsEmpty:
+ AppendRequirements(op.Requirements, collected, seen);
+ break;
+
+ case OperationBatchExecutionNode batch:
+ foreach (var definition in batch.Operations)
+ {
+ if (!definition.ParentDependencies.IsEmpty)
+ {
+ AppendRequirements(definition.Requirements, collected, seen);
+ }
+ }
+ break;
+ }
+ }
+
+ return collected;
+ }
+
+ private static void AppendRequirements(
+ ReadOnlySpan requirements,
+ List collected,
+ HashSet seen)
+ {
+ foreach (var requirement in requirements)
+ {
+ if (seen.Add(requirement.Key))
+ {
+ collected.Add(requirement);
+ }
+ }
+ }
+
+ private static bool IsDeliveryGroupActive(DeliveryGroup deliveryGroup, IVariableValueCollection variables)
{
if (deliveryGroup.IfVariable is null)
{
@@ -492,9 +615,9 @@ private static bool IsDeliveryGroupActive(DeferUsage deliveryGroup, IVariableVal
return boolValue.Value;
}
- private static bool IsSubPlanActive(ExecutionSubPlan subPlan, HashSet activeDeliveryGroupIds)
+ private static bool IsIncrementalPlanActive(IncrementalPlan incrementalPlan, HashSet activeDeliveryGroupIds)
{
- foreach (var deliveryGroup in subPlan.DeliveryGroups)
+ foreach (var deliveryGroup in incrementalPlan.DeliveryGroups)
{
if (activeDeliveryGroupIds.Contains(deliveryGroup.Id))
{
@@ -506,21 +629,18 @@ private static bool IsSubPlanActive(ExecutionSubPlan subPlan, HashSet activ
}
///
- /// Picks the best delivery group for emitting a subplan's incremental
- /// payload. Per graphql-js _getBestIdAndSubPath, the best group is
- /// the one whose is the longest prefix of
- /// the data's actual path (equivalently, the shortest subPath).
- /// Ties are broken by the smallest , which is
- /// the first element in the sorted .
+ /// Selects the delivery group used to anchor an incremental result.
+ /// The group with the deepest path is selected.
+ /// Ties are broken by the smallest .
///
- private static DeferUsage PickBestDeliveryGroup(ExecutionSubPlan subPlan)
+ private static DeliveryGroup PickBestDeliveryGroup(IncrementalPlan incrementalPlan)
{
- var best = subPlan.DeliveryGroups[0];
+ var best = incrementalPlan.DeliveryGroups[0];
var bestLength = best.Path?.Length ?? 0;
- for (var i = 1; i < subPlan.DeliveryGroups.Length; i++)
+ for (var i = 1; i < incrementalPlan.DeliveryGroups.Length; i++)
{
- var candidate = subPlan.DeliveryGroups[i];
+ var candidate = incrementalPlan.DeliveryGroups[i];
var candidateLength = candidate.Path?.Length ?? 0;
if (candidateLength > bestLength)
@@ -533,14 +653,14 @@ private static DeferUsage PickBestDeliveryGroup(ExecutionSubPlan subPlan)
return best;
}
- private static void CompleteDeliveryGroupsForSubPlan(
- ExecutionSubPlan subPlan,
+ private static void CompleteDeliveryGroupsForIncrementalPlan(
+ IncrementalPlan incrementalPlan,
HashSet activeDeliveryGroupIds,
Dictionary pendingCountByDeliveryGroup,
ImmutableList.Builder completed,
IReadOnlyList? errors)
{
- foreach (var deliveryGroup in subPlan.DeliveryGroups)
+ foreach (var deliveryGroup in incrementalPlan.DeliveryGroups)
{
if (!activeDeliveryGroupIds.Contains(deliveryGroup.Id))
{
@@ -585,22 +705,31 @@ private static Path BuildPath(SelectionPath selectionPath)
}
///
- /// Produces an whose logical root is the
- /// subtree at the best delivery group's path within the deferred plan's
- /// composite result. The incremental delivery contract requires
- /// incremental.data to be the delta to merge at the pending path,
- /// not the fully rooted result.
+ /// Produces entries whose logical root
+ /// is the subtree at the best delivery group's path within the deferred
+ /// plan's composite result. The incremental delivery contract requires
+ /// incremental.data to be a map of fields to merge at the pending
+ /// path, not the fully rooted result. When the pending path points at a list,
+ /// each list element is emitted as a separate incremental result with a
+ /// relative index subPath.
///
- private static bool TryCreateIncrementalData(
+ private static bool TryCreateIncrementalResults(
OperationResultData rootData,
- DeferUsage bestDeliveryGroup,
- out OperationResultData incrementalData)
+ DeliveryGroup bestDeliveryGroup,
+ ImmutableList? errors,
+ out ImmutableList incrementalResults)
{
if (rootData.Value is not CompositeResultDocument document)
{
// Unknown backing value: fall through to the default behavior and
// emit the result as-is.
- incrementalData = rootData;
+ incrementalResults =
+ [
+ new IncrementalObjectResult(
+ bestDeliveryGroup.Id,
+ errors,
+ data: rootData)
+ ];
return true;
}
@@ -622,23 +751,60 @@ private static bool TryCreateIncrementalData(
|| next.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
// The path could not be resolved or is null; nothing to merge.
- incrementalData = default;
+ incrementalResults = [];
return false;
}
element = next;
}
- // MemoryHolder is intentionally not carried over: the surrounding
- // OperationResult already owns the composite document's lifetime,
- // and the IncrementalObjectResult is a non-owning view over it.
- incrementalData = new OperationResultData(
+ if (element.ValueKind is JsonValueKind.Array)
+ {
+ var builder = ImmutableList.CreateBuilder();
+ var length = element.GetArrayLength();
+
+ for (var i = 0; i < length; i++)
+ {
+ var item = element[i];
+
+ if (item.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ continue;
+ }
+
+ builder.Add(
+ new IncrementalObjectResult(
+ bestDeliveryGroup.Id,
+ errors,
+ subPath: Path.Root.Append(i),
+ data: CreateIncrementalData(document, item)));
+ }
+
+ incrementalResults = builder.ToImmutable();
+ return true;
+ }
+
+ incrementalResults =
+ [
+ new IncrementalObjectResult(
+ bestDeliveryGroup.Id,
+ errors,
+ data: CreateIncrementalData(document, element))
+ ];
+ return true;
+ }
+
+ private static OperationResultData CreateIncrementalData(
+ CompositeResultDocument document,
+ CompositeResultElement element)
+ // MemoryHolder is intentionally not carried over. The surrounding
+ // OperationResult already owns the composite document's lifetime, and
+ // the IncrementalObjectResult is a non-owning view over it.
+ => new(
document,
isValueNull: false,
new DeferredPayloadDataFormatter(element),
memoryHolder: null);
- return true;
- }
public static async Task SubscribeAsync(
RequestContext requestContext,
@@ -713,7 +879,7 @@ public static async Task SubscribeAsync(
private static async Task ExecuteQueryAsync(
OperationPlanContext context,
- OperationPlan plan,
+ IOperationPlan plan,
CancellationToken cancellationToken)
{
var executionState = context.ExecutionState;
@@ -760,7 +926,7 @@ private static async Task ExecuteQueryAsync(
private static async Task ExecuteMutationAsync(
OperationPlanContext context,
- OperationPlan plan,
+ IOperationPlan plan,
CancellationToken cancellationToken)
{
var executionState = context.ExecutionState;
@@ -953,3 +1119,9 @@ private static async IAsyncEnumerable CreateResponseStream(
}
}
}
+
+internal readonly record struct IncrementalPlanResult(
+ IncrementalPlan IncrementalPlan,
+ OperationPlanContext? Context,
+ OperationResult? Result,
+ Exception? Error);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs
index c0c092dc43f..d4a9ff1fc62 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Pipeline/OperationExecutionMiddleware.cs
@@ -51,7 +51,7 @@ public async ValueTask InvokeAsync(
{
if (context.VariableValues.Length > 1)
{
- if (!operationPlan.DeferredSubPlans.IsEmpty)
+ if (!operationPlan.IncrementalPlans.IsEmpty)
{
var error = ErrorBuilder.New()
.SetMessage("Variable batching is not supported with @defer.")
@@ -78,7 +78,7 @@ public async ValueTask InvokeAsync(
var results = ImmutableList.CreateRange(await Task.WhenAll(tasks));
context.Result = new OperationResultBatch(results);
}
- else if (!operationPlan.DeferredSubPlans.IsEmpty)
+ else if (!operationPlan.IncrementalPlans.IsEmpty)
{
context.Result = await OperationPlanExecutor.ExecuteWithDeferAsync(
context,
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs
index 8764f56fc18..f9d94fb4d9a 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/AdditionalPathAccumulator.cs
@@ -34,6 +34,14 @@ public void Add(int slotIndex, CompactPath path)
_count++;
}
+ public void AddRange(int slotIndex, ReadOnlySpan paths)
+ {
+ foreach (var path in paths)
+ {
+ Add(slotIndex, path);
+ }
+ }
+
public void ApplyTo(VariableValues[] variableValueSets, int slotCount)
{
if (_count == 0)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs
index cf04aac8655..f5fa36162f7 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs
@@ -586,7 +586,9 @@ private bool SaveSafeResult(
return false;
}
- var element = path.IsRoot ? resultData : GetStartObjectResult(path);
+ var element = path.IsRoot ? resultData : GetStartResult(path);
+ Debug.Assert(element.ValueKind is JsonValueKind.Object or JsonValueKind.Null or JsonValueKind.Undefined);
+
if (element.IsNullOrInvalidated)
{
return true;
@@ -689,6 +691,43 @@ public ImmutableArray CreateVariableValueSets(
}
}
+ internal ImmutableArray CreateVariableValueSetsFromSnapshot(
+ ImmutableArray importedEntries,
+ HashSet importedKeys,
+ IReadOnlyList requestVariables,
+ ReadOnlySpan requiredData)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(importedKeys);
+ ArgumentNullException.ThrowIfNull(requestVariables);
+
+ if (requiredData.Length == 0)
+ {
+ throw new ArgumentException(
+ "The required data span must contain at least one requirement.",
+ nameof(requiredData));
+ }
+
+ if (importedEntries.IsDefaultOrEmpty)
+ {
+ return [];
+ }
+
+ foreach (var requirement in requiredData)
+ {
+ if (!importedKeys.Contains(requirement.Key))
+ {
+ throw new InvalidOperationException(
+ "A deferred incremental plan fetch references a requirement that was not imported.");
+ }
+ }
+
+ lock (_lock)
+ {
+ return BuildVariableValueSetsFromSnapshot(importedEntries, requestVariables, requiredData);
+ }
+ }
+
// Caller must hold _lock for reading.
private ReadOnlySpan CollectTargetElements(SelectionPath selectionSet)
{
@@ -755,19 +794,79 @@ private ReadOnlySpan CollectTargetElements(SelectionPath
if (currentCount == 0)
{
- // Store potentially grown arrays back.
+ // Update collection state.
_collectTargetA = current;
_collectTargetB = next;
return [];
}
}
- // Store potentially grown arrays back.
+ // Update collection state.
_collectTargetA = current;
_collectTargetB = next;
return current.AsSpan(0, currentCount);
}
+ private ImmutableArray BuildVariableValueSetsFromSnapshot(
+ ImmutableArray importedEntries,
+ IReadOnlyList requestVariables,
+ ReadOnlySpan requiredData)
+ {
+ _variableDedupTable.Initialize(importedEntries.Length);
+
+ VariableValues[]? variableValueSets = null;
+ var additionalPaths = new AdditionalPathAccumulator();
+ var nextIndex = 0;
+
+ foreach (var importedEntry in importedEntries)
+ {
+ if (importedEntry.IsEmpty)
+ {
+ continue;
+ }
+
+ _jsonWriter.Reset(_variableWriter);
+ var startPosition = _variableWriter.Position;
+ _jsonWriter.WriteStartObject();
+
+ for (var i = 0; i < requestVariables.Count; i++)
+ {
+ var field = requestVariables[i];
+ _jsonWriter.WritePropertyName(field.Name.Value);
+ WriteValueNode(field.Value);
+ }
+
+ if (!TryWriteRequestedRequirementValues(importedEntry.Values, requiredData))
+ {
+ _variableWriter.ResetTo(startPosition);
+ continue;
+ }
+
+ _jsonWriter.WriteEndObject();
+
+ var entry = TryCreateVariableValues(
+ importedEntry.Path,
+ startPosition,
+ ref additionalPaths,
+ nextIndex,
+ out var dedupIndex);
+
+ if (entry is null)
+ {
+ additionalPaths.AddRange(dedupIndex, importedEntry.AdditionalPaths.AsSpan());
+ continue;
+ }
+
+ variableValueSets ??= s_variableValuePool.Rent(importedEntries.Length);
+ variableValueSets[nextIndex] = entry.Value;
+ additionalPaths.AddRange(nextIndex, importedEntry.AdditionalPaths.AsSpan());
+ nextIndex++;
+ }
+
+ _variableDedupTable.Clear();
+ return FinalizeVariableValueSets(variableValueSets, ref additionalPaths, nextIndex);
+ }
+
private ImmutableArray BuildVariableValueSets(
ReadOnlySpan elements,
IReadOnlyList requestVariables,
@@ -1290,6 +1389,14 @@ private static bool TryGetSimpleRequirementFieldName(
int startPosition,
ref AdditionalPathAccumulator additionalPaths,
int nextIndex)
+ => TryCreateVariableValues(path, startPosition, ref additionalPaths, nextIndex, out _);
+
+ private VariableValues? TryCreateVariableValues(
+ CompactPath path,
+ int startPosition,
+ ref AdditionalPathAccumulator additionalPaths,
+ int nextIndex,
+ out int dedupIndex)
{
var length = _variableWriter.Position - startPosition;
var hash = _variableWriter.GetHashCode(startPosition, length);
@@ -1300,15 +1407,93 @@ private static bool TryGetSimpleRequirementFieldName(
// this allows us to fetch once and then insert the data at different locations.
if (_variableDedupTable.TryGet(hash, startPosition, length, out var existingIndex))
{
+ dedupIndex = existingIndex;
additionalPaths.Add(existingIndex, path);
_variableWriter.ResetTo(startPosition);
return null;
}
+ dedupIndex = nextIndex;
_variableDedupTable.Add(hash, nextIndex, startPosition, length);
return new VariableValues(path, JsonSegment.Create(_variableWriter, startPosition, length));
}
+ private bool TryWriteRequestedRequirementValues(
+ JsonSegment values,
+ ReadOnlySpan requiredData)
+ {
+ if (values.IsEmpty)
+ {
+ return false;
+ }
+
+ var sequence = values.AsSequence();
+
+ foreach (var requirement in requiredData)
+ {
+ if (!TryWriteRequirementValue(sequence, requirement.Key))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TryWriteRequirementValue(ReadOnlySequence values, string key)
+ {
+ var reader = new Utf8JsonReader(values);
+
+ if (!reader.Read() || reader.TokenType is not JsonTokenType.StartObject)
+ {
+ return false;
+ }
+
+ while (reader.Read())
+ {
+ if (reader.TokenType is JsonTokenType.EndObject)
+ {
+ return false;
+ }
+
+ if (reader.TokenType is not JsonTokenType.PropertyName)
+ {
+ return false;
+ }
+
+ var matches = reader.ValueTextEquals(key);
+
+ if (!reader.Read())
+ {
+ return false;
+ }
+
+ var start = reader.TokenStartIndex;
+ reader.Skip();
+ var length = reader.BytesConsumed - start;
+
+ if (matches)
+ {
+ _jsonWriter.WritePropertyName(key);
+ WriteRawJsonValue(values.Slice(start, length));
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void WriteRawJsonValue(ReadOnlySequence value)
+ {
+ if (value.IsSingleSegment)
+ {
+ _jsonWriter.WriteRawValue(value.FirstSpan);
+ return;
+ }
+
+ _jsonWriter.WriteRawValue(value.ToArray());
+ }
+
private void WriteValueNode(IValueNode value)
{
switch (value)
@@ -1392,6 +1577,100 @@ internal VariableValues CreateVariableValueSets(
return new VariableValues(path, JsonSegment.Create(_variableWriter, startPosition, length));
}
+ ///
+ /// Imports variable value sets into this store for a child incremental plan.
+ ///
+ internal ImmutableArray ImportVariableValues(
+ ImmutableArray source)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (source.IsDefaultOrEmpty)
+ {
+ return [];
+ }
+
+ var builder = ImmutableArray.CreateBuilder(source.Length);
+
+ ImmutableArray imported;
+
+ lock (_lock)
+ {
+ foreach (var entry in source)
+ {
+ builder.Add(ImportVariableValuesEntry(entry));
+ }
+
+ imported = builder.MoveToImmutable();
+ }
+
+ InitializeTargetPaths(imported);
+
+ return imported;
+ }
+
+ private VariableValues ImportVariableValuesEntry(VariableValues source)
+ {
+ var path = ImportPath(source.Path);
+ var values = ImportJsonSegment(source.Values);
+ var additionalPaths = ImportAdditionalPaths(source.AdditionalPaths);
+
+ return new VariableValues(path, values)
+ {
+ AdditionalPaths = additionalPaths
+ };
+ }
+
+ private JsonSegment ImportJsonSegment(JsonSegment source)
+ {
+ if (source.IsEmpty)
+ {
+ return JsonSegment.Empty;
+ }
+
+ var startPosition = _variableWriter.Position;
+ foreach (var memory in source.AsSequence())
+ {
+ var span = _variableWriter.GetSpan(memory.Length);
+ memory.Span.CopyTo(span);
+ _variableWriter.Advance(memory.Length);
+ }
+
+ var length = _variableWriter.Position - startPosition;
+ return JsonSegment.Create(_variableWriter, startPosition, length);
+ }
+
+ private static CompactPath ImportPath(CompactPath source)
+ {
+ if (source.IsRoot)
+ {
+ return CompactPath.Root;
+ }
+
+ var segments = source.Segments;
+ var copy = new int[segments.Length + 1];
+ copy[0] = segments.Length;
+ segments.CopyTo(copy.AsSpan(1));
+ return new CompactPath(copy);
+ }
+
+ private static CompactPathSegment ImportAdditionalPaths(CompactPathSegment source)
+ {
+ if (source.IsDefaultOrEmpty)
+ {
+ return default;
+ }
+
+ var paths = source.AsSpan();
+ var copy = new CompactPath[paths.Length];
+ for (var i = 0; i < paths.Length; i++)
+ {
+ copy[i] = ImportPath(paths[i]);
+ }
+
+ return new CompactPathSegment(copy, 0, copy.Length);
+ }
+
private static void AppendUnrolledLists(
CompositeResultElement list,
ref CompositeResultElement[] destination,
@@ -1604,23 +1883,176 @@ private CompositeResultElement GetStartResult(CompactPath path)
if (segment >= 0)
{
+ if (element.ValueKind is not JsonValueKind.Object)
+ {
+ throw new InvalidOperationException(
+ $"The path segment '{segment}' does not exist in the data.");
+ }
+
element = element.GetPropertyBySelectionId(segment);
}
else
{
var index = ~segment;
+ element = element[index];
+ }
+ }
+
+ return element;
+ }
+
+ private void InitializeTargetPaths(ImmutableArray importedValues)
+ {
+ if (importedValues.IsDefaultOrEmpty)
+ {
+ return;
+ }
- if (element.GetArrayLength() <= index)
+ lock (_lock)
+ {
+ // Track list containers that need imported elements.
+ Dictionary? containers = null;
+
+ foreach (var entry in importedValues)
+ {
+ TrackListContainer(entry.Path, ref containers);
+ foreach (var additional in entry.AdditionalPaths.AsSpan())
+ {
+ TrackListContainer(additional, ref containers);
+ }
+ }
+
+ if (containers is null)
+ {
+ return;
+ }
+
+ foreach (var (_, (container, maxIndex)) in containers)
+ {
+ if (container.ValueKind is JsonValueKind.Undefined)
+ {
+ container.SetArrayValue(maxIndex + 1);
+ }
+ else if (container.ValueKind is JsonValueKind.Array)
+ {
+ if (container.GetArrayLength() <= maxIndex)
+ {
+ throw new InvalidOperationException(
+ $"The target path list container is shorter than required for index {maxIndex}.");
+ }
+ }
+ else if (container.ValueKind is not JsonValueKind.Null)
{
throw new InvalidOperationException(
- $"The path segment '[{index}]' does not exist in the data.");
+ "The target path list container does not exist in the data.");
}
+ }
+ }
+ }
- element = element[index];
+ private void TrackListContainer(
+ CompactPath path,
+ ref Dictionary? containers)
+ {
+ if (path.IsRoot)
+ {
+ return;
+ }
+
+ var element = _result.Data;
+ var segments = path.Segments;
+
+ for (var i = 0; i < segments.Length; i++)
+ {
+ var seg = segments[i];
+
+ if (element.ValueKind is JsonValueKind.Null)
+ {
+ return;
+ }
+
+ if (seg >= 0)
+ {
+ var hasListAnchor = HasListAnchor(segments, i + 1);
+
+ if (element.ValueKind is JsonValueKind.Undefined)
+ {
+ if (!hasListAnchor)
+ {
+ return;
+ }
+
+ InitializeIntermediateObject(element);
+ }
+
+ if (element.ValueKind is not JsonValueKind.Object)
+ {
+ if (!hasListAnchor)
+ {
+ return;
+ }
+
+ throw new InvalidOperationException(
+ $"The path segment '{seg}' does not exist in the data.");
+ }
+
+ element = element.GetPropertyBySelectionId(seg);
+ }
+ else
+ {
+ // Negative segment encodes a list index as ~index.
+ var index = ~seg;
+ var cursorKey = element.Cursor.Index;
+
+ containers ??= new Dictionary();
+
+ if (containers.TryGetValue(cursorKey, out var existing))
+ {
+ if (index > existing.MaxIndex)
+ {
+ containers[cursorKey] = (existing.Container, index);
+ }
+ }
+ else
+ {
+ containers[cursorKey] = (element, index);
+ }
+
+ // Only process the outermost list anchor; stop here.
+ return;
+ }
+ }
+ }
+
+ private static bool HasListAnchor(ReadOnlySpan segments, int start)
+ {
+ for (var i = start; i < segments.Length; i++)
+ {
+ if (segments[i] < 0)
+ {
+ return true;
}
}
- return element;
+ return false;
+ }
+
+ private void InitializeIntermediateObject(CompositeResultElement element)
+ {
+ var selection = element.Selection
+ ?? throw new InvalidOperationException(
+ "Cannot initialize an intermediate target object without selection metadata.");
+
+ if (selection.Type.NamedType() is not IObjectTypeDefinition objectType)
+ {
+ throw new InvalidOperationException(
+ "Cannot initialize an intermediate target object for an abstract selection.");
+ }
+
+ var selectionSet = selection.DeclaringSelectionSet.DeclaringOperation
+ .GetSelectionSet(selection, objectType);
+
+ element.SetObjectValue(selectionSet);
}
public void Dispose()
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs
index eca12b91b20..5fcac27ae68 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using HotChocolate.Execution;
@@ -71,6 +72,11 @@ public bool BuildResult(
return ApplyPocketedErrors(target);
}
+ if (target.ValueKind is JsonValueKind.Undefined)
+ {
+ InitializeTargetObject(source, target);
+ }
+
foreach (var property in source.EnumerateObject())
{
if (!target.TryGetProperty(property.NameSpan, out var resultField))
@@ -123,6 +129,23 @@ public bool BuildResult(
return ApplyPocketedErrors(target);
}
+ private void InitializeTargetObject(
+ SourceResultElement source,
+ CompositeResultElement target)
+ {
+ if (!TryGetSelectionContext(target, out var selection, out var type)
+ || !type.IsCompositeType())
+ {
+ throw new InvalidOperationException(
+ "Cannot initialize a result object without selection metadata.");
+ }
+
+ var objectType = GetType(type, source);
+ var objectSelectionSet = selection.GetSelectionSet(objectType)!;
+
+ target.SetObjectValue(objectSelectionSet);
+ }
+
///
/// Tries to null and assign the to the path
/// of each field selected by .
@@ -260,9 +283,97 @@ private bool BuildResultForInvalidSource(
.SetMessage("Unexpected Execution Error")
.Build();
+ if (target.ValueKind is JsonValueKind.Undefined
+ && !TryInitializeTargetObject(target))
+ {
+ return BuildErrorResultForUndefinedTarget(
+ target,
+ resultSelectionSet,
+ fallbackError,
+ target.CompactPath);
+ }
+
return BuildErrorResult(target, resultSelectionSet, fallbackError, target.CompactPath);
}
+ private static bool TryInitializeTargetObject(CompositeResultElement target)
+ {
+ if (!TryGetSelectionContext(target, out var selection, out var type)
+ || type.NamedType() is not IObjectTypeDefinition objectType)
+ {
+ return false;
+ }
+
+ if (selection.IsLeaf)
+ {
+ return false;
+ }
+
+ target.SetObjectValue(selection.GetSelectionSet(objectType)!);
+ return true;
+ }
+
+ private static bool TryGetSelectionContext(
+ CompositeResultElement target,
+ [NotNullWhen(true)] out Selection? selection,
+ [NotNullWhen(true)] out IType? type)
+ {
+ type = target.Type;
+
+ if (type is null)
+ {
+ selection = null;
+ return false;
+ }
+
+ var current = target;
+ while ((selection = current.Selection) is null)
+ {
+ var parent = current.Parent;
+ if (parent.IsNullOrInvalidated)
+ {
+ type = null;
+ return false;
+ }
+
+ current = parent;
+ }
+
+ return true;
+ }
+
+ private bool BuildErrorResultForUndefinedTarget(
+ CompositeResultElement target,
+ ResultSelectionSet resultSelectionSet,
+ IError error,
+ CompactPath path)
+ {
+ var operation = target.Operation;
+ var errorPath = path.ToPath(operation);
+ var hasResponseNames = false;
+
+ foreach (var responseName in resultSelectionSet.ResponseNames)
+ {
+ hasResponseNames = true;
+ var errorWithPath = ErrorBuilder.FromError(error)
+ .SetPath(errorPath.Append(responseName))
+ .Build();
+
+ _store.AddError(_errorHandler.Handle(errorWithPath));
+ }
+
+ if (!hasResponseNames)
+ {
+ var errorWithPath = ErrorBuilder.FromError(error)
+ .SetPath(errorPath)
+ .Build();
+
+ _store.AddError(_errorHandler.Handle(errorWithPath));
+ }
+
+ return true;
+ }
+
private bool ApplyPocketedErrors(CompositeResultElement target)
{
if (!_store.HasPocketedErrors)
@@ -643,8 +754,9 @@ private bool TryCompleteObjectValue(
// with the current selection set.
if (target.ValueKind is JsonValueKind.Undefined)
{
- var operation = parentSelection.DeclaringSelectionSet.DeclaringOperation;
- var objectSelectionSet = operation.GetSelectionSet(parentSelection, objectType);
+ var objectSelectionSet = parentSelection.GetSelectionSet(objectType)
+ ?? throw new InvalidOperationException(
+ "Cannot initialize a result object without a selection set.");
target.SetObjectValue(objectSelectionSet);
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs
index e14e23f204a..12132a39dda 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs
@@ -15,9 +15,9 @@ public static KeyNotFoundException NodeNotFound(int id)
FusionExecutionResources.OperationPlan_NodeNotFound,
id));
- public static InvalidOperationException DeferredSubPlanParentNotFound(SelectionPath path)
+ public static InvalidOperationException IncrementalPlanParentNotFound(SelectionPath path)
=> new(string.Format(
- FusionExecutionResources.OperationPlan_DeferredSubPlanParentNotFound,
+ FusionExecutionResources.OperationPlan_IncrementalPlanParentNotFound,
path));
public static InvalidOperationException MissingBatchResult(int operationId)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
index 91b7bc537e5..0d2fdb84de4 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs
index 9c011cda0a9..49d7c2ea0b0 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Backlog.cs
@@ -89,7 +89,7 @@ public Backlog PushUnresolvable(
///
public Backlog PushRequirements(
ImmutableStack fieldsWithRequirements,
- int stepId,
+ RequirementConsumer consumer,
int parentDepth)
{
if (fieldsWithRequirements.IsEmpty)
@@ -101,7 +101,7 @@ public Backlog PushRequirements(
foreach (var entry in fieldsWithRequirements.Reverse())
{
- var workItem = new FieldRequirementWorkItem(entry.FieldSelection, stepId)
+ var workItem = new FieldRequirementWorkItem(entry.FieldSelection, consumer)
{
ParentDepth = parentDepth,
Conditions = entry.Conditions
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferEffectiveSetResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferEffectiveSetResolver.cs
index 472b5d823f7..eae73e44963 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferEffectiveSetResolver.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferEffectiveSetResolver.cs
@@ -4,20 +4,18 @@
namespace HotChocolate.Fusion.Planning;
///
-/// Resolves the effective for every leaf
+/// Resolves the effective for every leaf
/// field location collected by .
-/// Fields whose set is empty are non-deferred; fields sharing a non-empty
-/// set form a single subplan, matching the GraphQL incremental-delivery
-/// spec rule that two sibling ... @defer fragments which contribute
-/// the same field collapse into one delivery group.
+/// An empty set means the field belongs to the initial result. Otherwise,
+/// fields sharing the same set can be grouped into one incremental plan.
///
internal static class DeferEffectiveSetResolver
{
///
/// Groups occurrences by and computes the
- /// effective per location.
+ /// effective per location.
///
- public static Dictionary Resolve(List occurrences)
+ public static Dictionary Resolve(List occurrences)
{
var leavesByLocation = new Dictionary();
@@ -31,10 +29,10 @@ public static Dictionary Resolve(List(leavesByLocation.Count);
+ var result = new Dictionary(leavesByLocation.Count);
foreach (var (location, acc) in leavesByLocation)
{
@@ -46,14 +44,14 @@ public static Dictionary Resolve(List _leaves = [];
+ private bool _hasImmediate;
+ private readonly List _leaves = [];
- public void Add(DeferUsage? leaf)
+ public void Add(DeliveryGroup? leaf)
{
if (leaf is null)
{
- _hasNonDeferred = true;
+ _hasImmediate = true;
return;
}
@@ -68,16 +66,16 @@ public void Add(DeferUsage? leaf)
_leaves.Add(leaf);
}
- public DeferUsageSetKey ToEffectiveSet()
+ public DeliveryGroupSetKey ToEffectiveSet()
{
- if (_hasNonDeferred || _leaves.Count == 0)
+ if (_hasImmediate || _leaves.Count == 0)
{
- return DeferUsageSetKey.Empty;
+ return DeliveryGroupSetKey.Empty;
}
// Parent-child pruning: drop any leaf whose ancestor is also
// in the set.
- var pruned = new List(_leaves.Count);
+ var pruned = new List(_leaves.Count);
for (var i = 0; i < _leaves.Count; i++)
{
var ancestor = _leaves[i].Parent;
@@ -110,11 +108,11 @@ public DeferUsageSetKey ToEffectiveSet()
if (pruned.Count == 0)
{
- return DeferUsageSetKey.Empty;
+ return DeliveryGroupSetKey.Empty;
}
pruned.Sort(static (a, b) => a.Id.CompareTo(b.Id));
- return new DeferUsageSetKey(pruned.ToImmutableArray());
+ return new DeliveryGroupSetKey(pruned.ToImmutableArray());
}
}
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferIncrementalPlanResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferIncrementalPlanResult.cs
new file mode 100644
index 00000000000..057dbcb2dfd
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferIncrementalPlanResult.cs
@@ -0,0 +1,18 @@
+using System.Collections.Immutable;
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion.Planning;
+
+///
+/// Describes the planning result for an incremental plan.
+///
+///
+/// The final plan steps for the incremental plan.
+///
+///
+/// The for the incremental plan, or
+/// null when planning produced no operation.
+///
+internal readonly record struct DeferIncrementalPlanResult(
+ ImmutableList Steps,
+ OperationDefinitionNode? InternalOperationDefinition);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOccurrenceCollector.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOccurrenceCollector.cs
index 0a46e9d3216..bcf48b6a2a0 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOccurrenceCollector.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOccurrenceCollector.cs
@@ -5,23 +5,20 @@
namespace HotChocolate.Fusion.Planning;
///
-/// Walks an operation AST and records every leaf field together with the
-/// enclosing @defer chain leaf. The resulting list is the single
-/// source of truth for downstream passes that compute effective defer
-/// usage sets and emit per-set subplans. The walk consults the
-/// map so every leaf
-/// reference shares object identity with the planner pipeline.
+/// Collects leaf field occurrences together with their enclosing delivery
+/// group. The collected occurrences are grouped later by effective delivery
+/// group set to produce incremental plans.
///
internal static class DeferOccurrenceCollector
{
///
/// Collects leaf field occurrences from the given operation, optionally
- /// folding unlabeled nested @defer fragments into their parent
- /// when the wire output would be indistinguishable.
+ /// folding equivalent unlabeled nested @defer fragments into their
+ /// parent delivery group.
///
/// The operation definition to walk.
///
- /// The canonical to
+ /// The to
/// lookup produced by .
///
///
@@ -30,7 +27,7 @@ internal static class DeferOccurrenceCollector
///
public static List Collect(
OperationDefinitionNode operation,
- IReadOnlyDictionary byFragment,
+ IReadOnlyDictionary byFragment,
bool inlineUnlabeledNestedDefers)
{
var occurrences = new List();
@@ -48,9 +45,9 @@ public static List Collect(
private static void CollectOccurrences(
IReadOnlyList selections,
ImmutableArray parentPath,
- DeferUsage? enclosingDefer,
+ DeliveryGroup? enclosingDefer,
NamedTypeNode? parentTypeCondition,
- IReadOnlyDictionary byFragment,
+ IReadOnlyDictionary byFragment,
bool inlineUnlabeledNestedDefers,
List occurrences)
{
@@ -60,13 +57,8 @@ private static void CollectOccurrences(
{
if (fieldNode.SelectionSet is { } childSelectionSet)
{
- // Composite field: we do NOT record an occurrence at this
- // level. The wrapping is reconstructed by the subplan AST
- // builder from the path tree, which looks up the original
- // field (with its arguments/alias/directives) in the
- // source AST. Recording an occurrence here would lead to
- // the field being emitted twice: once as a wrapper and
- // once as a leaf contribution.
+ // Composite fields define the path for child leaves. Only
+ // leaf fields contribute to delivery group sets.
var childPath = parentPath.Add(
new FieldPathSegment(
fieldNode.Name.Value,
@@ -83,10 +75,9 @@ private static void CollectOccurrences(
}
else
{
- // Leaf field: add as a direct contribution at the current
- // path. Effective-set computation groups these by
- // (parentPath, responseName) so sibling @defer fragments
- // that share a leaf are unified into a single subplan.
+ // Leaf fields contribute at the current path. Sibling
+ // @defer fragments that share a leaf are unified into a
+ // single incremental plan.
occurrences.Add(
new FieldOccurrence(
parentPath,
@@ -105,10 +96,9 @@ private static void CollectOccurrences(
if (byFragment.TryGetValue(inlineFragment, out var canonical))
{
- // Fragment is a @defer. Honor the unlabeled-inlining option:
- // an unlabeled nested @defer whose condition matches its
- // parent's is indistinguishable from the parent in the wire
- // output, so we fold its fields into the parent's set.
+ // An unlabeled nested @defer with the same condition as
+ // its parent shares the parent's delivery group when
+ // inlining is enabled.
if (inlineUnlabeledNestedDefers
&& canonical.Label is null
&& enclosingDefer is not null
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs
index aed2dd49c15..560b282f8f0 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs
@@ -7,14 +7,10 @@
namespace HotChocolate.Fusion.Planning;
///
-/// Splits an operation with @defer directives into a main operation
-/// (non-deferred fields only) and one subplan operation per unique
-/// set. The set keying follows the GraphQL
-/// incremental-delivery spec: each field's active defer usage set is the
-/// union of its per-occurrence enclosing @defer leaves (with
-/// parent-child pruning). Two sibling ... @defer fragments that share
-/// a field therefore produce one subplan keyed by both usages for the shared
-/// field, rather than two independent subplans that both fetch it.
+/// Splits an operation with @defer directives into a main operation and
+/// incremental plan operations keyed by effective delivery group sets.
+/// Fields shared by sibling deferred fragments belong to one incremental plan
+/// keyed by the delivery groups that apply to that field.
///
internal sealed class DeferOperationRewriter
{
@@ -26,8 +22,7 @@ internal DeferOperationRewriter(bool inlineUnlabeledNestedDefers = true)
}
///
- /// Fast check whether the operation contains any @defer directives.
- /// Used to avoid the full split for non-deferred operations (the common case).
+ /// Determines whether the operation contains any @defer directives.
///
public static bool HasDeferDirective(OperationDefinitionNode operation)
{
@@ -88,12 +83,11 @@ static bool HasDeferDirectiveOnSpread(FragmentSpreadNode node)
///
/// Splits the given operation at @defer boundaries using the
- /// topology produced by
- /// . The output contains the stripped main
- /// operation (fields whose active DeferUsageSet is empty) plus one
- /// subplan per unique non-empty active set. Sibling @defer
- /// fragments that share a field collapse into a single subplan keyed by
- /// the union of both usages.
+ /// topology produced by
+ /// . The output contains the main operation
+ /// plus incremental plan operations for deferred delivery group sets.
+ /// Sibling @defer fragments that share a field collapse into a
+ /// single incremental plan keyed by the groups that apply to that field.
///
/// The operation definition that may contain @defer directives.
/// The output for .
@@ -113,26 +107,24 @@ public DeferSplitResult Split(
var mainOperation = BuildMainOperation(operation, partitioning.ByFragment);
- if (partitioning.AllDeferUsages.IsEmpty)
+ if (partitioning.AllDeliveryGroups.IsEmpty)
{
return new DeferSplitResult(mainOperation, []);
}
- var subPlanDescriptors = BuildSubPlanOps(operation, occurrences, effectiveSetByLocation);
+ var incrementalPlanDescriptors = BuildIncrementalPlanOperations(operation, occurrences, effectiveSetByLocation);
- return new DeferSplitResult(mainOperation, subPlanDescriptors);
+ return new DeferSplitResult(mainOperation, incrementalPlanDescriptors);
}
- private static ImmutableArray BuildSubPlanOps(
+ private static ImmutableArray BuildIncrementalPlanOperations(
OperationDefinitionNode operation,
List occurrences,
- Dictionary effectiveSetByLocation)
+ Dictionary effectiveSetByLocation)
{
- // Bucket occurrences by effective set. We use a canonical
- // ImmutableArray (sorted by Id) as the set key so the
- // resulting buckets are stable across runs and trivially comparable
- // by sequence equality.
- var buckets = new Dictionary();
+ // Bucket occurrences by effective delivery group set. The key is sorted
+ // by DeliveryGroup Id so the order is stable.
+ var buckets = new Dictionary();
foreach (var occurrence in occurrences)
{
@@ -145,7 +137,7 @@ private static ImmutableArray BuildSubPlanOps(
if (!buckets.TryGetValue(key, out var bucket))
{
- bucket = new SubPlanBucket(key);
+ bucket = new IncrementalPlanBucket(key);
buckets[key] = bucket;
}
@@ -160,35 +152,35 @@ private static ImmutableArray BuildSubPlanOps(
// Synthesize one OperationDefinitionNode per bucket. The output is a
// query operation rooted at Query, with the wrapping path fields
// looked up in the original AST so arguments, aliases and directives
- // are preserved. Buckets are ordered by the smallest DeferUsage Id in
- // the key so subsequent sort-by-Id consumers see a deterministic order.
+ // are preserved. Buckets are ordered by DeliveryGroup Id for stable
+ // output.
var ordered = buckets.Values.ToList();
ordered.Sort(static (a, b) => a.Key.CompareTo(b.Key));
- var subPlanDescriptors = ImmutableArray.CreateBuilder(ordered.Count);
- var descriptorByKey = new Dictionary(ordered.Count);
+ var incrementalPlanDescriptors = ImmutableArray.CreateBuilder(ordered.Count);
+ var descriptorByKey = new Dictionary(ordered.Count);
foreach (var bucket in ordered)
{
- var subPlanOp = BuildSubPlanOperation(operation, bucket);
+ var incrementalPlanOperation = BuildIncrementalPlanOperation(operation, bucket);
var path = DeterminePath(bucket.Key);
var parent = ResolveParentDescriptor(bucket.Key, descriptorByKey);
- var descriptor = new DeferSubPlanDescriptor(
- deferUsageSet: bucket.Key.Items,
- operation: subPlanOp,
+ var descriptor = new IncrementalPlanDescriptor(
+ deliveryGroupSet: bucket.Key.Items,
+ operation: incrementalPlanOperation,
path: path,
parent: parent);
descriptorByKey[bucket.Key] = descriptor;
- subPlanDescriptors.Add(descriptor);
+ incrementalPlanDescriptors.Add(descriptor);
}
- return subPlanDescriptors.ToImmutable();
+ return incrementalPlanDescriptors.ToImmutable();
}
private OperationDefinitionNode BuildMainOperation(
OperationDefinitionNode operation,
- IReadOnlyDictionary byFragment)
+ IReadOnlyDictionary byFragment)
{
var newRoot = StripDeferFromSelectionSet(operation.SelectionSet, byFragment);
return operation.WithSelectionSet(newRoot);
@@ -196,7 +188,7 @@ private OperationDefinitionNode BuildMainOperation(
private SelectionSetNode StripDeferFromSelectionSet(
SelectionSetNode selectionSet,
- IReadOnlyDictionary byFragment)
+ IReadOnlyDictionary byFragment)
{
var selections = new List(selectionSet.Selections.Count);
var modified = false;
@@ -209,7 +201,7 @@ private SelectionSetNode StripDeferFromSelectionSet(
&& byFragment.TryGetValue(inlineFragment, out var usage))
{
// The main operation never keeps @defer fragments: all
- // deferred fields go through their subplan. For a conditional
+ // deferred fields go through their incremental plan. For a conditional
// @defer(if: $variable) we still keep a @skip(if: $variable)
// guarded inline copy so the variable-off runtime path
// fetches the fields eagerly.
@@ -284,9 +276,9 @@ private SelectionSetNode StripDeferFromSelectionSet(
return new SelectionSetNode(selections);
}
- private static OperationDefinitionNode BuildSubPlanOperation(
+ private static OperationDefinitionNode BuildIncrementalPlanOperation(
OperationDefinitionNode rootOperation,
- SubPlanBucket bucket)
+ IncrementalPlanBucket bucket)
{
// Build a tree of PathNodes keyed by FieldPathSegment. Each leaf
// carries the FieldNodes (per optional type condition) contributed
@@ -377,7 +369,7 @@ private static SelectionSetNode BuildSelectionSetFromPathNode(
}
// Child path nodes: wrap in the original field node (preserving
- // name/alias/arguments/directives) so the subplan operation is a
+ // name/alias/arguments/directives) so the incremental plan operation is a
// syntactically valid query against the root schema.
foreach (var (segment, childNode) in node.Children)
{
@@ -458,9 +450,9 @@ private static string FormatPath(ImmutableArray path)
return builder.ToString();
}
- private static SelectionPath DeterminePath(DeferUsageSetKey key)
+ private static SelectionPath DeterminePath(DeliveryGroupSetKey key)
{
- // The subplan roots at the longest shared ancestor of all usages in
+ // The incremental plan roots at the longest shared ancestor of all usages in
// its set. For siblings (same parent), every usage has the same
// anchor path; for the general case we pick the deepest path because
// after parent pruning no two usages in the same set sit on the same
@@ -483,12 +475,12 @@ private static SelectionPath DeterminePath(DeferUsageSetKey key)
return best ?? SelectionPath.Root;
}
- private static DeferSubPlanDescriptor? ResolveParentDescriptor(
- DeferUsageSetKey key,
- Dictionary descriptorByKey)
+ private static IncrementalPlanDescriptor? ResolveParentDescriptor(
+ DeliveryGroupSetKey key,
+ Dictionary descriptorByKey)
{
- // A subplan's "parent" is the subplan whose key contains the parent
- // DeferUsage of any usage in this set. We pick the first usage's
+ // An incremental plan's "parent" is the plan whose key contains the parent
+ // DeliveryGroup of any usage in this set. We pick the first usage's
// parent chain; all other usages in the set share an equivalent
// ancestry after parent pruning.
foreach (var usage in key.Items)
@@ -545,9 +537,9 @@ private static bool HasDeferDirective(InlineFragmentNode node)
return false;
}
- private sealed class SubPlanBucket(DeferUsageSetKey key)
+ private sealed class IncrementalPlanBucket(DeliveryGroupSetKey key)
{
- public DeferUsageSetKey Key { get; } = key;
+ public DeliveryGroupSetKey Key { get; } = key;
public List Occurrences { get; } = [];
public void Add(FieldOccurrence occurrence) => Occurrences.Add(occurrence);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioner.cs
index 063e8bcf178..cd21ac2a1ea 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioner.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioner.cs
@@ -7,9 +7,9 @@
namespace HotChocolate.Fusion.Planning;
///
-/// Walks an operation AST once and produces the tree
+/// Walks an operation AST once and produces the tree
/// for all @defer occurrences. The resulting mapping from
-/// instance to is the
+/// instance to is the
/// single source of truth for defer topology, consumed by the planner pipeline
/// stages (rewriter, compiler) so they do not perform parallel AST walks with
/// divergent state.
@@ -18,7 +18,7 @@ internal static class DeferPartitioner
{
///
/// Walks and produces the complete
- /// tree for every ... @defer inline
+ /// tree for every ... @defer inline
/// fragment encountered. Defer conditions are registered into
/// (passed in rather than owned so the
/// caller can share one collection with the operation being compiled).
@@ -30,8 +30,8 @@ public static DeferPartitioningResult Partition(
ArgumentNullException.ThrowIfNull(operation);
ArgumentNullException.ThrowIfNull(deferConditions);
- var byFragment = new Dictionary(ReferenceEqualityComparer.Instance);
- var ordered = new List();
+ var byFragment = new Dictionary(ReferenceEqualityComparer.Instance);
+ var ordered = new List();
Walk(
operation.SelectionSet.Selections,
@@ -41,10 +41,8 @@ public static DeferPartitioningResult Partition(
byFragment,
ordered);
- // Assign plan-stable Ids in declaration order. Re-create the records
- // via `with { Id = i }` so downstream stages can key off Id for
- // serialization and sorted DeferUsageSet emission. The re-creation
- // also updates parent references to point to the Id-assigned parents.
+ // Assign plan-stable Ids in declaration order and update parent
+ // references to point to the Id-assigned records.
var reassigned = AssignIds(ordered, byFragment);
return new DeferPartitioningResult(reassigned, byFragment);
@@ -52,11 +50,11 @@ public static DeferPartitioningResult Partition(
private static void Walk(
IReadOnlyList selections,
- DeferUsage? parent,
+ DeliveryGroup? parent,
SelectionPath currentPath,
DeferConditionCollection deferConditions,
- Dictionary byFragment,
- List ordered)
+ Dictionary byFragment,
+ List ordered)
{
for (var i = 0; i < selections.Count; i++)
{
@@ -85,7 +83,7 @@ private static void Walk(
var label = GetDeferLabel(inline);
var ifVariable = GetDeferIfVariable(inline);
- var usage = new DeferUsage(label, parent, (byte)deferIndex)
+ var usage = new DeliveryGroup(label, parent, (byte)deferIndex)
{
Path = currentPath,
IfVariable = ifVariable
@@ -105,22 +103,22 @@ private static void Walk(
}
///
- /// Assigns plan-stable Ids to every in declaration
+ /// Assigns plan-stable Ids to every in declaration
/// order and rebuilds parent references against the Id-assigned instances.
/// The map is updated in place so callers see
/// the canonical records.
///
- private static ImmutableArray AssignIds(
- List ordered,
- Dictionary byFragment)
+ private static ImmutableArray AssignIds(
+ List ordered,
+ Dictionary byFragment)
{
if (ordered.Count == 0)
{
return [];
}
- var remap = new Dictionary(ordered.Count, ReferenceEqualityComparer.Instance);
- var builder = ImmutableArray.CreateBuilder(ordered.Count);
+ var remap = new Dictionary(ordered.Count, ReferenceEqualityComparer.Instance);
+ var builder = ImmutableArray.CreateBuilder(ordered.Count);
for (var i = 0; i < ordered.Count; i++)
{
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioningResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioningResult.cs
index e301486e28c..d187c99c340 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioningResult.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferPartitioningResult.cs
@@ -6,26 +6,26 @@ namespace HotChocolate.Fusion.Planning;
///
/// Result of . Carries the complete
-/// tree (in declaration order) together with the
+/// tree (in declaration order) together with the
/// -keyed lookup so downstream stages can
/// resolve any ... @defer in the same AST to the canonical
-/// instance.
+/// instance.
///
internal sealed class DeferPartitioningResult(
- ImmutableArray allDeferUsages,
- IReadOnlyDictionary byFragment)
+ ImmutableArray allDeliveryGroups,
+ IReadOnlyDictionary byFragment)
{
///
- /// Every discovered in the operation, in
+ /// Every discovered in the operation, in
/// declaration order (depth-first, left-to-right as encountered).
- /// The array index equals each usage's .
+ /// The array index equals each usage's .
///
- public ImmutableArray AllDeferUsages { get; } = allDeferUsages;
+ public ImmutableArray AllDeliveryGroups { get; } = allDeliveryGroups;
///
/// Maps each ... @defer inline fragment to its canonical
- /// . Lookup is by reference identity on the AST
+ /// . Lookup is by reference identity on the AST
/// node.
///
- public IReadOnlyDictionary ByFragment { get; } = byFragment;
+ public IReadOnlyDictionary ByFragment { get; } = byFragment;
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferRewriterTypes.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferRewriterTypes.cs
index dcfcb91d5dc..9cbd0a0c766 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferRewriterTypes.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferRewriterTypes.cs
@@ -6,9 +6,9 @@ namespace HotChocolate.Fusion.Planning;
///
/// The result of splitting an operation at @defer boundaries: a stripped
-/// main operation plus one subplan descriptor per unique
-/// set.
+/// main operation plus one incremental plan descriptor per unique
+/// set.
///
internal readonly record struct DeferSplitResult(
OperationDefinitionNode MainOperation,
- ImmutableArray SubPlanDescriptors);
+ ImmutableArray IncrementalPlanDescriptors);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferSubPlanDescriptor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferSubPlanDescriptor.cs
deleted file mode 100644
index 3821987a6dc..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferSubPlanDescriptor.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System.Collections.Immutable;
-using HotChocolate.Execution;
-using HotChocolate.Fusion.Execution.Nodes;
-using HotChocolate.Language;
-
-namespace HotChocolate.Fusion.Planning;
-
-///
-/// Describes a single subplan: the compiled operation for a unique
-/// set together with the set itself (sorted by
-/// for stability). Fields whose active defer
-/// usage set equals are fetched by this subplan
-/// and delivered to every delivery group in the set.
-///
-internal sealed class DeferSubPlanDescriptor(
- ImmutableArray deferUsageSet,
- OperationDefinitionNode operation,
- SelectionPath path,
- DeferSubPlanDescriptor? parent)
-{
- ///
- /// The set this subplan is keyed by, sorted
- /// ascending by .
- ///
- public ImmutableArray DeferUsageSet { get; } = deferUsageSet;
-
- ///
- /// The compiled operation for this subplan.
- ///
- public OperationDefinitionNode Operation { get; internal set; } = operation;
-
- ///
- /// The path where the subplan's data is inserted in the response tree.
- /// Derived from the deepest in the set.
- ///
- public SelectionPath Path { get; } = path;
-
- ///
- /// The parent subplan for nested @defer, or null for a
- /// top-level subplan. Determined by walking each set member's
- /// chain and finding the first already-
- /// emitted subplan whose set contains a matching ancestor.
- ///
- public DeferSubPlanDescriptor? Parent { get; } = parent;
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferUsageSetKey.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeliveryGroupSetKey.cs
similarity index 76%
rename from src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferUsageSetKey.cs
rename to src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeliveryGroupSetKey.cs
index 35d3aabc011..c04a03a9277 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferUsageSetKey.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeliveryGroupSetKey.cs
@@ -4,19 +4,18 @@
namespace HotChocolate.Fusion.Planning;
///
-/// A canonical key for a set. The wrapped array is
-/// sorted ascending by so sequence equality
-/// doubles as set equality.
+/// A key for a set, sorted ascending by
+/// . The set can be empty.
///
-internal readonly record struct DeferUsageSetKey(ImmutableArray Items)
- : IEquatable
- , IComparable
+internal readonly record struct DeliveryGroupSetKey(ImmutableArray Items)
+ : IEquatable
+ , IComparable
{
- public static DeferUsageSetKey Empty { get; } = new([]);
+ public static DeliveryGroupSetKey Empty { get; } = new([]);
public bool IsEmpty => Items.IsDefaultOrEmpty;
- public bool Equals(DeferUsageSetKey other)
+ public bool Equals(DeliveryGroupSetKey other)
{
if (Items.IsDefaultOrEmpty)
{
@@ -60,7 +59,7 @@ public override int GetHashCode()
return hash.ToHashCode();
}
- public int CompareTo(DeferUsageSetKey other)
+ public int CompareTo(DeliveryGroupSetKey other)
{
if (Items.IsDefaultOrEmpty)
{
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldLocation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldLocation.cs
index 8cdc507da19..93a41a61b17 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldLocation.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldLocation.cs
@@ -5,7 +5,7 @@ namespace HotChocolate.Fusion.Planning;
///
/// Identifies a leaf field by the path to its parent object plus its
/// response name. Used as a dictionary key when partitioning fields by
-/// effective . Equality is sequence-based on
+/// effective . Equality is sequence-based on
/// , which is why this type overrides the record-struct
/// default (the default would compare the underlying ImmutableArray by
/// reference, not by contents).
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldOccurrence.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldOccurrence.cs
index 2a2ff979b9a..6f7689ace65 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldOccurrence.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldOccurrence.cs
@@ -14,5 +14,5 @@ internal sealed record FieldOccurrence(
ImmutableArray ParentPath,
string ResponseName,
FieldNode FieldNode,
- DeferUsage? EnclosingDefer,
+ DeliveryGroup? EnclosingDeliveryGroup,
NamedTypeNode? TypeCondition);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldWithRequirementWorkItem.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldWithRequirementWorkItem.cs
index e9a59a8946f..05897be29c1 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldWithRequirementWorkItem.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/FieldWithRequirementWorkItem.cs
@@ -5,13 +5,11 @@ namespace HotChocolate.Fusion.Planning;
internal sealed record FieldRequirementWorkItem(
FieldSelection Selection,
- int StepId,
+ RequirementConsumer Consumer,
Lookup? Lookup = null)
: WorkItem
{
public ExecutionNodeCondition[] Conditions { get; init; } = [];
- public int StepIndex => StepId - 1;
-
public override double Cost => Lookup is null ? 1 : 2;
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/IncrementalPlanDescriptor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/IncrementalPlanDescriptor.cs
new file mode 100644
index 00000000000..139241e2eb4
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/IncrementalPlanDescriptor.cs
@@ -0,0 +1,48 @@
+using System.Collections.Immutable;
+using HotChocolate.Execution;
+using HotChocolate.Fusion.Execution.Nodes;
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion.Planning;
+
+///
+/// Describes an incremental plan operation and the delivery group set that keys
+/// it. Fields whose effective delivery group set equals
+/// belong to this plan.
+///
+internal sealed class IncrementalPlanDescriptor(
+ ImmutableArray deliveryGroupSet,
+ OperationDefinitionNode operation,
+ SelectionPath path,
+ IncrementalPlanDescriptor? parent)
+{
+ ///
+ /// The set this incremental plan is keyed by,
+ /// sorted ascending by .
+ ///
+ public ImmutableArray DeliveryGroupSet { get; } = deliveryGroupSet;
+
+ ///
+ /// The operation definition for this incremental plan.
+ ///
+ public OperationDefinitionNode Operation { get; internal set; } = operation;
+
+ ///
+ /// The anchor path for this incremental plan.
+ ///
+ public SelectionPath Path { get; } = path;
+
+ ///
+ /// The parent incremental plan for nested @defer, or null
+ /// for a top-level incremental plan.
+ ///
+ public IncrementalPlanDescriptor? Parent { get; } = parent;
+
+ ///
+ /// Requirements this incremental plan resolves from its enclosing plan
+ /// scope. Each entry maps a variable used by this plan to a selection in
+ /// the enclosing scope's result. Entries are keyed by requirement name.
+ ///
+ public SortedDictionary Requirements { get; } =
+ new(StringComparer.Ordinal);
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
index d3dda452717..37cf3f453d5 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs
@@ -24,8 +24,8 @@ private OperationPlan BuildExecutionPlan(
Operation operation,
OperationDefinitionNode operationDefinition,
ImmutableList planSteps,
- ImmutableArray deliveryGroups,
- ImmutableArray deferredSubPlans,
+ ImmutableArray deliveryGroups,
+ ImmutableArray incrementalPlans,
int searchSpace,
int expandedNodes,
CancellationToken cancellationToken)
@@ -77,44 +77,48 @@ private OperationPlan BuildExecutionPlan(
node.Seal();
}
- // Resolve each deferred subplan's parent execution node id now that both
- // the main plan's nodes and each subplan's own nodes are built.
- // Top-level subplans resolve against the main plan's allNodes; a nested
- // subplan resolves against the enclosing subplan's AllNodes, where the
- // enclosing subplan is the one whose DeliveryGroups contain the parent
- // DeferUsage of any usage in the nested subplan's key set.
- if (!deferredSubPlans.IsDefaultOrEmpty)
- {
- foreach (var subPlan in deferredSubPlans)
- {
- var path = ResolveSubPlanPath(subPlan);
- var parent = ResolveSubPlanParent(subPlan, deferredSubPlans);
- var owningNodes = parent is null ? allNodes : parent.AllNodes;
- subPlan.ParentNodeId = ResolveDeferParentNodeId(owningNodes, path)
- ?? throw ThrowHelper.DeferredSubPlanParentNotFound(path);
- }
- }
-
- return OperationPlan.Create(
+ var operationPlan = OperationPlan.Create(
operation,
rootNodes,
allNodes,
deliveryGroups,
- deferredSubPlans,
+ incrementalPlans,
searchSpace,
expandedNodes);
+
+ // Assign parent node ids and stable ids after the root plan and
+ // incremental plan nodes have been built. Nested incremental plans are
+ // associated with the plan that owns their parent delivery group.
+ if (!incrementalPlans.IsDefaultOrEmpty)
+ {
+ // Plan-time id: the parent id is known here because the root plan
+ // was just created above, and OperationPlan.Create's content hash
+ // does not include incremental plan ids.
+ for (var i = 0; i < incrementalPlans.Length; i++)
+ {
+ var incrementalPlan = incrementalPlans[i];
+ incrementalPlan.Id = $"{operationPlan.Id}#{i}";
+
+ var path = ResolveIncrementalPlanPath(incrementalPlan);
+ var parent = ResolveIncrementalPlanParent(incrementalPlan, incrementalPlans);
+ var owningNodes = parent is null ? allNodes : parent.AllNodes;
+ incrementalPlan.ParentNodeId = ResolveDeferParentNodeId(owningNodes, path)
+ ?? throw ThrowHelper.IncrementalPlanParentNotFound(path);
+ }
+ }
+
+ return operationPlan;
}
///
- /// Returns the path at which the given subplan's data is inserted into the
- /// result tree. Equivalent to the deepest
- /// across the subplan's delivery groups.
+ /// Returns the anchor path for the given incremental plan. When the plan
+ /// has multiple delivery groups, the deepest delivery group path is used.
///
- private static SelectionPath ResolveSubPlanPath(ExecutionSubPlan subPlan)
+ private static SelectionPath ResolveIncrementalPlanPath(IncrementalPlan incrementalPlan)
{
SelectionPath? best = null;
- foreach (var usage in subPlan.DeliveryGroups)
+ foreach (var usage in incrementalPlan.DeliveryGroups)
{
if (usage.Path is null)
{
@@ -131,23 +135,21 @@ private static SelectionPath ResolveSubPlanPath(ExecutionSubPlan subPlan)
}
///
- /// Finds the enclosing subplan for the given nested subplan. A subplan is
- /// nested inside another subplan when some in its
- /// key set has a that belongs to another
- /// subplan's key set. Returns null for top-level subplans.
+ /// Finds the enclosing incremental plan for the given incremental plan.
+ /// Returns null for top-level incremental plans.
///
- private static ExecutionSubPlan? ResolveSubPlanParent(
- ExecutionSubPlan subPlan,
- ImmutableArray subPlans)
+ private static IncrementalPlan? ResolveIncrementalPlanParent(
+ IncrementalPlan incrementalPlan,
+ ImmutableArray incrementalPlans)
{
- foreach (var usage in subPlan.DeliveryGroups)
+ foreach (var usage in incrementalPlan.DeliveryGroups)
{
var ancestor = usage.Parent;
while (ancestor is not null)
{
- foreach (var candidate in subPlans)
+ foreach (var candidate in incrementalPlans)
{
- if (ReferenceEquals(candidate, subPlan))
+ if (ReferenceEquals(candidate, incrementalPlan))
{
continue;
}
@@ -545,7 +547,7 @@ private static OperationExecutionNode CreateOperationExecutionNode(
selectionSetNode = PruneNonValueTypeChildren(selectionSetNode, operationStep.Type, schema);
var resultSelectionSet = ResultSelectionSet.Create(selectionSetNode, schema);
- return new OperationExecutionNode(
+ var node = new OperationExecutionNode(
operationStep.Id,
operationSource,
operationStep.SchemaName,
@@ -556,6 +558,13 @@ private static OperationExecutionNode CreateOperationExecutionNode(
resultSelectionSet,
operationStep.Conditions,
requiresFileUpload);
+
+ foreach (var parentDependency in operationStep.ParentDependencies)
+ {
+ node.AddParentDependency(parentDependency.StepId);
+ }
+
+ return node;
}
private static void MergeAndBatchOperations(
@@ -924,7 +933,8 @@ private static void ReplaceMembersWithBatchNode(
private static BatchOperationDefinition CreateBatchOperationDefinition(MergeResult merge)
{
var primary = merge.Primary;
- return new BatchOperationDefinition(
+
+ var definition = new BatchOperationDefinition(
primary.Id,
merge.CanonicalOp,
primary.SchemaName,
@@ -935,11 +945,18 @@ private static BatchOperationDefinition CreateBatchOperationDefinition(MergeResu
primary.ResultSelectionSet,
primary.Conditions.ToArray(),
primary.RequiresFileUpload);
+
+ foreach (var parentDependency in primary.BufferedParentDependencies)
+ {
+ definition.AddParentDependency(parentDependency);
+ }
+
+ return definition;
}
private static SingleOperationDefinition CreateSingleOperationDefinition(OperationExecutionNode member)
{
- return new SingleOperationDefinition(
+ var definition = new SingleOperationDefinition(
member.Id,
member.Operation,
member.SchemaName,
@@ -950,6 +967,13 @@ private static SingleOperationDefinition CreateSingleOperationDefinition(Operati
member.ResultSelectionSet,
member.Conditions.ToArray(),
member.RequiresFileUpload);
+
+ foreach (var parentDependency in member.BufferedParentDependencies)
+ {
+ definition.AddParentDependency(parentDependency);
+ }
+
+ return definition;
}
///
@@ -1062,7 +1086,7 @@ private static void WireOperationDependencies(ExecutionPlanBuildContext ctx)
continue;
}
- // For a standalone operation node, wire dependencies directly.
+ // For a standalone operation node, attach dependencies directly.
foreach (var dependencyId in stepDependencies)
{
if (!ctx.ExecutionNodes.TryGetValue(dependencyId, out var childEntry)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs
index 76070997363..ff4cdb40d6e 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs
@@ -1,87 +1,138 @@
using System.Collections.Immutable;
+using HotChocolate.Execution;
using HotChocolate.Fusion.Execution.Nodes;
+using HotChocolate.Fusion.Rewriters;
+using HotChocolate.Fusion.Types;
using HotChocolate.Language;
+using HotChocolate.Types;
namespace HotChocolate.Fusion.Planning;
public sealed partial class OperationPlanner
{
///
- /// Plans each independently and
- /// materializes one per unique
- /// DeferUsageSet. The resulting subplan carries its own compiled
- /// together with the
- /// set that keys it (sorted by
- /// for stable serialization). The subplan's data is delivered on the wire
- /// to every in its
- /// .
+ /// Plans each and records any
+ /// parent-scope requirements needed by its incremental plan.
///
- private ImmutableArray PlanDeferredSubPlans(
+ private ImmutableArray RouteIncrementalPlans(
string id,
- string hash,
DeferSplitResult splitResult,
+ PlanContextGraph contextGraph,
bool emitPlannerEvents,
CancellationToken cancellationToken)
{
- if (splitResult.SubPlanDescriptors.IsEmpty)
+ if (splitResult.IncrementalPlanDescriptors.IsEmpty)
{
return [];
}
- var subPlans = ImmutableArray.CreateBuilder(splitResult.SubPlanDescriptors.Length);
+ var routingStates = ImmutableArray.CreateBuilder