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( + splitResult.IncrementalPlanDescriptors.Length); - for (var i = 0; i < splitResult.SubPlanDescriptors.Length; i++) + for (var i = 0; i < splitResult.IncrementalPlanDescriptors.Length; i++) { cancellationToken.ThrowIfCancellationRequested(); - var descriptor = splitResult.SubPlanDescriptors[i]; - var subPlanId = i; + var descriptor = splitResult.IncrementalPlanDescriptors[i]; - // Plan the subplan as an independent query. - var (deferredPlanSteps, deferredInternalOp) = PlanDeferredSubPlan( + var parentContext = contextGraph.GetParentContext(descriptor); + var incrementalPlanResult = PlanIncrementalPlan( id, descriptor, - subPlanId, + i, emitPlannerEvents, cancellationToken); - // Build execution nodes for this subplan. + var rewrittenIncrementalPlan = ApplyDeferRequirementsToParent( + descriptor, + incrementalPlanResult.Steps, + parentContext, + contextGraph); + + var registeredInternalOp = incrementalPlanResult.InternalOperationDefinition ?? descriptor.Operation; + contextGraph.RegisterDeferContext( + descriptor, + rewrittenIncrementalPlan, + SelectionSetIndexer.Create(registeredInternalOp), + registeredInternalOp); + + routingStates.Add(new DeferRoutingState(descriptor, i)); + } + + return routingStates.ToImmutable(); + } + + /// + /// Builds the incremental plans for the routed descriptors. + /// + private ImmutableArray BuildIncrementalPlans( + string id, + string hash, + ImmutableArray routingStates, + PlanContextGraph contextGraph, + CancellationToken cancellationToken) + { + if (routingStates.IsDefaultOrEmpty) + { + return []; + } + + var incrementalPlansBuilder = ImmutableArray.CreateBuilder(routingStates.Length); + + foreach (var routingState in routingStates) + { + cancellationToken.ThrowIfCancellationRequested(); + + var descriptor = routingState.Descriptor; + + // Use the registered scope state because nested descriptors may + // have added parent-scope requirements. + var finalSteps = contextGraph.GetRegisteredSteps(descriptor); + var registeredInternalOp = contextGraph.GetRegisteredInternalOperation(descriptor); + var (rootNodes, allNodes) = BuildDeferredExecutionNodes( - deferredInternalOp ?? descriptor.Operation, - deferredPlanSteps); - - // Compile a standalone Operation for this subplan's result mapping. - var compiledOp = deferredInternalOp ?? descriptor.Operation; - compiledOp = AddTypeNameToAbstractSelections( - compiledOp, - _schema.GetOperationType(compiledOp.Operation)); + registeredInternalOp, + finalSteps); + + var compiledOp = AddTypeNameToAbstractSelections( + registeredInternalOp, + _schema.GetOperationType(registeredInternalOp.Operation)); var deferredOperation = _operationCompiler.Compile( - id + "#defer_" + subPlanId, - hash + "#defer_" + subPlanId, + id + "#defer_" + routingState.Index, + hash + "#defer_" + routingState.Index, compiledOp); - // The descriptor carries the DeferUsageSet already sorted by Id - // (see DeferOperationRewriter.Split); capture it as this subplan's - // delivery groups. - var subPlan = new ExecutionSubPlan( + var planScopeRequirements = descriptor.Requirements.Count == 0 + ? ImmutableArray.Empty + : [.. descriptor.Requirements.Values]; + + var incrementalPlan = new IncrementalPlan( deferredOperation, rootNodes, allNodes, - descriptor.DeferUsageSet); + descriptor.DeliveryGroupSet, + planScopeRequirements); - subPlans.Add(subPlan); + incrementalPlansBuilder.Add(incrementalPlan); } - return subPlans.ToImmutable(); + return incrementalPlansBuilder.ToImmutable(); } /// - /// Plans a single subplan using the A* planner. + /// Captures the routed descriptor order used when building incremental plans. + /// + private readonly record struct DeferRoutingState( + IncrementalPlanDescriptor Descriptor, + int Index); + + /// + /// Plans a single incremental plan descriptor. /// - private (ImmutableList Steps, OperationDefinitionNode? InternalOp) PlanDeferredSubPlan( + private DeferIncrementalPlanResult PlanIncrementalPlan( string operationId, - DeferSubPlanDescriptor descriptor, - int subPlanId, + IncrementalPlanDescriptor descriptor, + int incrementalPlanId, bool emitPlannerEvents, CancellationToken cancellationToken) { @@ -93,7 +144,7 @@ private ImmutableArray PlanDeferredSubPlans( if (node.Backlog.IsEmpty) { - return ([], null); + return new DeferIncrementalPlanResult([], null); } var possiblePlans = new PlanQueue(_schema); @@ -113,18 +164,763 @@ node with possiblePlans.Enqueue(node); } - var plan = Plan(operationId + "#defer_" + subPlanId, possiblePlans, emitPlannerEvents, cancellationToken); + var plan = Plan(operationId + "#defer_" + incrementalPlanId, possiblePlans, emitPlannerEvents, cancellationToken); if (!plan.HasValue) { - return ([], null); + return new DeferIncrementalPlanResult([], null); + } + + return new DeferIncrementalPlanResult( + plan.Value.Steps, + plan.Value.InternalOperationDefinition); + } + + /// + /// Moves requirements that can be satisfied by the enclosing plan scope out + /// of the incremental plan and records them as parent-scope dependencies. + /// + /// + /// The updated incremental plan step list, or the original list when no + /// parent-scope requirements can be moved. + /// + private ImmutableList ApplyDeferRequirementsToParent( + IncrementalPlanDescriptor descriptor, + ImmutableList incrementalPlanSteps, + ParentPlanContext parentContext, + PlanContextGraph contextGraph) + { + // Parent-scope requirements can only be moved when an initial step has + // dependent steps. + if (incrementalPlanSteps.Count < 2 + || incrementalPlanSteps[0] is not OperationPlanStep selfFetch + || selfFetch.SchemaName is null + || selfFetch.Dependents.IsEmpty) + { + return incrementalPlanSteps; + } + + // Collect steps that depend directly on the initial requirement step. + var downstreamByStepId = new Dictionary(); + foreach (var dependentStepId in selfFetch.Dependents) + { + if (incrementalPlanSteps.ById(dependentStepId) is OperationPlanStep dependentStep) + { + downstreamByStepId[dependentStepId] = dependentStep; + } + } + + if (downstreamByStepId.Count == 0) + { + return incrementalPlanSteps; + } + + // Route each requirement through a scope-walker: at each scope, try + // same-subgraph inline first, then cross-subgraph promote; on both + // failing, escalate to the next enclosing scope. The walker exits on + // the first scope that successfully serves the requirement. At root + // exhaustion, throw. Per-scope mutations (step-list + internal op) + // are accumulated by scope identity and committed back to the graph + // after the loop. + var resolver = new ValueSelectionToSelectionSetRewriter(_schema); + ScopeState? rootScopeState = null; + var enclosingScopeStates = new Dictionary(); + var lifted = new List(); + var promotedIncrementalPlanStepIds = new HashSet(); + + ScopeState ScopeStateFor(ParentPlanContext scope) + { + if (scope.OwnerDescriptor is { } owner) + { + if (!enclosingScopeStates.TryGetValue(owner, out var enclosingState)) + { + enclosingState = new ScopeState(scope.ParentSteps, scope.ParentInternalOperation); + enclosingScopeStates[owner] = enclosingState; + } + return enclosingState; + } + + if (rootScopeState is null) + { + rootScopeState = new ScopeState(scope.ParentSteps, scope.ParentInternalOperation); + } + return rootScopeState; + } + + foreach (var (downstreamStepId, downstreamStep) in downstreamByStepId) + { + foreach (var (_, requirement) in downstreamStep.Requirements) + { + // Requirements whose resolving path sits outside the + // self-fetch's target are not our business; the self-fetch + // shape is an existence proof that every requirement it + // satisfies is reachable through its own target path. + if (!selfFetch.Target.IsParentOfOrSame(requirement.Path)) + { + throw CreateUnsatisfiableDeferRequirementException( + selfFetch, + requirement, + "requirement path lies outside the defer's self-fetch target"); + } + + var resolved = false; + var walkScope = parentContext; + + while (walkScope is not null) + { + var scopeState = ScopeStateFor(walkScope); + + // Same-subgraph attempt: inline the requirement's field + // selection into an existing parent-scope step that + // already targets the path on the same source schema. + if (TryInlineDeferRequirementInScope( + requirement, + selfFetch.SchemaName, + resolver, + scopeState, + out var parentStepId)) + { + lifted.Add(new LiftedDeferRequirement(requirement, downstreamStepId, parentStepId)); + resolved = true; + break; + } + + // When no existing parent-scope step can supply the + // requirement, add a dedicated step to the enclosing scope. + var (stepsAfterPromotion, newStepId, promotedIncrementalPlanStepId) = + PlanCrossSubgraphDeferRequirement( + requirement, + downstreamStep, + incrementalPlanSteps, + scopeState.Steps); + + if (newStepId is { } resolvedStepId) + { + scopeState.Steps = stepsAfterPromotion; + lifted.Add(new LiftedDeferRequirement(requirement, downstreamStepId, resolvedStepId)); + + if (promotedIncrementalPlanStepId is { } pid) + { + promotedIncrementalPlanStepIds.Add(pid); + } + + resolved = true; + break; + } + + // This scope cannot serve the requirement. Escalate to + // the next enclosing scope and retry. At root exhaustion + // the loop exits with resolved=false. + walkScope = contextGraph.GetEnclosingScope(walkScope); + } + + if (!resolved) + { + throw CreateUnsatisfiableDeferRequirementException( + selfFetch, + requirement, + "no parent-reachable subgraph provides the required field"); + } + } + } + + if (lifted.Count == 0) + { + return incrementalPlanSteps; + } + + // Remove moved steps from the incremental plan and record parent-scope + // dependencies on the remaining steps. + var rewrittenIncrementalPlan = RewriteIncrementalPlanAfterDeferRequirementRouting( + incrementalPlanSteps, + selfFetch, + lifted, + promotedIncrementalPlanStepIds); + + // Record the parent-scope requirements on the descriptor. + foreach (var step in rewrittenIncrementalPlan) + { + if (step is not OperationPlanStep operationStep) + { + continue; + } + + foreach (var (key, requirement) in operationStep.Requirements) + { + descriptor.Requirements.TryAdd(key, requirement); + } + } + + // Publish scope updates before processing additional descriptors. + if (rootScopeState is not null) + { + contextGraph.UpdateRootSteps(rootScopeState.Steps); + contextGraph.UpdateRootInternalOperation(rootScopeState.InternalOperation); + } + foreach (var (ownerDescriptor, state) in enclosingScopeStates) + { + contextGraph.UpdateDeferContext(ownerDescriptor, state.Steps, state.InternalOperation); + } + + return rewrittenIncrementalPlan; + } + + /// + /// Tries to inline the field selection implied by a defer's + /// into a parent-scope step that already + /// targets the requirement's path on the same source schema. Uses the + /// planner's same helper as intra-plan field-requirement inlining so the + /// operation document rewrite and selection-set index bookkeeping match. + /// Mirrors the inline onto the parent's internal operation so the + /// compiled parent Operation carries the field (the runtime composite + /// result document relies on that field being preserved during result + /// merging). + /// + private bool TryInlineDeferRequirementInScope( + OperationRequirement requirement, + string schemaName, + ValueSelectionToSelectionSetRewriter resolver, + ScopeState scopeState, + out int parentStepId) + { + parentStepId = 0; + + for (var i = 0; i < scopeState.Steps.Count; i++) + { + if (scopeState.Steps[i] is not OperationPlanStep parentStep) + { + continue; + } + + if (!string.Equals(parentStep.SchemaName, schemaName, StringComparison.Ordinal)) + { + continue; + } + + if (!parentStep.Target.IsParentOfOrSame(requirement.Path)) + { + continue; + } + + if (!TryLocateDeferRequirementTarget( + parentStep, + requirement.Path, + out _, + out var targetType)) + { + continue; + } + + SelectionSetNode injectionSelections; + try + { + injectionSelections = resolver.Rewrite(requirement.Map, targetType); + } + catch + { + continue; + } + + var stepIndex = SelectionSetIndexer.Create(parentStep.Definition).ToBuilder(); + var targetId = stepIndex.GetId( + LocateSelectionSetAtPath(parentStep.Definition.SelectionSet, requirement.Path, parentStep.Target.Length)); + + var dependentsBeforeInline = parentStep.Dependents; + + if (!TryInlineSelectionSetIntoStep( + parentStep, + targetId, + targetType, + requirement.Path, + injectionSelections, + dependentStepId: 0, + stepIndex, + out var updatedParentStep, + out _)) + { + continue; + } + + updatedParentStep = updatedParentStep with + { + Dependents = dependentsBeforeInline, + SelectionSets = SelectionSetIndexer.CreateIdSet(updatedParentStep.Definition.SelectionSet, stepIndex) + }; + + scopeState.Steps = scopeState.Steps.SetItem(i, updatedParentStep); + + // Mirror the inline onto the enclosing scope's internal operation + // so the compiled Operation (and the runtime composite result + // document bound to it) also carries the selection. + if (TryLocateInternalSelectionSetAtPath( + scopeState.InternalOperation.SelectionSet, + requirement.Path, + out var internalTargetSelectionSet)) + { + var overallIndex = SelectionSetIndexer.Create(scopeState.InternalOperation).ToBuilder(); + var overallTargetId = overallIndex.GetId(internalTargetSelectionSet); + scopeState.InternalOperation = InlineSelectionsIntoOverallOperation( + scopeState.InternalOperation, + overallIndex, + targetType, + overallTargetId, + injectionSelections); + } + + parentStepId = parentStep.Id; + return true; + } + + return false; + } + + /// + /// Mutable planning state for an enclosing scope while parent-scope + /// requirements are resolved. + /// + private sealed class ScopeState( + ImmutableList steps, + OperationDefinitionNode internalOperation) + { + public ImmutableList Steps { get; set; } = steps; + + public OperationDefinitionNode InternalOperation { get; set; } = internalOperation; + } + + private static bool TryLocateInternalSelectionSetAtPath( + SelectionSetNode root, + SelectionPath path, + out SelectionSetNode targetSelectionSet) + { + var currentSet = root; + + for (var i = 0; i < path.Length; i++) + { + var segment = path[i]; + + switch (segment.Kind) + { + case SelectionPathSegmentKind.Field: + FieldNode? fieldNode = null; + foreach (var selection in currentSet.Selections) + { + if (selection is FieldNode candidate + && (candidate.Alias?.Value == segment.Name || candidate.Name.Value == segment.Name)) + { + fieldNode = candidate; + break; + } + } + + if (fieldNode?.SelectionSet is null) + { + targetSelectionSet = null!; + return false; + } + + currentSet = fieldNode.SelectionSet; + break; + + case SelectionPathSegmentKind.InlineFragment: + InlineFragmentNode? fragment = null; + foreach (var selection in currentSet.Selections) + { + if (selection is InlineFragmentNode candidate + && candidate.TypeCondition?.Name.Value == segment.Name) + { + fragment = candidate; + break; + } + } + + if (fragment is null) + { + targetSelectionSet = null!; + return false; + } + + currentSet = fragment.SelectionSet; + break; + + default: + targetSelectionSet = null!; + return false; + } + } + + targetSelectionSet = currentSet; + return true; + } + + /// + /// Creates a parent-scope step for a requirement that cannot be satisfied + /// by an existing parent-scope step. + /// + private (ImmutableList UpdatedParentSteps, int? NewStepId, int? PromotedIncrementalPlanStepId) + PlanCrossSubgraphDeferRequirement( + OperationRequirement requirement, + OperationPlanStep consumingStep, + ImmutableList incrementalPlanSteps, + ImmutableList parentSteps) + { + if (TryFindDeferRequirementProvider(incrementalPlanSteps, consumingStep, requirement) is not { } providerStep) + { + return (parentSteps, null, null); + } + + if (providerStep.SchemaName is null) + { + return (parentSteps, null, null); + } + + var index = SelectionSetIndexer.Create(providerStep.Definition); + var newParentStepId = parentSteps.NextId(); + var promotedStep = providerStep with + { + Id = newParentStepId, + Dependents = ImmutableHashSet.Empty, + ParentDependencies = ImmutableHashSet.Empty, + SelectionSets = SelectionSetIndexer.CreateIdSet(providerStep.Definition.SelectionSet, index), + RootSelectionSetId = index.GetId(providerStep.Definition.SelectionSet) + }; + + return (parentSteps.Add(promotedStep), newParentStepId, providerStep.Id); + } + + /// + /// Locates the incremental plan step that produces 's + /// value for . The provider is a step the + /// consuming step depends on whose target is a parent-of-or-same ancestor + /// of the requirement path (so the provider's tree reaches the required + /// entity) and whose selection set contains the requirement's mapped + /// field. + /// + private static OperationPlanStep? TryFindDeferRequirementProvider( + ImmutableList incrementalPlanSteps, + OperationPlanStep consumingStep, + OperationRequirement requirement) + { + var requirementFieldName = ExtractRootFieldName(requirement.Map.ToString()); + + if (requirementFieldName is null) + { + return null; + } + + foreach (var step in incrementalPlanSteps) + { + if (step is not OperationPlanStep candidate + || candidate.Id == consumingStep.Id + || !candidate.Dependents.Contains(consumingStep.Id)) + { + continue; + } + + if (!candidate.Target.IsParentOfOrSame(requirement.Path)) + { + continue; + } + + if (SelectionSetContainsField(candidate.Definition.SelectionSet, requirementFieldName)) + { + return candidate; + } + } + + return null; + } + + /// + /// Removes steps that moved to the parent scope and records parent + /// dependencies on the remaining steps. + /// + private static ImmutableList RewriteIncrementalPlanAfterDeferRequirementRouting( + ImmutableList incrementalPlanSteps, + OperationPlanStep selfFetch, + List lifted, + HashSet promotedIncrementalPlanStepIds) + { + var parentRefsByStepId = new Dictionary.Builder>(); + foreach (var entry in lifted) + { + if (!parentRefsByStepId.TryGetValue(entry.DownstreamStepId, out var builder)) + { + builder = ImmutableHashSet.CreateBuilder(); + parentRefsByStepId[entry.DownstreamStepId] = builder; + } + + builder.Add(new ParentStepRef(entry.ParentStepId)); + } + + var droppedStepIds = new HashSet(promotedIncrementalPlanStepIds) { selfFetch.Id }; + var survivors = new List(incrementalPlanSteps.Count - droppedStepIds.Count); + var oldToNewId = new Dictionary(incrementalPlanSteps.Count - droppedStepIds.Count); + + foreach (var step in incrementalPlanSteps) + { + if (droppedStepIds.Contains(step.Id)) + { + continue; + } + + oldToNewId[step.Id] = survivors.Count + 1; + survivors.Add(step); + } + + var rewritten = ImmutableList.CreateBuilder(); + + foreach (var step in survivors) + { + if (step is OperationPlanStep operationStep) + { + var newDependents = RenumberDeferDependents(operationStep.Dependents, droppedStepIds, oldToNewId); + var newParentDependencies = operationStep.ParentDependencies; + + if (parentRefsByStepId.TryGetValue(operationStep.Id, out var parentRefBuilder)) + { + newParentDependencies = newParentDependencies.Union(parentRefBuilder.ToImmutable()); + } + + rewritten.Add(operationStep with + { + Id = oldToNewId[operationStep.Id], + Dependents = newDependents, + ParentDependencies = newParentDependencies + }); + } + else + { + rewritten.Add(step with { Id = oldToNewId[step.Id] }); + } + } + + return rewritten.ToImmutable(); + } + + private static ImmutableHashSet RenumberDeferDependents( + ImmutableHashSet dependents, + HashSet removedStepIds, + Dictionary oldToNewId) + { + if (dependents.IsEmpty) + { + return dependents; + } + + var builder = ImmutableHashSet.CreateBuilder(); + foreach (var dependentId in dependents) + { + if (removedStepIds.Contains(dependentId)) + { + continue; + } + + if (oldToNewId.TryGetValue(dependentId, out var newId)) + { + builder.Add(newId); + } + } + + return builder.ToImmutable(); + } + + /// + /// Walks 's definition to locate the selection + /// set at and resolves its GraphQL type. + /// + private bool TryLocateDeferRequirementTarget( + OperationPlanStep parentStep, + SelectionPath path, + out SelectionSetNode targetSelectionSet, + out ITypeDefinition targetType) + { + var currentSet = parentStep.Definition.SelectionSet; + ITypeDefinition currentType = _schema.GetOperationType(parentStep.Definition.Operation); + + var startIndex = parentStep.Target.Length; + + for (var i = startIndex; i < path.Length; i++) + { + var segment = path[i]; + + switch (segment.Kind) + { + case SelectionPathSegmentKind.Field: + FieldNode? fieldNode = null; + foreach (var selection in currentSet.Selections) + { + if (selection is FieldNode candidate + && (candidate.Alias?.Value == segment.Name || candidate.Name.Value == segment.Name)) + { + fieldNode = candidate; + break; + } + } + + if (fieldNode?.SelectionSet is null + || currentType is not FusionComplexTypeDefinition complexType + || !complexType.Fields.TryGetField( + fieldNode.Name.Value, + allowInaccessibleFields: true, + out var field)) + { + targetSelectionSet = null!; + targetType = null!; + return false; + } + + currentSet = fieldNode.SelectionSet; + currentType = field.Type.NamedType(); + break; + + case SelectionPathSegmentKind.InlineFragment: + InlineFragmentNode? fragment = null; + foreach (var selection in currentSet.Selections) + { + if (selection is InlineFragmentNode candidate + && candidate.TypeCondition?.Name.Value == segment.Name) + { + fragment = candidate; + break; + } + } + + if (fragment is null + || !_schema.Types.TryGetType(segment.Name, out var fragmentType)) + { + targetSelectionSet = null!; + targetType = null!; + return false; + } + + currentSet = fragment.SelectionSet; + currentType = fragmentType; + break; + + default: + targetSelectionSet = null!; + targetType = null!; + return false; + } + } + + targetSelectionSet = currentSet; + targetType = currentType; + return true; + } + + private static SelectionSetNode LocateSelectionSetAtPath( + SelectionSetNode root, + SelectionPath path, + int startIndex) + { + var currentSet = root; + + for (var i = startIndex; i < path.Length; i++) + { + var segment = path[i]; + + switch (segment.Kind) + { + case SelectionPathSegmentKind.Field: + foreach (var selection in currentSet.Selections) + { + if (selection is FieldNode candidate + && (candidate.Alias?.Value == segment.Name || candidate.Name.Value == segment.Name) + && candidate.SelectionSet is { } inner) + { + currentSet = inner; + break; + } + } + break; + + case SelectionPathSegmentKind.InlineFragment: + foreach (var selection in currentSet.Selections) + { + if (selection is InlineFragmentNode candidate + && candidate.TypeCondition?.Name.Value == segment.Name) + { + currentSet = candidate.SelectionSet; + break; + } + } + break; + } + } + + return currentSet; + } + + private static bool SelectionSetContainsField( + SelectionSetNode selectionSet, + string fieldName) + { + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field + when (field.Alias?.Value ?? field.Name.Value).Equals(fieldName, StringComparison.Ordinal): + return true; + + case FieldNode { SelectionSet: { } nested }: + if (SelectionSetContainsField(nested, fieldName)) + { + return true; + } + break; + + case InlineFragmentNode fragment: + if (SelectionSetContainsField(fragment.SelectionSet, fieldName)) + { + return true; + } + break; + } + } + + return false; + } + + private static string? ExtractRootFieldName(string text) + { + if (string.IsNullOrEmpty(text)) + { + return null; } - return (plan.Value.Steps, plan.Value.InternalOperationDefinition); + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + + if (c == ' ' || c == '{' || c == '.') + { + return i == 0 ? null : text[..i]; + } + } + + return text; } + private static InvalidOperationException CreateUnsatisfiableDeferRequirementException( + OperationPlanStep selfFetch, + OperationRequirement requirement, + string reason) + => new( + $"The deferred incremental plan's requirement '{requirement.Key}' at path " + + $"'{requirement.Path}' could not be resolved from the parent plan. " + + $"Defer anchor: '{selfFetch.Target}'. " + + $"Target schema: '{selfFetch.SchemaName}'. " + + $"Reason: {reason}."); + + private readonly record struct LiftedDeferRequirement( + OperationRequirement Requirement, + int DownstreamStepId, + int ParentStepId); + /// - /// Builds execution nodes for a subplan's plan steps. + /// Builds execution nodes for an incremental plan's plan steps. /// private (ImmutableArray RootNodes, ImmutableArray AllNodes) BuildDeferredExecutionNodes( OperationDefinitionNode deferredOperation, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 25966f4f901..23a696f80c7 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -91,15 +91,10 @@ public OperationPlan CreatePlan( try { - // Check for @defer directives before planning. If present, we split the - // operation into a main (non-deferred) part and per-DeferUsageSet subplans. - // The main operation is planned without the deferred selections, and each - // subplan is planned independently. - // - // PERF: For non-deferred operations (the common case), the only overhead is - // the HasDeferDirective check which does a fast AST walk looking for @defer. - ImmutableArray deliveryGroups = []; - ImmutableArray deferredSubPlans = []; + // Split deferred selections into a main operation and incremental + // plan descriptors before planning. + ImmutableArray deliveryGroups = []; + ImmutableArray incrementalPlans = []; DeferSplitResult? deferSplit = null; DeferPartitioningResult? partitioning = null; var mainOperationDefinition = operationDefinition; @@ -107,17 +102,17 @@ public OperationPlan CreatePlan( if (_options.EnableDefer && DeferOperationRewriter.HasDeferDirective(operationDefinition)) { // The partitioner walks the original AST once and hands every - // @defer fragment a canonical DeferUsage instance (with Id, + // @defer fragment a canonical DeliveryGroup instance (with Id, // Path and IfVariable populated). The rewriter consumes the // same instances so its per-set grouping and the compiler's - // later Selection._deferUsages entries share object identity. + // later Selection._deliveryGroups entries share object identity. var deferConditions = new DeferConditionCollection(); partitioning = DeferPartitioner.Partition(operationDefinition, deferConditions); var rewriter = new DeferOperationRewriter(_options.InlineUnlabeledDeferFragments); var splitResult = rewriter.Split(operationDefinition, partitioning); - if (!splitResult.SubPlanDescriptors.IsEmpty) + if (!splitResult.IncrementalPlanDescriptors.IsEmpty) { deferSplit = splitResult; mainOperationDefinition = splitResult.MainOperation; @@ -186,24 +181,46 @@ node with _schema.GetOperationType(mainOperationDefinition.Operation)); } - // Always compile from the planner's internal definition. For defer, - // this is the stripped main operation (without deferred fragments), - // which ensures the result mapper only includes non-deferred fields. - var operation = _operationCompiler.Compile(id, hash, internalOperationDefinition); + // Prepare incremental plans before the root operation is compiled + // so parent-scope requirements are represented in the root plan. + PlanContextGraph? deferContextGraph = null; + ImmutableArray deferRoutingStates = []; - // Plan deferred subplans if @defer was detected. Each unique - // DeferUsageSet becomes one ExecutionSubPlan; the set of all - // DeferUsage instances (one per @defer occurrence) is kept on the - // plan for wire-level delivery-group identity. if (deferSplit.HasValue && partitioning is not null) { - deliveryGroups = partitioning.AllDeferUsages; - deferredSubPlans = PlanDeferredSubPlans( + deliveryGroups = partitioning.AllDeliveryGroups; + + deferContextGraph = PlanContextGraph.Create( + planSteps, + index, + internalOperationDefinition); + + deferRoutingStates = RouteIncrementalPlans( id, - hash, deferSplit.Value, + deferContextGraph, eventSourceEnabled, cancellationToken); + + // Any parent-scope transformations applied while routing + // defer requirements flow back into the root step list and + // the root internal operation definition that compile and + // the execution tree builder consume below. + planSteps = deferContextGraph.RootSteps; + internalOperationDefinition = deferContextGraph.RootInternalOperation; + } + + // Use the latest root operation definition after deferred planning. + var operation = _operationCompiler.Compile(id, hash, internalOperationDefinition); + + if (deferContextGraph is not null) + { + incrementalPlans = BuildIncrementalPlans( + id, + hash, + deferRoutingStates, + deferContextGraph, + cancellationToken); } var operationPlan = BuildExecutionPlan( @@ -211,7 +228,7 @@ node with mainOperationDefinition, planSteps, deliveryGroups, - deferredSubPlans, + incrementalPlans, searchSpace, expandedNodes, cancellationToken); @@ -725,7 +742,7 @@ private void PlanSelections( } backlog = backlog.PushUnresolvable(unresolvable, current.SchemaName, stepDepth); - backlog = backlog.PushRequirements(fieldsWithRequirements, stepId, stepDepth); + backlog = backlog.PushRequirements(fieldsWithRequirements, new StepConsumer(stepId), stepDepth); // Lookups are always queries. Root work items can also be rewritten to the query root // when walking shared paths (for example the viewer convention in mutations). @@ -1018,9 +1035,16 @@ private void PlanInlineFieldWithRequirements( PlanQueue possiblePlans, Backlog backlog) { + // The main planning backlog handles step-owned requirements. Incremental + // plan requirements are handled by defer planning. + if (workItem.Consumer is not StepConsumer stepConsumer) + { + return; + } + // we first resolve the original intended plan step, so we can inline the field // into it. - if (current.Steps.ById(workItem.StepId) is not OperationPlanStep currentStep) + if (current.Steps.ById(stepConsumer.StepId) is not OperationPlanStep currentStep) { return; } @@ -1034,6 +1058,7 @@ private void PlanInlineFieldWithRequirements( var success = TryInlineFieldRequirements( workItem, + stepConsumer.StepId, ref current, currentStep, index, @@ -1097,7 +1122,7 @@ private void PlanInlineFieldWithRequirements( var updatedStep = currentStep with { Definition = operation, Requirements = requirements }; - steps = steps.SetItem(workItem.StepIndex, updatedStep); + steps = steps.SetItem(stepConsumer.StepId - 1, updatedStep); var remainingCost = PlannerCostEstimator.EstimateRemainingCost( current.Options, @@ -1134,6 +1159,13 @@ private void PlanFieldWithRequirement( PlanQueue possiblePlans, Backlog backlog) { + // The main planning backlog handles step-owned requirements. Incremental + // plan requirements are handled by defer planning. + if (workItem.Consumer is not StepConsumer stepConsumer) + { + return; + } + var selectionSetStub = new SelectionSet( workItem.Selection.SelectionSetId, new SelectionSetNode([workItem.Selection.Node]), @@ -1148,7 +1180,7 @@ private void PlanFieldWithRequirement( workItem.Conditions); backlog = current.Backlog; - if (current.Steps.ById(workItem.StepId) is not OperationPlanStep currentStep) + if (current.Steps.ById(stepConsumer.StepId) is not OperationPlanStep currentStep) { return; } @@ -1164,6 +1196,7 @@ private void PlanFieldWithRequirement( var leftoverRequirements = TryInlineFieldRequirements( workItem, + stepConsumer.StepId, ref current, currentStep, indexBuilder, @@ -1352,7 +1385,7 @@ private void PlanNodeLookup( } backlog = backlog.PushUnresolvable(unresolvable, current.SchemaName, stepDepth); - backlog = backlog.PushRequirements(fieldsWithRequirements, stepId, stepDepth); + backlog = backlog.PushRequirements(fieldsWithRequirements, new StepConsumer(stepId), stepDepth); var resolvableSelections = resolvable.Selections; if (!resolvableSelections.Any(IsTypeNameSelection)) @@ -1681,12 +1714,13 @@ private static List GetLookupArguments(Lookup lookup, string requi var (resolvable, unresolvable, fieldsWithRequirements, _) = _partitioner.Partition(input); backlog = backlog.PushUnresolvable(unresolvable, current.SchemaName, stepDepth); - backlog = backlog.PushRequirements(fieldsWithRequirements, stepId, stepDepth); + backlog = backlog.PushRequirements(fieldsWithRequirements, new StepConsumer(stepId), stepDepth); return resolvable; } private SelectionSetNode? TryInlineFieldRequirements( FieldRequirementWorkItem workItem, + int dependentStepId, ref PlanNode current, OperationPlanStep currentStep, SelectionSetIndexBuilder index, @@ -1732,7 +1766,7 @@ private static List GetLookupArguments(Lookup lookup, string requi requirements); current = current with { InternalOperationDefinition = internalOperation }; - foreach (var (step, stepIndex, schemaName) in current.GetCandidateSteps(workItem.Selection.SelectionSetId)) + foreach (var (step, stepIndex, _) in current.GetCandidateSteps(workItem.Selection.SelectionSetId)) { if (currentStep.Id == step.Id) { @@ -1748,53 +1782,22 @@ private static List GetLookupArguments(Lookup lookup, string requi continue; } - index.Register(workItem.Selection.SelectionSetId, requirements); - - var input = new SelectionSetPartitionerInput - { - SchemaName = schemaName, - SelectionSet = new SelectionSet( - workItem.Selection.SelectionSetId, - requirements, - workItem.Selection.Field.DeclaringType, - workItem.Selection.Path), - SelectionSetIndex = index - }; - - var (resolvable, unresolvable, _, _) = _partitioner.Partition(input); - - if (resolvable is not { Selections.Count: > 0 }) + if (!TryInlineSelectionSetIntoStep( + step, + workItem.Selection.SelectionSetId, + workItem.Selection.Field.DeclaringType, + workItem.Selection.Path, + requirements, + dependentStepId, + index, + out var updatedStep, + out var unresolvable)) { // if we cannot resolve any selection with the current source we cannot inline the // field requirements into this step. continue; } - // the resolvable part of the requirement could be different from the requirement - // if we are unable to inline the complete requirement into a single plan step. - // in this case we will register the resolvable part as part of the requirements selection set - // so that they logically belong together. - if (resolvable != requirements) - { - index.Register(workItem.Selection.SelectionSetId, resolvable); - } - - var operation = - InlineSelections( - step.Definition, - index, - workItem.Selection.Field.DeclaringType, - workItem.Selection.SelectionSetId, - resolvable); - - var updatedStep = step with - { - Definition = operation, - - // the step containing the field requirements is now dependent on this step. - Dependents = step.Dependents.Add(workItem.StepId) - }; - steps = steps.SetItem(stepIndex, updatedStep); requirements = null; @@ -1847,7 +1850,7 @@ private static List GetLookupArguments(Lookup lookup, string requi { if (TryInlineIntoAncestorStep( ancestorMatch, requirements, workItem.Selection.Path, - workItem.StepId, index, ref steps, out var unresolvable, + dependentStepId, index, ref steps, out var unresolvable, resolvableRegistrationId: workItem.Selection.SelectionSetId)) { requirements = null; @@ -1880,6 +1883,68 @@ private static List GetLookupArguments(Lookup lookup, string requi return requirements; } + private bool TryInlineSelectionSetIntoStep( + OperationPlanStep step, + uint targetSelectionSetId, + ITypeDefinition selectionSetType, + SelectionPath path, + SelectionSetNode requirementSelections, + int dependentStepId, + SelectionSetIndexBuilder index, + out OperationPlanStep updatedStep, + out ImmutableStack unresolvable) + { + index.Register(targetSelectionSetId, requirementSelections); + + var input = new SelectionSetPartitionerInput + { + SchemaName = step.SchemaName!, + SelectionSet = new SelectionSet( + targetSelectionSetId, + requirementSelections, + selectionSetType, + path), + SelectionSetIndex = index + }; + + var (resolvable, partitionUnresolvable, _, _) = _partitioner.Partition(input); + + if (resolvable is not { Selections.Count: > 0 }) + { + updatedStep = step; + unresolvable = []; + return false; + } + + // the resolvable part of the requirement could be different from the requirement + // if we are unable to inline the complete requirement into a single plan step. + // in this case we will register the resolvable part as part of the requirements selection set + // so that they logically belong together. + if (resolvable != requirementSelections) + { + index.Register(targetSelectionSetId, resolvable); + } + + var operation = + InlineSelections( + step.Definition, + index, + selectionSetType, + targetSelectionSetId, + resolvable); + + updatedStep = step with + { + Definition = operation, + + // the step containing the field requirements is now dependent on this step. + Dependents = step.Dependents.Add(dependentStepId) + }; + + unresolvable = partitionUnresolvable; + return true; + } + private OperationDefinitionNode InlineSelectionsIntoOverallOperation( OperationDefinitionNode operation, SelectionSetIndexBuilder index, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/ParentPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/ParentPlanContext.cs new file mode 100644 index 00000000000..7f20180d294 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/ParentPlanContext.cs @@ -0,0 +1,46 @@ +using System.Collections.Immutable; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +/// +/// Describes the plan scope that encloses an incremental plan. +/// +/// +/// The plan steps available in the enclosing scope. +/// +/// +/// The selection set index for the enclosing scope. +/// +/// +/// The for the enclosing scope. +/// +/// +/// The kind of enclosing scope. +/// +/// +/// The descriptor for an enclosing incremental plan, or +/// for the root scope. +/// +internal sealed record ParentPlanContext( + ImmutableList ParentSteps, + ISelectionSetIndex ParentSelectionSetIndex, + OperationDefinitionNode ParentInternalOperation, + ParentScope Kind, + IncrementalPlanDescriptor? OwnerDescriptor = null); + +/// +/// Identifies the kind of plan scope that encloses an incremental plan. +/// +internal enum ParentScope +{ + /// + /// The enclosing scope is the root operation plan. + /// + Root, + + /// + /// The enclosing scope is another incremental plan. + /// + EnclosingIncrementalPlan +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanContextGraph.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanContextGraph.cs new file mode 100644 index 00000000000..2ce9b1cfc25 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlanContextGraph.cs @@ -0,0 +1,240 @@ +using System.Collections.Immutable; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +/// +/// Tracks the enclosing plan scope for each incremental plan descriptor. +/// +/// +/// Descriptors are registered in parent-before-child order so nested +/// descriptors can resolve their immediate enclosing scope. +/// +internal sealed class PlanContextGraph +{ + private readonly Dictionary _enclosingContextByDescriptor = []; + private ParentPlanContext _rootContext; + + private PlanContextGraph(ParentPlanContext rootContext) + { + _rootContext = rootContext; + } + + /// + /// Creates a new seeded with the root plan + /// scope. + /// + /// + /// The root plan's current list of plan steps. + /// + /// + /// The selection set index associated with the root plan's internal + /// operation. + /// + /// + /// The planner's internal operation for the root plan scope. For an + /// operation with @defer this is the stripped main operation. + /// + public static PlanContextGraph Create( + ImmutableList rootSteps, + ISelectionSetIndex rootSelectionSetIndex, + OperationDefinitionNode rootInternalOperation) + { + ArgumentNullException.ThrowIfNull(rootSteps); + ArgumentNullException.ThrowIfNull(rootSelectionSetIndex); + ArgumentNullException.ThrowIfNull(rootInternalOperation); + + var rootContext = new ParentPlanContext( + rootSteps, + rootSelectionSetIndex, + rootInternalOperation, + ParentScope.Root); + + return new PlanContextGraph(rootContext); + } + + /// + /// Gets the current root plan scope's step list. Reflects any updates + /// applied via . + /// + public ImmutableList RootSteps => _rootContext.ParentSteps; + + /// + /// Gets the current root plan scope's operation definition. + /// + public OperationDefinitionNode RootInternalOperation => _rootContext.ParentInternalOperation; + + /// + /// Returns the immediate enclosing for the + /// given incremental plan descriptor. + /// + /// The descriptor whose enclosing context is queried. + public ParentPlanContext GetParentContext(IncrementalPlanDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + if (descriptor.Parent is null) + { + return _rootContext; + } + + if (!_enclosingContextByDescriptor.TryGetValue(descriptor.Parent, out var parentContext)) + { + throw new InvalidOperationException( + "The enclosing deferred incremental plan has not been registered with the defer context graph. " + + "Parent incremental plans must be planned before their nested children."); + } + + return parentContext; + } + + /// + /// Updates the root plan scope's step list. + /// + /// The new root plan step list. + public void UpdateRootSteps(ImmutableList updatedRootSteps) + { + ArgumentNullException.ThrowIfNull(updatedRootSteps); + + _rootContext = _rootContext with { ParentSteps = updatedRootSteps }; + } + + /// + /// Updates the root plan scope's operation definition. + /// + /// The new root internal operation. + public void UpdateRootInternalOperation(OperationDefinitionNode updatedRootInternalOperation) + { + ArgumentNullException.ThrowIfNull(updatedRootInternalOperation); + + _rootContext = _rootContext with { ParentInternalOperation = updatedRootInternalOperation }; + } + + /// + /// Registers an incremental plan scope for nested incremental plans. + /// + /// The incremental plan descriptor that was just planned. + /// The incremental plan's final plan steps. + /// The selection set index used for the incremental plan. + /// The incremental plan's internal operation. + public void RegisterDeferContext( + IncrementalPlanDescriptor descriptor, + ImmutableList steps, + ISelectionSetIndex selectionSetIndex, + OperationDefinitionNode internalOperation) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(steps); + ArgumentNullException.ThrowIfNull(selectionSetIndex); + ArgumentNullException.ThrowIfNull(internalOperation); + + var context = new ParentPlanContext( + steps, + selectionSetIndex, + internalOperation, + ParentScope.EnclosingIncrementalPlan, + OwnerDescriptor: descriptor); + + _enclosingContextByDescriptor[descriptor] = context; + } + + /// + /// Returns the next enclosing scope above , or + /// when is the root. + /// The walker in + /// calls this to escalate a requirement that the current scope cannot + /// satisfy (same-subgraph inline and cross-subgraph promote both failed). + /// Lookups go through the graph each call so the walker always observes + /// the current state of each scope, including mutations applied by + /// sibling or nested descriptors that were processed in between. + /// + public ParentPlanContext? GetEnclosingScope(ParentPlanContext scope) + { + ArgumentNullException.ThrowIfNull(scope); + + if (scope.Kind == ParentScope.Root) + { + return null; + } + + var ownerDescriptor = scope.OwnerDescriptor + ?? throw new InvalidOperationException( + "An EnclosingIncrementalPlan-kind scope must carry an OwnerDescriptor."); + + if (ownerDescriptor.Parent is null) + { + return _rootContext; + } + + if (!_enclosingContextByDescriptor.TryGetValue(ownerDescriptor.Parent, out var next)) + { + throw new InvalidOperationException( + "The parent deferred incremental plan has not been registered with the defer context graph."); + } + + return next; + } + + /// + /// Returns the registered step list for a descriptor, reflecting any + /// parent-scope-style mutations applied after the initial registration + /// (for example a nested inner defer promoting a step into its outer + /// enclosing scope). + /// + public ImmutableList GetRegisteredSteps(IncrementalPlanDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + if (!_enclosingContextByDescriptor.TryGetValue(descriptor, out var context)) + { + throw new InvalidOperationException( + "The deferred incremental plan has not been registered with the defer context graph."); + } + + return context.ParentSteps; + } + + /// + /// Returns the registered operation definition for a descriptor. + /// + public OperationDefinitionNode GetRegisteredInternalOperation(IncrementalPlanDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + if (!_enclosingContextByDescriptor.TryGetValue(descriptor, out var context)) + { + throw new InvalidOperationException( + "The deferred incremental plan has not been registered with the defer context graph."); + } + + return context.ParentInternalOperation; + } + + /// + /// Updates the registered enclosing-scope context of a descriptor after + /// its plan absorbs an inner defer's requirement. Nested inner + /// descriptors consult their immediate enclosing scope; when the outer + /// scope gets a new parent-scope step appended for an inner's + /// requirement, subsequent siblings of that inner must observe the + /// update. + /// + public void UpdateDeferContext( + IncrementalPlanDescriptor descriptor, + ImmutableList steps, + OperationDefinitionNode? internalOperation = null) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(steps); + + if (!_enclosingContextByDescriptor.TryGetValue(descriptor, out var existing)) + { + return; + } + + _enclosingContextByDescriptor[descriptor] = existing with + { + ParentSteps = steps, + ParentInternalOperation = internalOperation ?? existing.ParentInternalOperation + }; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlannerCostEstimator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlannerCostEstimator.cs index 371af26de2c..1b52680b853 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlannerCostEstimator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/PlannerCostEstimator.cs @@ -167,12 +167,13 @@ private static double EstimateInlineCost( PlanNode current) { var selectionSetId = workItem.Selection.SelectionSetId; + var consumerStepId = workItem.Consumer is StepConsumer stepConsumer ? stepConsumer.StepId : -1; for (var i = 0; i < current.Steps.Count; i++) { if (current.Steps[i] is OperationPlanStep step && step.SelectionSets.Contains(selectionSetId) - && step.Id != workItem.StepId) + && step.Id != consumerStepId) { return 0.0; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/RequirementConsumer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/RequirementConsumer.cs new file mode 100644 index 00000000000..47f5d341614 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/RequirementConsumer.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Planning; + +/// +/// Identifies the owner of a resolved field requirement. +/// +internal abstract record RequirementConsumer; + +/// +/// A requirement owned by a in the current plan scope. +/// +internal sealed record StepConsumer(int StepId) : RequirementConsumer; + +/// +/// A requirement owned by an and +/// satisfied by the enclosing plan scope. +/// +internal sealed record DeferConsumer(IncrementalPlanDescriptor Descriptor) : RequirementConsumer; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs index 0f68cb3f3e4..bbb8a0892a0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/OperationPlanStep.cs @@ -23,6 +23,8 @@ public record OperationPlanStep : PlanStep public ImmutableHashSet Dependents { get; init; } = []; + public ImmutableHashSet ParentDependencies { get; init; } = []; + public ImmutableDictionary Requirements { get; init; } #if NET10_0_OR_GREATER = []; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/ParentStepRef.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/ParentStepRef.cs new file mode 100644 index 00000000000..53d877690e9 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/Steps/ParentStepRef.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Planning; + +/// +/// Identifies a dependency on a step in an enclosing plan scope. +/// +/// +/// The identifier of the step in the parent plan scope that must complete +/// before the declaring step can execute. +/// +public readonly record struct ParentStepRef(int StepId); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs index 94d2c29afa8..c3947b60bcc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs @@ -159,9 +159,9 @@ internal static string OperationPlan_NodeNotFound { } } - internal static string OperationPlan_DeferredSubPlanParentNotFound { + internal static string OperationPlan_IncrementalPlanParentNotFound { get { - return ResourceManager.GetString("OperationPlan_DeferredSubPlanParentNotFound", resourceCulture); + return ResourceManager.GetString("OperationPlan_IncrementalPlanParentNotFound", resourceCulture); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx index 4c51a8dd2b9..12809e28e0c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx @@ -75,8 +75,8 @@ No execution node with id '{0}' exists in this plan. - - Could not resolve parent execution node for deferred subplan at path '{0}'. + + Could not resolve parent execution node for incremental plan at path '{0}'. The batch response does not contain any result for operation '{0}'. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs index c76b0722a7b..d8aec144f46 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompositeResultDocument.cs @@ -93,7 +93,8 @@ internal CompositeResultElement GetArrayIndexElement(Cursor current, int arrayIn if ((uint)arrayIndex >= (uint)row.NumberOfRows) { - throw new IndexOutOfRangeException(); + throw new IndexOutOfRangeException( + $"Array index {arrayIndex} is out of range for an array of length {row.NumberOfRows}."); } // first element is at +1 after StartArray diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs index 05aa0fdb72d..b7586230f3d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DeferTests.cs @@ -464,7 +464,7 @@ ... @defer(label: "location") { // assert // The stable-stream snapshot lays out the per-frame timeline (pending / - // incremental / completed) so the shared subplan emitting once under the + // incremental / completed) so the shared incremental plan emitting once under the // best delivery-group id while still completing both groups is visible // as a single block. await MatchSnapshotAsync(gateway, request, result, stableStream: true); @@ -712,4 +712,239 @@ ... @defer(label: "pricing") { doc.Dispose(); } } + + [Fact] + public async Task Defer_With_Forwarded_Variable_Merges_Into_Subgraph_Request() + { + // arrange + // Schema A owns the user lookup and exposes the entity key. Schema B owns the + // posts(first:) field, so the deferred fetch hops to B with both the imported + // parent key (__fusion_1_id) and the forwarded request variable ($limit). + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + posts(first: Int!): [Post!]! + } + + type Post { + id: ID! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + query: """ + query GetUserPosts($limit: Int!) { + user(id: "1") { + id + ... @defer(label: "posts") { + posts(first: $limit) { + id + } + } + } + } + """, + variables: new Dictionary { ["limit"] = 5 }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + // The snapshot's deferred subgraph interaction must show both the imported + // __fusion_*_id key and the forwarded $limit variable in the variables payload. + await MatchSnapshotAsync(gateway, request, result, stableStream: true); + } + + [Fact] + public async Task Defer_Nested_With_Forwarded_Variable_At_Each_Level() + { + // arrange + // Three subgraphs so each defer level requires its own hop. Each hop carries an + // imported parent key plus a forwarded request variable scoped to that level. + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + posts(first: Int!): [Post!]! + } + + type Post @key(fields: "id") { + id: ID! + } + """); + + using var server3 = CreateSourceSchema( + "C", + """ + type Query { + postById(id: ID!): Post @lookup + } + + type Post @key(fields: "id") { + id: ID! + comments(first: Int!): [Comment!]! + } + + type Comment { + id: ID! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ("C", server3) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + query: """ + query GetUserPostsAndComments($postLimit: Int!, $commentLimit: Int!) { + user(id: "1") { + id + ... @defer(label: "posts") { + posts(first: $postLimit) { + id + ... @defer(label: "comments") { + comments(first: $commentLimit) { + id + } + } + } + } + } + } + """, + variables: new Dictionary + { + ["postLimit"] = 3, + ["commentLimit"] = 2 + }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + // The snapshot must show both deferred subgraph calls carrying their respective + // forwarded variables alongside the imported parent keys. + await MatchSnapshotAsync(gateway, request, result, stableStream: true); + } + + [Fact] + public async Task Defer_With_Forwarded_Variable_Over_List_Anchor() + { + // arrange + // Root list anchor so the deferred incremental plan expands across multiple imported + // entries. Each expanded entry must carry the forwarded $limit variable. + using var server1 = CreateSourceSchema( + "A", + """ + type Query { + users: [User!]! + } + + type User @key(fields: "id") { + id: ID! + } + """); + + using var server2 = CreateSourceSchema( + "B", + """ + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + posts(first: Int!): [Post!]! + } + + type Post { + id: ID! + } + """); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + query: """ + query GetUsersPosts($limit: Int!) { + users { + id + ... @defer(label: "posts") { + posts(first: $limit) { + id + } + } + } + } + """, + variables: new Dictionary { ["limit"] = 5 }); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + // The deferred subgraph call expands across all imported user entries. Each + // outbound variable set carries the forwarded $limit alongside the parent key. + await MatchSnapshotAsync(gateway, request, result, stableStream: true); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml index b8cf11d1f5a..16908385205 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfFalse_Variable_Should_Return_NonStreamed_Result.yaml @@ -64,6 +64,7 @@ sourceSchemas: ... @skip(if: $shouldDefer) { id } + id } } variables: | @@ -145,12 +146,10 @@ operationPlan: query GetUser($shouldDefer: Boolean!) { user(id: "1") { name - ... @skip(if: $shouldDefer) { - reviews { - title - body - } - id @fusion__requirement + id @fusion__requirement + reviews @skip(if: $shouldDefer) { + title + body } } } @@ -169,6 +168,7 @@ operationPlan: ... @skip(if: $shouldDefer) { id } + id } } forwardedVariables: @@ -201,21 +201,16 @@ operationPlan: path: $.user label: reviews ifVariable: $shouldDefer - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 parentNodeId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query GetUser_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -234,4 +229,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml index 1106742f70d..4dbcdf78dc5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_IfTrue_Variable_Should_Return_Streamed_Result.yaml @@ -113,6 +113,7 @@ sourceSchemas: ... @skip(if: $shouldDefer) { id } + id } } variables: | @@ -125,24 +126,7 @@ sourceSchemas: { "data": { "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query GetUser_defer_1 { - user(id: "1") { - id - } - } - response: - results: - - | - { - "data": { - "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -211,12 +195,10 @@ operationPlan: query GetUser($shouldDefer: Boolean!) { user(id: "1") { name - ... @skip(if: $shouldDefer) { - reviews { - title - body - } - id @fusion__requirement + id @fusion__requirement + reviews @skip(if: $shouldDefer) { + title + body } } } @@ -235,6 +217,7 @@ operationPlan: ... @skip(if: $shouldDefer) { id } + id } } forwardedVariables: @@ -267,21 +250,16 @@ operationPlan: path: $.user label: reviews ifVariable: $shouldDefer - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 parentNodeId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query GetUser_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -300,4 +278,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml index cb1e50395cd..21fb8915fb7 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Should_Return_Incremental_Response_In_Order.yaml @@ -99,23 +99,6 @@ sourceSchemas: query Op_d1db12b9_1 { user(id: "1") { name - } - } - response: - results: - - | - { - "data": { - "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query Op_defer_1 { - user(id: "1") { id } } @@ -125,6 +108,7 @@ sourceSchemas: { "data": { "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -213,6 +197,7 @@ operationPlan: { user(id: "1") { name + id @fusion__requirement } } hash: d1db12b9a5735063f8b816612efd3c53 @@ -226,6 +211,7 @@ operationPlan: query Op_d1db12b9_1 { user(id: "1") { name + id } } deliveryGroups: @@ -236,21 +222,16 @@ operationPlan: path: $.user label: inner parentId: 0 - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query Op_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -266,12 +247,8 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 - - deliveryGroupIds: - - 1 - parentNodeId: 2 - nodes: - - id: 1 + - parentNodeId: 1 + - id: 2 type: Operation schema: A operation: | @@ -280,7 +257,15 @@ operationPlan: id } } - - id: 2 + - deliveryGroupIds: + - 1 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 type: Operation schema: B operation: | @@ -296,4 +281,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_With_Forwarded_Variable_At_Each_Level.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_With_Forwarded_Variable_At_Each_Level.yaml new file mode 100644 index 00000000000..9224249e353 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_With_Forwarded_Variable_At_Each_Level.yaml @@ -0,0 +1,565 @@ +title: Defer_Nested_With_Forwarded_Variable_At_Each_Level +request: + document: | + query GetUserPostsAndComments($postLimit: Int!, $commentLimit: Int!) { + user(id: "1") { + id + ... @defer(label: "posts") { + posts(first: $postLimit) { + id + ... @defer(label: "comments") { + comments(first: $commentLimit) { + id + } + } + } + } + } + } + variables: | + { + "postLimit": 3, + "commentLimit": 2 + } +stableResponseStream: | + { + "kind": "stable-stream", + "payloadCount": 3, + "initial": { + "data": { + "user": { + "id": "VXNlcjox" + } + } + }, + "pending": [ + { + "id": "0", + "path": [ + "user" + ], + "label": "posts" + }, + { + "id": "1", + "path": [ + "user", + "posts" + ], + "label": "comments" + } + ], + "incremental": [ + { + "id": "0", + "path": [ + "user" + ], + "data": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + }, + { + "id": "1", + "path": [ + "user", + "posts" + ], + "subPath": [ + 0 + ], + "data": { + "comments": [ + { + "id": "Q29tbWVudDox" + }, + { + "id": "Q29tbWVudDoy" + }, + { + "id": "Q29tbWVudDoz" + } + ] + } + }, + { + "id": "1", + "path": [ + "user", + "posts" + ], + "subPath": [ + 1 + ], + "data": { + "comments": [ + { + "id": "Q29tbWVudDo0" + }, + { + "id": "Q29tbWVudDo1" + }, + { + "id": "Q29tbWVudDo2" + } + ] + } + }, + { + "id": "1", + "path": [ + "user", + "posts" + ], + "subPath": [ + 2 + ], + "data": { + "comments": [ + { + "id": "Q29tbWVudDo3" + }, + { + "id": "Q29tbWVudDo4" + }, + { + "id": "Q29tbWVudDo5" + } + ] + } + } + ], + "completed": [ + { + "id": "0" + }, + { + "id": "1" + } + ], + "final": { + "data": { + "user": { + "id": "VXNlcjox", + "posts": [ + { + "comments": [ + { + "id": "Q29tbWVudDox" + }, + { + "id": "Q29tbWVudDoy" + }, + { + "id": "Q29tbWVudDoz" + } + ], + "id": "UG9zdDox" + }, + { + "comments": [ + { + "id": "Q29tbWVudDo0" + }, + { + "id": "Q29tbWVudDo1" + }, + { + "id": "Q29tbWVudDo2" + } + ], + "id": "UG9zdDoy" + }, + { + "comments": [ + { + "id": "Q29tbWVudDo3" + }, + { + "id": "Q29tbWVudDo4" + }, + { + "id": "Q29tbWVudDo5" + } + ], + "id": "UG9zdDoz" + } + ] + } + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUserPostsAndComments_5ec00c7b_1 { + user(id: "1") { + id + } + } + response: + results: + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUserPostsAndComments_defer_1 { + user(id: "1") { + id + } + } + response: + results: + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type Post @key(fields: "id") { + id: ID! + } + + type User @key(fields: "id") { + id: ID! + posts(first: Int!): [Post!]! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUserPostsAndComments_defer_2($postLimit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $postLimit) { + id + } + } + } + variables: | + { + "postLimit": 3, + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + } + } + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUserPostsAndComments_defer_2($postLimit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $postLimit) { + id + } + } + } + variables: | + { + "postLimit": 3, + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + } + } + - name: C + schema: | + schema { + query: Query + } + + type Query { + postById(id: ID!): Post @lookup + } + + type Comment { + id: ID! + } + + type Post @key(fields: "id") { + id: ID! + comments(first: Int!): [Comment!]! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query GetUserPostsAndComments_defer_3($commentLimit: Int!, $__fusion_2_id: ID!) { + postById(id: $__fusion_2_id) { + comments(first: $commentLimit) { + id + } + } + } + variables: | + [ + { + "commentLimit": 2, + "__fusion_2_id": "UG9zdDox" + }, + { + "commentLimit": 2, + "__fusion_2_id": "UG9zdDoy" + }, + { + "commentLimit": 2, + "__fusion_2_id": "UG9zdDoz" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "postById": { + "comments": [ + { + "id": "Q29tbWVudDox" + }, + { + "id": "Q29tbWVudDoy" + }, + { + "id": "Q29tbWVudDoz" + } + ] + } + } + } + - | + { + "data": { + "postById": { + "comments": [ + { + "id": "Q29tbWVudDo0" + }, + { + "id": "Q29tbWVudDo1" + }, + { + "id": "Q29tbWVudDo2" + } + ] + } + } + } + - | + { + "data": { + "postById": { + "comments": [ + { + "id": "Q29tbWVudDo3" + }, + { + "id": "Q29tbWVudDo4" + }, + { + "id": "Q29tbWVudDo5" + } + ] + } + } + } +operationPlan: + operation: + - document: | + query GetUserPostsAndComments($postLimit: Int!, $commentLimit: Int!) { + user(id: "1") { + id + id @fusion__requirement + } + } + name: GetUserPostsAndComments + hash: 5ec00c7b8afb06fc2d7d497960fd2bda + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUserPostsAndComments_5ec00c7b_1 { + user(id: "1") { + id + } + } + deliveryGroups: + - id: 0 + path: $.user + label: posts + - id: 1 + path: $.user.posts + label: comments + parentId: 0 + incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query GetUserPostsAndComments_defer_2($postLimit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $postLimit) { + id + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + forwardedVariables: + - postLimit + dependencies: + - parentNodeId: 1 + - id: 2 + type: Operation + schema: A + operation: | + query GetUserPostsAndComments_defer_1 { + user(id: "1") { + id + } + } + - deliveryGroupIds: + - 1 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query GetUserPostsAndComments_defer_2($postLimit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $postLimit) { + id + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + forwardedVariables: + - postLimit + dependencies: + - parentNodeId: 2 + - id: 2 + type: Operation + schema: C + operation: | + query GetUserPostsAndComments_defer_3($commentLimit: Int!, $__fusion_2_id: ID!) { + postById(id: $__fusion_2_id) { + comments(first: $commentLimit) { + id + } + } + } + source: $.postById + target: $.user.posts + requirements: + - name: __fusion_2_id + selectionMap: >- + id + forwardedVariables: + - commentLimit + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml index 898551dea5b..e0f701010b1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Nested_Without_Label_On_Inner_Should_Return_Incremental_Response.yaml @@ -81,23 +81,6 @@ sourceSchemas: query Op_f58e5f87_1 { user(id: "1") { name - } - } - response: - results: - - | - { - "data": { - "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query Op_defer_1 { - user(id: "1") { id } } @@ -107,6 +90,7 @@ sourceSchemas: { "data": { "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -157,6 +141,7 @@ operationPlan: { user(id: "1") { name + id @fusion__requirement } } hash: f58e5f87c8862b9cc4c515a1fb51b598 @@ -170,6 +155,7 @@ operationPlan: query Op_f58e5f87_1 { user(id: "1") { name + id } } deliveryGroups: @@ -179,21 +165,16 @@ operationPlan: - id: 1 path: $.user parentId: 0 - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query Op_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -210,4 +191,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml index 7da2c9d0cb6..c449a03d23a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Single_Fragment_Returns_Incremental_Response.yaml @@ -106,23 +106,6 @@ sourceSchemas: query GetUser_9d7b4d57_1 { user(id: "1") { name - } - } - response: - results: - - | - { - "data": { - "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query GetUser_defer_1 { - user(id: "1") { id } } @@ -132,6 +115,7 @@ sourceSchemas: { "data": { "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -200,6 +184,7 @@ operationPlan: query GetUser { user(id: "1") { name + id @fusion__requirement } } name: GetUser @@ -214,27 +199,23 @@ operationPlan: query GetUser_9d7b4d57_1 { user(id: "1") { name + id } } deliveryGroups: - id: 0 path: $.user label: reviews - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query GetUser_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -253,4 +234,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_Sharing_Field_Emit_One_Incremental_That_Completes_Both_Groups.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_Sharing_Field_Emit_One_Incremental_That_Completes_Both_Groups.yaml index 6764eb650d2..39514a743c1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_Sharing_Field_Emit_One_Incremental_That_Completes_Both_Groups.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_Sharing_Field_Emit_One_Incremental_That_Completes_Both_Groups.yaml @@ -100,41 +100,6 @@ sourceSchemas: query Op_29ccee0f_1 { user(id: "1") { name - } - } - response: - results: - - | - { - "data": { - "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query Op_defer_1 { - user(id: "1") { - id - } - } - response: - results: - - | - { - "data": { - "user": { - "id": "VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query Op_defer_1 { - user(id: "1") { id } } @@ -144,6 +109,7 @@ sourceSchemas: { "data": { "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -214,6 +180,7 @@ operationPlan: { user(id: "1") { name + id @fusion__requirement } } hash: 29ccee0fa1c82a6cb002b417ee776893 @@ -227,6 +194,7 @@ operationPlan: query Op_29ccee0f_1 { user(id: "1") { name + id } } deliveryGroups: @@ -236,22 +204,17 @@ operationPlan: - id: 1 path: $.user label: location - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 - 1 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query Op_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -267,21 +230,16 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 - deliveryGroupIds: - 1 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query Op_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -297,4 +255,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml index bfaa006a18f..9d8e6c99d16 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_Two_Siblings_With_Overlapping_Fields_Should_Deduplicate.yaml @@ -100,41 +100,6 @@ sourceSchemas: query Op_29ccee0f_1 { user(id: "1") { name - } - } - response: - results: - - | - { - "data": { - "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query Op_defer_1 { - user(id: "1") { - id - } - } - response: - results: - - | - { - "data": { - "user": { - "id": "VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query Op_defer_1 { - user(id: "1") { id } } @@ -144,6 +109,7 @@ sourceSchemas: { "data": { "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -214,6 +180,7 @@ operationPlan: { user(id: "1") { name + id @fusion__requirement } } hash: 29ccee0fa1c82a6cb002b417ee776893 @@ -227,6 +194,7 @@ operationPlan: query Op_29ccee0f_1 { user(id: "1") { name + id } } deliveryGroups: @@ -236,22 +204,17 @@ operationPlan: - id: 1 path: $.user label: location - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 - 1 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query Op_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -267,21 +230,16 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 - deliveryGroupIds: - 1 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query Op_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -297,4 +255,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml index 3d9dd0f5332..f957da563e9 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Error_In_Deferred_Fragment_Should_Return_Error_In_Incremental_Payload.yaml @@ -73,23 +73,6 @@ sourceSchemas: query GetUser_28690061_1 { user(id: "1") { name - } - } - response: - results: - - | - { - "data": { - "user": { - "name": "User: VXNlcjox" - } - } - } - - request: - accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 - document: | - query GetUser_defer_1 { - user(id: "1") { id } } @@ -99,6 +82,7 @@ sourceSchemas: { "data": { "user": { + "name": "User: VXNlcjox", "id": "VXNlcjox" } } @@ -153,6 +137,7 @@ operationPlan: query GetUser { user(id: "1") { name + id @fusion__requirement } } name: GetUser @@ -167,27 +152,23 @@ operationPlan: query GetUser_28690061_1 { user(id: "1") { name + id } } deliveryGroups: - id: 0 path: $.user label: email - deferredSubPlans: + incrementalPlans: - deliveryGroupIds: - 0 parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id nodes: - id: 1 - type: Operation - schema: A - operation: | - query GetUser_defer_1 { - user(id: "1") { - id - } - } - - id: 2 type: Operation schema: B operation: | @@ -203,4 +184,4 @@ operationPlan: selectionMap: >- id dependencies: - - id: 1 + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Forwarded_Variable_Merges_Into_Subgraph_Request.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Forwarded_Variable_Merges_Into_Subgraph_Request.yaml new file mode 100644 index 00000000000..48e220b4612 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Forwarded_Variable_Merges_Into_Subgraph_Request.yaml @@ -0,0 +1,226 @@ +title: Defer_With_Forwarded_Variable_Merges_Into_Subgraph_Request +request: + document: | + query GetUserPosts($limit: Int!) { + user(id: "1") { + id + ... @defer(label: "posts") { + posts(first: $limit) { + id + } + } + } + } + variables: | + { + "limit": 5 + } +stableResponseStream: | + { + "kind": "stable-stream", + "payloadCount": 2, + "initial": { + "data": { + "user": { + "id": "VXNlcjox" + } + } + }, + "pending": [ + { + "id": "0", + "path": [ + "user" + ], + "label": "posts" + } + ], + "incremental": [ + { + "id": "0", + "path": [ + "user" + ], + "data": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "final": { + "data": { + "user": { + "id": "VXNlcjox", + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUserPosts_ca271134_1 { + user(id: "1") { + id + } + } + response: + results: + - | + { + "data": { + "user": { + "id": "VXNlcjox" + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type Post { + id: ID! + } + + type User @key(fields: "id") { + id: ID! + posts(first: Int!): [Post!]! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUserPosts_defer_2($limit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $limit) { + id + } + } + } + variables: | + { + "limit": 5, + "__fusion_1_id": "VXNlcjox" + } + response: + results: + - | + { + "data": { + "userById": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + } + } +operationPlan: + operation: + - document: | + query GetUserPosts($limit: Int!) { + user(id: "1") { + id + id @fusion__requirement + } + } + name: GetUserPosts + hash: ca2711340198d8fa233e951d2350091a + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUserPosts_ca271134_1 { + user(id: "1") { + id + } + } + deliveryGroups: + - id: 0 + path: $.user + label: posts + incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query GetUserPosts_defer_2($limit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $limit) { + id + } + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + forwardedVariables: + - limit + dependencies: + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Forwarded_Variable_Over_List_Anchor.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Forwarded_Variable_Over_List_Anchor.yaml new file mode 100644 index 00000000000..d248a02410d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DeferTests.Defer_With_Forwarded_Variable_Over_List_Anchor.yaml @@ -0,0 +1,366 @@ +title: Defer_With_Forwarded_Variable_Over_List_Anchor +request: + document: | + query GetUsersPosts($limit: Int!) { + users { + id + ... @defer(label: "posts") { + posts(first: $limit) { + id + } + } + } + } + variables: | + { + "limit": 5 + } +stableResponseStream: | + { + "kind": "stable-stream", + "payloadCount": 2, + "initial": { + "data": { + "users": [ + { + "id": "VXNlcjox" + }, + { + "id": "VXNlcjoy" + }, + { + "id": "VXNlcjoz" + } + ] + } + }, + "pending": [ + { + "id": "0", + "path": [ + "users" + ], + "label": "posts" + } + ], + "incremental": [ + { + "id": "0", + "path": [ + "users" + ], + "subPath": [ + 0 + ], + "data": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + }, + { + "id": "0", + "path": [ + "users" + ], + "subPath": [ + 1 + ], + "data": { + "posts": [ + { + "id": "UG9zdDo0" + }, + { + "id": "UG9zdDo1" + }, + { + "id": "UG9zdDo2" + } + ] + } + }, + { + "id": "0", + "path": [ + "users" + ], + "subPath": [ + 2 + ], + "data": { + "posts": [ + { + "id": "UG9zdDo3" + }, + { + "id": "UG9zdDo4" + }, + { + "id": "UG9zdDo5" + } + ] + } + } + ], + "completed": [ + { + "id": "0" + } + ], + "final": { + "data": { + "users": [ + { + "id": "VXNlcjox", + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + }, + { + "id": "VXNlcjoy", + "posts": [ + { + "id": "UG9zdDo0" + }, + { + "id": "UG9zdDo1" + }, + { + "id": "UG9zdDo2" + } + ] + }, + { + "id": "VXNlcjoz", + "posts": [ + { + "id": "UG9zdDo3" + }, + { + "id": "UG9zdDo4" + }, + { + "id": "UG9zdDo5" + } + ] + } + ] + } + } + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + users: [User!]! + } + + type User @key(fields: "id") { + id: ID! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query GetUsersPosts_711d06f0_1 { + users { + id + } + } + response: + results: + - | + { + "data": { + "users": [ + { + "id": "VXNlcjox" + }, + { + "id": "VXNlcjoy" + }, + { + "id": "VXNlcjoz" + } + ] + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup + } + + type Post { + id: ID! + } + + type User @key(fields: "id") { + id: ID! + posts(first: Int!): [Post!]! + } + interactions: + - request: + accept: application/jsonl; charset=utf-8, text/event-stream; charset=utf-8, application/graphql-response+json; charset=utf-8, application/json; charset=utf-8 + document: | + query GetUsersPosts_defer_2($limit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $limit) { + id + } + } + } + variables: | + [ + { + "limit": 5, + "__fusion_1_id": "VXNlcjox" + }, + { + "limit": 5, + "__fusion_1_id": "VXNlcjoy" + }, + { + "limit": 5, + "__fusion_1_id": "VXNlcjoz" + } + ] + response: + contentType: application/jsonl; charset=utf-8 + results: + - | + { + "data": { + "userById": { + "posts": [ + { + "id": "UG9zdDox" + }, + { + "id": "UG9zdDoy" + }, + { + "id": "UG9zdDoz" + } + ] + } + } + } + - | + { + "data": { + "userById": { + "posts": [ + { + "id": "UG9zdDo0" + }, + { + "id": "UG9zdDo1" + }, + { + "id": "UG9zdDo2" + } + ] + } + } + } + - | + { + "data": { + "userById": { + "posts": [ + { + "id": "UG9zdDo3" + }, + { + "id": "UG9zdDo4" + }, + { + "id": "UG9zdDo5" + } + ] + } + } + } +operationPlan: + operation: + - document: | + query GetUsersPosts($limit: Int!) { + users { + id + id @fusion__requirement + } + } + name: GetUsersPosts + hash: 711d06f0c0ec248b4b3b51d0eac9311e + searchSpace: 1 + expandedNodes: 1 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query GetUsersPosts_711d06f0_1 { + users { + id + } + } + deliveryGroups: + - id: 0 + path: $.users + label: posts + incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: B + operation: | + query GetUsersPosts_defer_2($limit: Int!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + posts(first: $limit) { + id + } + } + } + source: $.userById + target: $.users + requirements: + - name: __fusion_1_id + selectionMap: >- + id + forwardedVariables: + - limit + dependencies: + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationPlanContextRoutingTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationPlanContextRoutingTests.cs new file mode 100644 index 00000000000..43d4f9278ae --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/OperationPlanContextRoutingTests.cs @@ -0,0 +1,356 @@ +using System.Collections.Immutable; +using System.Text.Json; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Execution.Results; +using HotChocolate.Fusion.Language; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Language; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; +using FusionNameNode = HotChocolate.Fusion.Language.NameNode; + +namespace HotChocolate.Fusion.Execution; + +public sealed class OperationPlanContextRoutingTests : FusionTestBase +{ + [Fact] + public async Task CreateVariableValueSets_Should_RouteThroughResultStore_When_RequirementKeysIsNull() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync(); + var context = fixture.CreateContext(); + + // act + var result = context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: [], + requirements: [Requirement("__fusion_1_id")]); + + // assert + Assert.True(result.IsDefaultOrEmpty); + } + + [Fact] + public async Task CreateVariableValueSets_Should_RouteThroughResultStore_When_RequestedRequirementsHaveNoOverlap() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync(); + var context = fixture.CreateContext(); + fixture.SetRequirements(context, ImportedKeys("__fusion_1_id"), Field("__fusion_1_id", new StringValueNode("1"))); + + // act + var result = context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: [], + requirements: [Requirement("__fusion_2_other")]); + + // assert + Assert.True(result.IsDefaultOrEmpty); + } + + [Fact] + public async Task CreateVariableValueSets_Should_Throw_When_RequestedRequirementsPartiallyOverlap() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync(); + var context = fixture.CreateContext(); + fixture.SetRequirements(context, ImportedKeys("__fusion_1_id"), Field("__fusion_1_id", new StringValueNode("1"))); + + // act + var exception = Assert.Throws( + () => context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: [], + requirements: + [ + Requirement("__fusion_1_id"), + Requirement("__fusion_2_other") + ])); + + // assert + Assert.Contains("__fusion_1_id", exception.Message); + Assert.Contains("__fusion_2_other", exception.Message); + } + + [Fact] + public async Task CreateVariableValueSets_Should_ReturnImportedSnapshotWholesale_When_FullExactMatchAndNoForwardedVariables() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync(); + var context = fixture.CreateContext(); + fixture.SetRequirements( + context, + ImportedKeys("__fusion_1_id", "__fusion_2_sku"), + Field("__fusion_1_id", new StringValueNode("1")), + Field("__fusion_2_sku", new StringValueNode("sku-1"))); + + // act + var first = context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: [], + requirements: + [ + Requirement("__fusion_1_id"), + Requirement("__fusion_2_sku") + ]); + var second = context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: [], + requirements: + [ + Requirement("__fusion_1_id"), + Requirement("__fusion_2_sku") + ]); + + // assert + // Case D returns the cached _requirementValues array wholesale, so two + // calls hand back identical references (the imported snapshot itself). + var entry = Assert.Single(first); + Assert.True(first == second); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_id":"1","__fusion_2_sku":"sku-1"} + """); + } + + [Fact] + public async Task CreateVariableValueSets_Should_CallSnapshotMergePath_When_FullMatchWithForwardedVariables() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync( + variables: new Dictionary + { + ["limit"] = new IntValueNode(10) + }); + var context = fixture.CreateContext(); + fixture.SetRequirements( + context, + ImportedKeys("__fusion_1_id"), + Field("__fusion_1_id", new StringValueNode("1"))); + + // act + var result = context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: ["limit"], + requirements: [Requirement("__fusion_1_id")]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"limit":10,"__fusion_1_id":"1"} + """); + } + + [Fact] + public async Task CreateVariableValueSets_Should_CallSnapshotMergePath_When_StrictSubsetWithoutForwardedVariables() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync(); + var context = fixture.CreateContext(); + fixture.SetRequirements( + context, + ImportedKeys("__fusion_1_id", "__fusion_2_sku"), + Field("__fusion_1_id", new StringValueNode("1")), + Field("__fusion_2_sku", new StringValueNode("sku-1"))); + + // act + var result = context.CreateVariableValueSets( + SelectionPath.Root, + forwardedVariables: [], + requirements: [Requirement("__fusion_1_id")]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_id":"1"} + """); + } + + [Fact] + public async Task CreateVariableValueSets_MultiSet_Should_Throw_When_RequestedRequirementsPartiallyOverlap() + { + // arrange + await using var fixture = await RoutingTestFixture.CreateAsync(); + var context = fixture.CreateContext(); + fixture.SetRequirements(context, ImportedKeys("__fusion_1_id"), Field("__fusion_1_id", new StringValueNode("1"))); + + // act + var exception = Assert.Throws( + () => context.CreateVariableValueSets( + selectionSets: [SelectionPath.Root], + forwardedVariables: [], + requiredData: + [ + Requirement("__fusion_1_id"), + Requirement("__fusion_2_other") + ])); + + // assert + Assert.Contains("__fusion_1_id", exception.Message); + Assert.Contains("__fusion_2_other", exception.Message); + } + + 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 HashSet ImportedKeys(params string[] keys) + => new(keys, StringComparer.Ordinal); + + private static string Normalize(JsonSegment segment) + { + using var document = JsonDocument.Parse(segment.AsSequence()); + return JsonSerializer.Serialize(document.RootElement); + } + + private sealed class RoutingTestFixture : IAsyncDisposable + { + private readonly ServiceProvider _services; + private readonly IRequestExecutor _executor; + private readonly OperationPlan _operationPlan; + private readonly IVariableValueCollection _variables; + private readonly List _rentedContexts = []; + private readonly List _stores = []; + private readonly List _ctsList = []; + private readonly List<(ObjectPool Pool, PooledRequestContext Context)> _requestContexts = []; + + private RoutingTestFixture( + ServiceProvider services, + IRequestExecutor executor, + OperationPlan operationPlan, + IVariableValueCollection variables) + { + _services = services; + _executor = executor; + _operationPlan = operationPlan; + _variables = variables; + } + + public static async Task CreateAsync( + IReadOnlyDictionary? variables = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddHttpClient(); + var services = serviceCollection + .AddGraphQLGateway() + .AddInMemoryConfiguration( + ComposeSchemaDocument( + """ + type Query { + field: String! + } + """)) + .Services + .BuildServiceProvider(); + + var executor = await services.GetRequestExecutorAsync(); + var schema = (HotChocolate.Fusion.Types.FusionSchemaDefinition)executor.Schema; + var operationPlan = PlanOperation( + schema, + """ + query { + field + } + """); + + var coercedVariables = new Dictionary(StringComparer.Ordinal); + var stringType = new NonNullType(schema.Types.GetType("String")); + + if (variables is not null) + { + foreach (var (name, value) in variables) + { + coercedVariables[name] = new VariableValue(name, stringType, value); + } + } + + return new RoutingTestFixture( + services, + executor, + operationPlan, + new VariableValueCollection(coercedVariables)); + } + + public OperationPlanContext CreateContext() + { + var pool = _executor.Schema.Services.GetRequiredService(); + var context = pool.Rent(); + var cts = new CancellationTokenSource(); + _ctsList.Add(cts); + + var requestContextPool = + _executor.Schema.Services.GetRequiredService>(); + var requestContext = requestContextPool.Get(); + _requestContexts.Add((requestContextPool, requestContext)); + requestContext.Initialize( + _executor.Schema, + _executor.Version, + new OperationRequest( + document: new OperationDocumentSourceText("{ field }"), + documentId: null, + documentHash: null, + operationName: null, + errorHandlingMode: null, + variableValues: null, + extensions: null, + contextData: null, + features: null, + services: _services, + flags: RequestFlags.AllowAll), + requestIndex: 0, + requestServices: _services, + requestAborted: CancellationToken.None); + + context.Initialize(requestContext, _variables, _operationPlan, cts); + _rentedContexts.Add(context); + return context; + } + + public void SetRequirements( + OperationPlanContext context, + HashSet keys, + params ObjectFieldNode[] fields) + { + var sourceStore = new FetchResultStore(); + _stores.Add(sourceStore); + + var entry = sourceStore.CreateVariableValueSets(CompactPath.Root, fields); + context.SetRequirements(ImmutableArray.Create(entry), keys); + } + + public async ValueTask DisposeAsync() + { + foreach (var context in _rentedContexts) + { + await context.DisposeAsync(); + } + + foreach (var (pool, context) in _requestContexts) + { + pool.Return(context); + } + + foreach (var store in _stores) + { + store.Dispose(); + } + + foreach (var cts in _ctsList) + { + cts.Dispose(); + } + + await _services.DisposeAsync(); + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs new file mode 100644 index 00000000000..e0c5fddb852 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Results/FetchResultStoreTests.cs @@ -0,0 +1,380 @@ +using System.Collections.Immutable; +using System.Text.Json; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Language; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Language; +using FusionNameNode = HotChocolate.Fusion.Language.NameNode; + +namespace HotChocolate.Fusion.Execution.Results; + +public sealed class FetchResultStoreTests +{ + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_MergeForwardedVariables_When_RequirementsAreImported() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field("__fusion_1_id", new StringValueNode("1")), + Field("__fusion_2_sku", new StringValueNode("sku-1"))); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_id", "__fusion_2_sku"), + [Field("limit", new IntValueNode(10))], + [Requirement("__fusion_1_id")]); + + // assert + var entry = Assert.Single(result); + Assert.Equal(CompactPath.Root, entry.Path); + Assert.True(entry.AdditionalPaths.IsDefaultOrEmpty); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"limit":10,"__fusion_1_id":"1"} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_PreserveAdditionalPaths_When_FilteredRequirementsDeduplicate() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var primaryPath = Path(1); + var primaryAdditionalPath = Path(2); + var duplicatePath = Path(3); + var duplicateAdditionalPath = Path(4); + + var first = WithAdditionalPaths( + CreateVariableValues( + source, + primaryPath, + Field("__fusion_1_id", new StringValueNode("1")), + Field("__fusion_2_sku", new StringValueNode("sku-1"))), + primaryAdditionalPath); + + var second = WithAdditionalPaths( + CreateVariableValues( + source, + duplicatePath, + Field("__fusion_1_id", new StringValueNode("1")), + Field("__fusion_2_sku", new StringValueNode("sku-2"))), + duplicateAdditionalPath); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [first, second], + ImportedKeys("__fusion_1_id", "__fusion_2_sku"), + [], + [Requirement("__fusion_1_id")]); + + // assert + var entry = Assert.Single(result); + Assert.Equal(primaryPath, entry.Path); + Assert.Equal( + [primaryAdditionalPath, duplicatePath, duplicateAdditionalPath], + entry.AdditionalPaths.AsSpan().ToArray()); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_id":"1"} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_CopyCompositeValues_When_ImportedRequirementIsNested() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field( + "__fusion_1_filter", + new ObjectValueNode( + Field("tags", new ListValueNode(new StringValueNode("a"), new StringValueNode("b"))), + Field("size", new IntValueNode(2))))); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_filter"), + [], + [Requirement("__fusion_1_filter")]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_filter":{"tags":["a","b"],"size":2}} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_Throw_When_RequirementWasNotImported() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field("__fusion_1_id", new StringValueNode("1"))); + + // act + var exception = Assert.Throws( + () => target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_id"), + [], + [Requirement("__fusion_2_sku")])); + + // assert + Assert.Equal( + "A deferred incremental plan fetch references a requirement that was not imported.", + exception.Message); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_CopyCompositeArrayValues_When_ImportedRequirementIsArray() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field( + "__fusion_1_ids", + new ListValueNode( + new ObjectValueNode( + Field("id", new StringValueNode("a")), + Field("kind", new StringValueNode("X"))), + new ObjectValueNode( + Field("id", new StringValueNode("b")), + Field("kind", new StringValueNode("Y")))))); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_ids"), + [], + [Requirement("__fusion_1_ids")]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_ids":[{"id":"a","kind":"X"},{"id":"b","kind":"Y"}]} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_CopyValueAcrossChunks_When_ValueSpansChunkBoundary() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + // The value alone exceeds one 128KB ChunkedArrayWriter chunk so the + // imported entry's JsonSegment spans more than one chunk. + var largeValue = new string('a', 200_000); + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field("__fusion_1_blob", new StringValueNode(largeValue))); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_blob"), + [], + [Requirement("__fusion_1_blob")]); + + // assert + var entry = Assert.Single(result); + using var document = JsonDocument.Parse(entry.Values.AsSequence()); + Assert.Equal(largeValue, document.RootElement.GetProperty("__fusion_1_blob").GetString()); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_MatchPropertyName_When_PropertyNameSpansChunkBoundary() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + // Pad the source writer so the next entry's property name straddles + // a 128KB ChunkedArrayWriter chunk boundary. + PadSourceWriterTo(source, position: 131_065); + + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field("__fusion_1_id", new StringValueNode("1"))); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_id"), + [], + [Requirement("__fusion_1_id")]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_id":"1"} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_EmitInRequestedOrder_When_ImportedSnapshotOrderDiffers() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + // Imported snapshot has properties in order a, b, c. + var imported = CreateVariableValues( + source, + CompactPath.Root, + Field("__fusion_1_a", new StringValueNode("aa")), + Field("__fusion_2_b", new StringValueNode("bb")), + Field("__fusion_3_c", new StringValueNode("cc"))); + + // act + // Requirements are passed in c, a, b order; output must follow this order. + var result = target.CreateVariableValueSetsFromSnapshot( + [imported], + ImportedKeys("__fusion_1_a", "__fusion_2_b", "__fusion_3_c"), + [], + [ + Requirement("__fusion_3_c"), + Requirement("__fusion_1_a"), + Requirement("__fusion_2_b") + ]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_3_c":"cc","__fusion_1_a":"aa","__fusion_2_b":"bb"} + """); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_ReturnEmpty_When_ImportedEntriesIsEmpty() + { + // arrange + using var target = new FetchResultStore(); + + // act + var result = target.CreateVariableValueSetsFromSnapshot( + ImmutableArray.Empty, + ImportedKeys("__fusion_1_id"), + [], + [Requirement("__fusion_1_id")]); + + // assert + Assert.True(result.IsDefaultOrEmpty); + } + + [Fact] + public void CreateVariableValueSetsFromSnapshot_Should_SkipEmptyEntries_When_EntryValuesIsEmpty() + { + // arrange + using var source = new FetchResultStore(); + using var target = new FetchResultStore(); + + var realEntry = CreateVariableValues( + source, + CompactPath.Root, + Field("__fusion_1_id", new StringValueNode("1"))); + + // act + // VariableValues.Empty has IsEmpty == true (default JsonSegment) and is skipped. + var result = target.CreateVariableValueSetsFromSnapshot( + [VariableValues.Empty, realEntry], + ImportedKeys("__fusion_1_id"), + [], + [Requirement("__fusion_1_id")]); + + // assert + var entry = Assert.Single(result); + Normalize(entry.Values).MatchInlineSnapshot( + """ + {"__fusion_1_id":"1"} + """); + } + + private static VariableValues CreateVariableValues( + FetchResultStore store, + CompactPath path, + params ObjectFieldNode[] fields) + => store.CreateVariableValueSets(path, fields); + + private static VariableValues WithAdditionalPaths( + VariableValues values, + params CompactPath[] additionalPaths) + => values with + { + AdditionalPaths = new CompactPathSegment(additionalPaths, 0, additionalPaths.Length) + }; + + 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 HashSet ImportedKeys(params string[] keys) + => new(keys, StringComparer.Ordinal); + + 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); + } + + private static string Normalize(JsonSegment segment) + { + using var document = JsonDocument.Parse(segment.AsSequence()); + return JsonSerializer.Serialize(document.RootElement); + } + + private static void PadSourceWriterTo(FetchResultStore store, int position) + { + // A padding entry has shape {"k":""} which is 8 + filler.Length bytes. + var fillerLength = position - 8; + + if (fillerLength < 0) + { + throw new ArgumentOutOfRangeException( + nameof(position), + "Position must leave room for the {\"k\":\"\"} padding shell."); + } + + _ = CreateVariableValues( + store, + CompactPath.Root, + Field("k", new StringValueNode(new string('p', fillerLength)))); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs index 2dfcad1d311..ee82b07b47e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/JsonOperationPlanSerializationTests.cs @@ -168,11 +168,11 @@ ... on Author { } [Fact] - public void Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans() + public void Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans() { // arrange // Two sibling @defer fragments share a field (email) plus a nested @defer - // adds a parent chain. Round-trip must restore canonical DeferUsage instances. + // adds a parent chain. Round-trip must restore canonical DeliveryGroup instances. var schema = ComposeSchema( """ # name: a @@ -237,7 +237,7 @@ ... @defer(label: "location") { // assert Encoding.UTF8.GetString(buffer.WrittenSpan).MatchSnapshot(); Assert.All( - parsedPlan.DeferredSubPlans, + parsedPlan.IncrementalPlans, p => Assert.All( p.DeliveryGroups, g => Assert.Same(parsedPlan.DeliveryGroups.Single(d => d.Id == g.Id), g))); @@ -246,6 +246,87 @@ ... @defer(label: "location") { g => Assert.Same(parsedPlan.DeliveryGroups.Single(d => d.Id == g.Parent!.Id), g.Parent)); } + [Fact] + public void Parse_Plan_Preserves_ParentDependencies_On_Deferred_IncrementalPlan_Nodes() + { + // arrange + // Same-subgraph hoist injects the key into the parent op so the + // deferred incremental plan node carries a ParentStepRef on its plan step + // and a {"parentNodeId": N} entry in its serialized dependencies. + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + var originalPlan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "contact") { + email + } + } + } + """); + + using var buffer = new PooledArrayWriter(); + var formatter = new JsonOperationPlanFormatter( + new JsonWriterOptions + { + Indented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + formatter.Format(buffer, originalPlan); + + var compiler = new OperationCompiler( + schema, + new DefaultObjectPool>>( + new DefaultPooledObjectPolicy>>())); + var parser = new JsonOperationPlanParser(compiler); + + // act + var parsedPlan = parser.Parse(buffer.WrittenMemory); + + // assert + var originalIncrementalPlanNode = originalPlan.IncrementalPlans + .Single() + .AllNodes + .OfType() + .Single(n => n.ParentDependencies.Length > 0); + var parsedIncrementalPlanNode = parsedPlan.IncrementalPlans + .Single() + .AllNodes + .OfType() + .Single(n => n.Id == originalIncrementalPlanNode.Id); + Assert.Equal( + originalIncrementalPlanNode.ParentDependencies.ToArray(), + parsedIncrementalPlanNode.ParentDependencies.ToArray()); + Assert.Equal( + originalIncrementalPlanNode.Dependencies.Length, + parsedIncrementalPlanNode.Dependencies.Length); + } + [Fact] public void Parse_Plan_Without_BatchingGroupId() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans.snap b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap similarity index 81% rename from src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans.snap rename to src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap index 4a7410cee69..e1c040ccb89 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans.snap +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_IncrementalPlans.snap @@ -1,8 +1,8 @@ { - "id": "68c2a64a8c04735ef1b991eeca37e895e81946d427b3c305d68fd2a19d988d51", + "id": "fb018f87cefd8af095abf721279688c3f6b2829b1f43a4e7ee46424bffc7c43b", "operation": { "kind": "Query", - "document": "{\n user(id: \"1\") {\n name\n }\n}", + "document": "{\n user(id: \"1\") {\n name\n id @fusion__requirement\n }\n}", "id": "123456789101112", "hash": "123456789101112", "shortHash": "12345678" @@ -17,9 +17,9 @@ "operation": { "name": "Op_123456789101112_1", "kind": "Query", - "document": "query Op_123456789101112_1 {\n user(id: \"1\") {\n name\n }\n}", - "hash": "167b5bb497f34e9c7516ec2d4598ae6d7f0fd73a9a9a329156910e99e5db4ec3", - "shortHash": "167b5bb4" + "document": "query Op_123456789101112_1 {\n user(id: \"1\") {\n name\n id\n }\n}", + "hash": "0c7c2bd3f37f66343f01b669dfab23ff06380e580c789ec14a0c77068974ac15", + "shortHash": "0c7c2bd3" }, "resultSelectionSet": "{ user }" } @@ -42,13 +42,21 @@ "label": "location" } ], - "deferredSubPlans": [ + "incrementalPlans": [ { "deliveryGroupIds": [ 0, 2 ], "parentNodeId": 1, + "requirements": [ + { + "name": "__fusion_1_id", + "type": "ID!", + "path": "$.user", + "selectionMap": "id" + } + ], "operation": { "kind": "Query", "document": "{\n user(id: \"1\") {\n email\n id @fusion__requirement\n }\n}", @@ -60,19 +68,6 @@ { "id": 1, "type": "Operation", - "schema": "a", - "operation": { - "name": "Op_defer_1", - "kind": "Query", - "document": "query Op_defer_1 {\n user(id: \"1\") {\n id\n }\n}", - "hash": "93841f3bbdb1c1bf3a949d7089b6713b11e6d33666aae10bdc8a885607465ca5", - "shortHash": "93841f3b" - }, - "resultSelectionSet": "{ user }" - }, - { - "id": 2, - "type": "Operation", "schema": "b", "operation": { "name": "Op_defer_2", @@ -93,8 +88,23 @@ } ], "dependencies": [ - 1 + { + "parentNodeId": 1 + } ] + }, + { + "id": 2, + "type": "Operation", + "schema": "a", + "operation": { + "name": "Op_defer_1", + "kind": "Query", + "document": "query Op_defer_1 {\n user(id: \"1\") {\n id\n }\n}", + "hash": "93841f3bbdb1c1bf3a949d7089b6713b11e6d33666aae10bdc8a885607465ca5", + "shortHash": "93841f3b" + }, + "resultSelectionSet": "{ user }" } ] }, @@ -102,7 +112,15 @@ "deliveryGroupIds": [ 1 ], - "parentNodeId": 2, + "parentNodeId": 1, + "requirements": [ + { + "name": "__fusion_1_id", + "type": "ID!", + "path": "$.user", + "selectionMap": "id" + } + ], "operation": { "kind": "Query", "document": "{\n user(id: \"1\") {\n address\n id @fusion__requirement\n }\n}", @@ -114,19 +132,6 @@ { "id": 1, "type": "Operation", - "schema": "a", - "operation": { - "name": "Op_defer_1", - "kind": "Query", - "document": "query Op_defer_1 {\n user(id: \"1\") {\n id\n }\n}", - "hash": "93841f3bbdb1c1bf3a949d7089b6713b11e6d33666aae10bdc8a885607465ca5", - "shortHash": "93841f3b" - }, - "resultSelectionSet": "{ user }" - }, - { - "id": 2, - "type": "Operation", "schema": "b", "operation": { "name": "Op_defer_2", @@ -147,7 +152,9 @@ } ], "dependencies": [ - 1 + { + "parentNodeId": 2 + } ] } ] diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs index e25805ad3a5..9769b85658e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs @@ -495,7 +495,8 @@ public TestServer CreateSourceSchema( protected static OperationPlan PlanOperation( FusionSchemaDefinition schema, - [StringSyntax("graphql")] string operationText) + [StringSyntax("graphql")] string operationText, + OperationPlannerOptions? options = null) { var pool = new DefaultObjectPool>>( new DefaultPooledObjectPolicy>>()); @@ -507,7 +508,9 @@ protected static OperationPlan PlanOperation( var operation = rewritten.Definitions.OfType().First(); var compiler = new OperationCompiler(schema, pool); - var planner = new OperationPlanner(schema, compiler); + var planner = options is null + ? new OperationPlanner(schema, compiler) + : new OperationPlanner(schema, compiler, options); const string id = "123456789101112"; return planner.CreatePlan(id, id, id, operation); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs index 2d2c0f4718e..8291a214d6b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/DeferPlannerTests.cs @@ -1,3 +1,5 @@ +using HotChocolate.Fusion.Execution.Nodes; + namespace HotChocolate.Fusion.Planning; public class DeferPlannerTests : FusionTestBase @@ -45,10 +47,10 @@ ... @defer { """); // assert - Assert.Single(plan.DeferredSubPlans); + Assert.Single(plan.IncrementalPlans); - var subPlan = plan.DeferredSubPlans[0]; - var group = subPlan.DeliveryGroups[0]; + var incrementalPlan = plan.IncrementalPlans[0]; + var group = incrementalPlan.DeliveryGroups[0]; Assert.Equal(0, group.Id); Assert.Null(group.Label); Assert.Equal("$.user", group.Path!.ToString()); @@ -107,14 +109,14 @@ ... @defer(label: "bioDefer") { """); // assert - Assert.Equal(2, plan.DeferredSubPlans.Length); + Assert.Equal(2, plan.IncrementalPlans.Length); - var emailSubPlan = plan.DeferredSubPlans.First(s => s.DeliveryGroups[0].Label == "emailDefer"); - var bioSubPlan = plan.DeferredSubPlans.First(s => s.DeliveryGroups[0].Label == "bioDefer"); + var emailIncrementalPlan = plan.IncrementalPlans.First(s => s.DeliveryGroups[0].Label == "emailDefer"); + var bioIncrementalPlan = plan.IncrementalPlans.First(s => s.DeliveryGroups[0].Label == "bioDefer"); - Assert.NotNull(emailSubPlan); - Assert.NotNull(bioSubPlan); - Assert.NotEqual(emailSubPlan.DeliveryGroups[0].Id, bioSubPlan.DeliveryGroups[0].Id); + Assert.NotNull(emailIncrementalPlan); + Assert.NotNull(bioIncrementalPlan); + Assert.NotEqual(emailIncrementalPlan.DeliveryGroups[0].Id, bioIncrementalPlan.DeliveryGroups[0].Id); } [Fact] @@ -160,8 +162,8 @@ ... @defer(label: "myLabel") { """); // assert - Assert.Single(plan.DeferredSubPlans); - Assert.Equal("myLabel", plan.DeferredSubPlans[0].DeliveryGroups[0].Label); + Assert.Single(plan.IncrementalPlans); + Assert.Equal("myLabel", plan.IncrementalPlans[0].DeliveryGroups[0].Label); } [Fact] @@ -207,7 +209,7 @@ ... @defer { """); // assert - Assert.False(plan.DeferredSubPlans.IsEmpty); + Assert.False(plan.IncrementalPlans.IsEmpty); } [Fact] @@ -251,7 +253,7 @@ type User @key(fields: "id") { """); // assert - Assert.True(plan.DeferredSubPlans.IsEmpty); + Assert.True(plan.IncrementalPlans.IsEmpty); Assert.False(plan.Operation.HasIncrementalParts); } @@ -298,8 +300,8 @@ ... @defer(if: $shouldDefer) { """); // assert - Assert.Single(plan.DeferredSubPlans); - Assert.Equal("shouldDefer", plan.DeferredSubPlans[0].DeliveryGroups[0].IfVariable); + Assert.Single(plan.IncrementalPlans); + Assert.Equal("shouldDefer", plan.IncrementalPlans[0].DeliveryGroups[0].IfVariable); } [Fact] @@ -348,11 +350,11 @@ ... @defer { Assert.NotEmpty(plan.RootNodes); Assert.NotEmpty(plan.AllNodes); - // The deferred subplan should also have its own execution nodes - var subPlan = plan.DeferredSubPlans[0]; - Assert.False(subPlan.RootNodes.IsEmpty); - Assert.False(subPlan.AllNodes.IsEmpty); - Assert.Null(subPlan.DeliveryGroups[0].Parent); + // The deferred incremental plan should also have its own execution nodes + var incrementalPlan = plan.IncrementalPlans[0]; + Assert.False(incrementalPlan.RootNodes.IsEmpty); + Assert.False(incrementalPlan.AllNodes.IsEmpty); + Assert.Null(incrementalPlan.DeliveryGroups[0].Parent); } [Fact] @@ -398,7 +400,7 @@ ... @defer(if: false) { """); // assert - Assert.True(plan.DeferredSubPlans.IsEmpty); + Assert.True(plan.IncrementalPlans.IsEmpty); } [Fact] @@ -444,8 +446,8 @@ ... @defer(if: true) { """); // assert - Assert.Single(plan.DeferredSubPlans); - Assert.Null(plan.DeferredSubPlans[0].DeliveryGroups[0].IfVariable); + Assert.Single(plan.IncrementalPlans); + Assert.Null(plan.IncrementalPlans[0].DeliveryGroups[0].IfVariable); } [Fact] @@ -495,12 +497,12 @@ ... @defer(label: "inner") { """); // assert - Assert.Equal(2, plan.DeferredSubPlans.Length); + Assert.Equal(2, plan.IncrementalPlans.Length); - var outerGroup = plan.DeferredSubPlans + var outerGroup = plan.IncrementalPlans .Select(s => s.DeliveryGroups[0]) .First(g => g.Label == "outer"); - var innerGroup = plan.DeferredSubPlans + var innerGroup = plan.IncrementalPlans .Select(s => s.DeliveryGroups[0]) .First(g => g.Label == "inner"); @@ -552,7 +554,7 @@ ... @defer @include(if: true) { """); // assert - Assert.Single(plan.DeferredSubPlans); + Assert.Single(plan.IncrementalPlans); } [Fact] @@ -601,17 +603,17 @@ ... @defer(label: "inner", if: true) { } """); - Assert.Equal(2, planBothActive.DeferredSubPlans.Length); - var outerSubPlan = planBothActive.DeferredSubPlans + Assert.Equal(2, planBothActive.IncrementalPlans.Length); + var outerIncrementalPlan = planBothActive.IncrementalPlans .First(s => s.DeliveryGroups.Any(g => g.Label == "outer")); - var innerSubPlan = planBothActive.DeferredSubPlans + var innerIncrementalPlan = planBothActive.IncrementalPlans .First(s => s.DeliveryGroups.Any(g => g.Label == "inner")); - Assert.Single(outerSubPlan.DeliveryGroups, g => g.Label == "outer"); - Assert.Single(innerSubPlan.DeliveryGroups, g => g.Label == "inner"); - Assert.Null(outerSubPlan.DeliveryGroups[0].Parent); - var innerParent = innerSubPlan.DeliveryGroups[0].Parent; + Assert.Single(outerIncrementalPlan.DeliveryGroups, g => g.Label == "outer"); + Assert.Single(innerIncrementalPlan.DeliveryGroups, g => g.Label == "inner"); + Assert.Null(outerIncrementalPlan.DeliveryGroups[0].Parent); + var innerParent = innerIncrementalPlan.DeliveryGroups[0].Parent; Assert.NotNull(innerParent); - Assert.Equal(outerSubPlan.DeliveryGroups[0].Id, innerParent.Id); + Assert.Equal(outerIncrementalPlan.DeliveryGroups[0].Id, innerParent.Id); // act + assert: a=true, b=false (inner inactive, its address collapses into outer) var planInnerInactive = PlanOperation( @@ -630,8 +632,8 @@ ... @defer(label: "inner", if: false) { } """); - Assert.Single(planInnerInactive.DeferredSubPlans); - var collapsedOuter = planInnerInactive.DeferredSubPlans[0]; + Assert.Single(planInnerInactive.IncrementalPlans); + var collapsedOuter = planInnerInactive.IncrementalPlans[0]; Assert.Single(collapsedOuter.DeliveryGroups); Assert.Equal("outer", collapsedOuter.DeliveryGroups[0].Label); @@ -652,13 +654,13 @@ ... @defer(label: "inner", if: true) { } """); - Assert.Single(planOuterInactive.DeferredSubPlans); - var innerOnly = planOuterInactive.DeferredSubPlans[0]; + Assert.Single(planOuterInactive.IncrementalPlans); + var innerOnly = planOuterInactive.IncrementalPlans[0]; Assert.Single(innerOnly.DeliveryGroups); Assert.Equal("inner", innerOnly.DeliveryGroups[0].Label); Assert.Null(innerOnly.DeliveryGroups[0].Parent); - // act + assert: a=false, b=false (both inactive, no subplans) + // act + assert: a=false, b=false (both inactive, no incremental plans) var planBothInactive = PlanOperation( schema, """ @@ -675,7 +677,7 @@ ... @defer(label: "inner", if: false) { } """); - Assert.True(planBothInactive.DeferredSubPlans.IsEmpty); + Assert.True(planBothInactive.IncrementalPlans.IsEmpty); } [Fact(Skip = "Known bug: BuildDeferredOperation forces OperationType.Query, causing KeyNotFoundException for mutation fields")] @@ -725,10 +727,939 @@ ... @defer { """); // assert - Assert.Single(plan.DeferredSubPlans); + Assert.Single(plan.IncrementalPlans); + + var incrementalPlan = plan.IncrementalPlans[0]; + Assert.False(incrementalPlan.RootNodes.IsEmpty); + Assert.False(incrementalPlan.AllNodes.IsEmpty); + } + + [Fact] + public void Defer_RequirementReachableFromParent_Should_InjectIntoParentOp_When_SameSubgraph() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer { + email + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Defer_MultipleDeferGroupsShareRequirement_Should_DeduplicateHoistedField() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "contact") { + email + } + ... @defer(label: "location") { + address + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Defer_RequirementOnForeignSubgraph_Should_PlanParentScopeLookup() + { + // arrange + // Parent plan only hits schema `a` for `product.name`. The defer + // group wants `reviews` on schema `c`, which requires `productSku`. + // `productSku` is only exposed by schema `b`, so the defer's incremental plan + // would otherwise self-fetch twice: once for `id` on `a`, once for + // `productSku` on `b`. Because schema `b` is reachable from the + // parent scope only through a cross-subgraph hop, the hoist must + // promote a dedicated parent-scope op on `b`. + var schema = ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + product(id: ID!): Product @lookup + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + productSku: String! + } + """, + """ + # name: c + schema { + query: Query + } + + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + reviews(productSku: String! @require(field: "productSku")): [String!]! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + product(id: "1") { + name + ... @defer { + reviews + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Defer_NestedDefer_InnerRequirement_Should_ResolveAgainstOuterDefer() + { + // arrange + // Outer defer needs `email` on schema `b` (requires `id` from root + // schema `a`). Inner defer needs `address`, also on schema `b` and + // also requiring `id`. The inner requirement is reachable from the + // outer defer's result tree (both select the same `User` at `$.user`). + // The current hoist only persists parent-step mutations when the + // enclosing scope is the root plan, so the outer incremental plan hoists `id` + // into the root op (one self-fetch eliminated) while the inner + // incremental plan falls back to its own local self-fetch. The runtime's + // try-parent-fall-back-to-own variable resolution (Step 8) consumes + // that local fetch. This snapshot locks in the current planner + // behavior and serves as the regression fixture for any future + // extension of the hoist to enclosing-defer scopes. + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + address + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Defer_NestedDefer_InnerAndOuterShareRequirement_Should_DeduplicateAtOuter() + { + // arrange + // Outer defer selects `reviews`, inner defer selects `summary`. Both + // fields live on schema `c` and both declare @require(field: + // "productSku"). The only way to satisfy `productSku` is via schema + // `b`. Because outer is planned first, it registers the productSku + // requirement in the outer's scope. When inner is planned, its own + // productSku requirement must reuse the outer's already-planned + // producer rather than duplicating another lookup step. + var schema = ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + product(id: ID!): Product @lookup + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + productSku: String! + } + """, + """ + # name: c + schema { + query: Query + } + + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + reviews(productSku: String! @require(field: "productSku")): [String!]! + summary(productSku: String! @require(field: "productSku")): String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + product(id: "1") { + name + ... @defer(label: "outer") { + reviews + ... @defer(label: "inner") { + summary + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Defer_NestedDefer_InnerRequirement_OnForeignSubgraph_Should_PlanLookupInOuterScope() + { + // arrange + // Inner defer's required variable (nickname) lives on schema `b`. The + // outer defer already hits `b` for `email`, but the root plan only + // hits schema `a`. The nearest reachable scope for `nickname` is the + // outer defer scope. The planner should plan the productSku lookup in + // outer-defer scope (not root) because root cannot satisfy it. + var schema = ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + email: String! + nickname: String! + } + """, + """ + # name: c + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + badge(nickname: String! @require(field: "nickname")): String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + badge + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact] + public void Defer_NestedDefer_InnerRequirement_Should_ResolveAtOuterScope_When_SameSubgraphAsOuterKey() + { + // arrange + // Inner defer's required variable (preferences) lives on schema `a`. + // The outer defer hits schema `b` for `email` and already has a + // promoted `user{id}` step on schema `a` (planned for its own + // lookup-key requirement). The walker's same-subgraph inline at outer + // scope succeeds by inlining `preferences` into that promoted step, + // so the inner's producer ends up at outer scope, not root. This is + // the observable planner behavior for schema-reachable requirements; + // the walker's parent-chain escalation to root is retained as + // defensive code (see the Skip'd fixture below). + var schema = ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + preferences: String! + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """, + """ + # name: c + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + customized(preferences: String! @require(field: "preferences")): String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + customized + } + } + } + } + """); + + // assert + MatchSnapshot(plan); + } + + [Fact(Skip = "Natural Fusion schemas do not reach this walker-escalation path. " + + "The outer defer always has a key-producing step (to satisfy its own lookup's " + + "argument requirement) on the same subgraph as the enclosing entity; the walker's " + + "per-scope same-subgraph inline always succeeds at outer scope before needing to " + + "escalate. Cross-subgraph promote also succeeds whenever the inner's required " + + "subgraph is reachable via a lookup from outer's key. The walker's parent-chain " + + "escalation is retained as defensive code against planner-internal matching or " + + "partitioning bugs; snapshot verification is not observable for schema-reachable " + + "cases. Kept as a documentation fixture of .work/defer-requirement-variable-wiring.md " + + "§2.4's target behavior.")] + public void Defer_NestedDefer_InnerRequirement_UnreachableFromOuter_Should_BubbleToRoot() + { + // arrange + // No schema construction produced walker escalation to root. The + // plan's §2.4 invariant is that the walker's chain escalation is + // logically unreachable for any schema whose outer defer's own + // lookup-key requirement anchors a same-subgraph step that can + // absorb an inner's requirement via inline or promote. + Assert.True(true); + } + + [Fact(Skip = "Natural schemas do not reach the unsatisfiable-requirement throw. " + + "If the defer's incremental plan planner produced a self-fetch, that self-fetch is " + + "an existence proof that the required value is reachable from some subgraph, " + + "and the parent's planning machinery (same-subgraph injection or cross-subgraph " + + "promote) can route to the same subgraph. Every attempt to construct an " + + "unsatisfiable schema collapsed into either (a) a composition-time failure or " + + "(b) a schema whose same-subgraph hoist or cross-subgraph promote succeeds. " + + "The throw in ApplyDeferRequirementsToParent is defensive against " + + "planner-internal matching bugs, not against schema shapes. Retained as a " + + "documentation fixture for .work/defer-requirement-variable-wiring.md Phase 1.")] + public void Defer_UnsatisfiableRequirement_Should_ThrowPlannerError_When_NotReachableAnywhere() + { + // arrange + // No schema construction produced the throw. The plan's Phase 1 + // invariant is that the throw is logically unreachable for any + // schema whose deferred incremental plan succeeds at plan time. + Assert.True(true); + } + + [Fact] + public void MaxNodeId_Should_Match_Max_Node_Id_Of_AllNodes_When_Computed() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer { + email + } + } + } + """); + + // assert + var incrementalPlan = plan.IncrementalPlans[0]; + Assert.Equal(incrementalPlan.AllNodes.Max(n => n.Id), incrementalPlan.MaxNodeId); + } + + [Fact] + public void IncrementalPlan_Ids_Should_Be_Positional_When_Plan_Built() + { + // arrange + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + address: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "contact") { + email + ... @defer(label: "nested") { address } + } + ... @defer(label: "location") { email } + } + } + """); + + // assert + for (var i = 0; i < plan.IncrementalPlans.Length; i++) + { + Assert.Equal($"{plan.Id}#{i}", plan.IncrementalPlans[i].Id); + } + } + + [Fact] + public void Defer_SingleAnchor_Should_Keep_NodeRequirements_AllImported_Or_AllLocal() + { + // arrange + // Single defer over a key handoff: the incremental plan's downstream node depends on + // a parent-scope step for the user id and has no plan-internal predecessors. + var schema = ComposeSchema( + """ + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer { + email + } + } + } + """); + + // assert + AssertNoMixedRequirementScope(plan); + } + + [Fact] + public void Defer_NestedDefer_Should_Keep_NodeRequirements_AllImported_Or_AllLocal() + { + // arrange + // Nested defer with a cross-subgraph requirement that the planner promotes + // into the outer defer scope. Each inner incremental plan node either lifts its + // requirements through ParentDependencies or resolves them through a + // sibling step in the same incremental plan, never both at once. + var schema = ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + email: String! + nickname: String! + } + """, + """ + # name: c + schema { + query: Query + } + + type Query { + userById(id: ID!): User @lookup @internal + } + + type User @key(fields: "id") { + id: ID! + badge(nickname: String! @require(field: "nickname")): String! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + user(id: "1") { + name + ... @defer(label: "outer") { + email + ... @defer(label: "inner") { + badge + } + } + } + } + """); + + // assert + AssertNoMixedRequirementScope(plan); + } + + [Fact] + public void Defer_StepInternalPredecessor_Should_Keep_NodeRequirements_AllImported_Or_AllLocal() + { + // arrange + // The incremental plan needs an extra hop on schema c to fetch a value that another + // incremental plan node consumes via @require. The chained downstream node has only + // incremental plan-internal predecessors and so must route through the local store + // path, not the imported snapshot path. + var schema = ComposeSchema( + """ + # name: a + schema { + query: Query + } + + type Query { + product(id: ID!): Product @lookup + } + + type Product @key(fields: "id") { + id: ID! + name: String! + } + """, + """ + # name: b + schema { + query: Query + } + + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + productSku: String! + } + """, + """ + # name: c + schema { + query: Query + } + + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + reviews(productSku: String! @require(field: "productSku")): [String!]! + } + """); + + // act + var plan = PlanOperation( + schema, + """ + query { + product(id: "1") { + name + ... @defer { + reviews + } + } + } + """); + + // assert + AssertNoMixedRequirementScope(plan); + } + + /// + /// Asserts the planner contract that backs the defer-time variable routing in + /// OperationPlanContext.CreateVariableValueSets. The runtime classifies + /// each node's requirement span by computing the executor's imported-key set + /// (the union of every parent-dependent node's requirement keys, mirroring + /// OperationPlanExecutor.CollectParentScopeRequirements) and then + /// asserting that any single node's requirement keys are either fully inside + /// that set or fully outside it. A partial overlap is an unreachable shape + /// that the routing layer rejects with an explicit invariant exception. + /// + private static void AssertNoMixedRequirementScope(OperationPlan plan) + { + foreach (var incrementalPlan in plan.IncrementalPlans) + { + var importedKeys = ComputeImportedKeys(incrementalPlan); + + foreach (var node in incrementalPlan.AllNodes) + { + switch (node) + { + case OperationExecutionNode operationNode: + AssertScopeIsUniform(operationNode.Requirements, importedKeys, incrementalPlan, operationNode.Id); + break; + + case OperationBatchExecutionNode batchNode: + foreach (var op in batchNode.Operations) + { + AssertScopeIsUniform(op.Requirements, importedKeys, incrementalPlan, op.Id); + } + break; + } + } + } + } + + private static HashSet ComputeImportedKeys(IncrementalPlan incrementalPlan) + { + var keys = new HashSet(StringComparer.Ordinal); + + foreach (var node in incrementalPlan.AllNodes) + { + switch (node) + { + case OperationExecutionNode operationNode when !operationNode.ParentDependencies.IsEmpty: + AddRequirementKeys(operationNode.Requirements, keys); + break; + + case OperationBatchExecutionNode batchNode: + foreach (var op in batchNode.Operations) + { + if (!op.ParentDependencies.IsEmpty) + { + AddRequirementKeys(op.Requirements, keys); + } + } + break; + } + } + + return keys; + } + + private static void AddRequirementKeys( + ReadOnlySpan requirements, + HashSet keys) + { + foreach (var requirement in requirements) + { + keys.Add(requirement.Key); + } + } + + private static void AssertScopeIsUniform( + ReadOnlySpan requirements, + HashSet importedKeys, + IncrementalPlan incrementalPlan, + int nodeId) + { + if (requirements.Length == 0) + { + return; + } + + var importedCount = 0; + + foreach (var requirement in requirements) + { + if (importedKeys.Contains(requirement.Key)) + { + importedCount++; + } + } + + if (importedCount != 0 && importedCount != requirements.Length) + { + var imported = new List(); + var local = new List(); + + foreach (var requirement in requirements) + { + if (importedKeys.Contains(requirement.Key)) + { + imported.Add(requirement.Key); + } + else + { + local.Add(requirement.Key); + } + } - var subPlan = plan.DeferredSubPlans[0]; - Assert.False(subPlan.RootNodes.IsEmpty); - Assert.False(subPlan.AllNodes.IsEmpty); + Assert.Fail( + $"Incremental plan '{incrementalPlan.Id}' node {nodeId} has a requirement span that mixes " + + $"imported parent-sourced keys [{string.Join(", ", imported)}] with local keys " + + $"[{string.Join(", ", local)}]. The runtime variable routing layer assumes " + + "every node's requirements are either all imported or all local."); + } } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_MultipleDeferGroupsShareRequirement_Should_DeduplicateHoistedField.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_MultipleDeferGroupsShareRequirement_Should_DeduplicateHoistedField.yaml new file mode 100644 index 00000000000..733413d86ec --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_MultipleDeferGroupsShareRequirement_Should_DeduplicateHoistedField.yaml @@ -0,0 +1,80 @@ +operation: + - document: | + { + user(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + user(id: "1") { + name + id + } + } +deliveryGroups: + - id: 0 + path: $.user + label: contact + - id: 1 + path: $.user + label: location +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 + - deliveryGroupIds: + - 1 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + address + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerAndOuterShareRequirement_Should_DeduplicateAtOuter.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerAndOuterShareRequirement_Should_DeduplicateAtOuter.yaml new file mode 100644 index 00000000000..a57a4ea79b0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerAndOuterShareRequirement_Should_DeduplicateAtOuter.yaml @@ -0,0 +1,140 @@ +operation: + - document: | + { + product(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + product(id: "1") { + name + id + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_defer_4($__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + productSku + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_4_id + selectionMap: >- + id +deliveryGroups: + - id: 0 + path: $.product + label: outer + - id: 1 + path: $.product + label: inner + parentId: 0 +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_productSku + selectionMap: >- + productSku + - name: __fusion_3_id + selectionMap: >- + id + nodes: + - id: 2 + type: Operation + schema: c + operation: | + query Op_defer_3($__fusion_2_productSku: String!, $__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + reviews(productSku: $__fusion_2_productSku) + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_2_productSku + selectionMap: >- + productSku + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 + - parentNodeId: 2 + - id: 3 + type: Operation + schema: a + operation: | + query Op_defer_1 { + product(id: "1") { + id + } + } + - id: 4 + type: Operation + schema: b + operation: | + query Op_defer_4($__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + productSku + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_4_id + selectionMap: >- + id + - deliveryGroupIds: + - 1 + parentNodeId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_productSku + selectionMap: >- + productSku + - name: __fusion_3_id + selectionMap: >- + id + nodes: + - id: 2 + type: Operation + schema: c + operation: | + query Op_defer_3($__fusion_2_productSku: String!, $__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + summary(productSku: $__fusion_2_productSku) + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_2_productSku + selectionMap: >- + productSku + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - parentNodeId: 3 + - parentNodeId: 4 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_OnForeignSubgraph_Should_PlanLookupInOuterScope.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_OnForeignSubgraph_Should_PlanLookupInOuterScope.yaml new file mode 100644 index 00000000000..ad256fb3806 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_OnForeignSubgraph_Should_PlanLookupInOuterScope.yaml @@ -0,0 +1,117 @@ +operation: + - document: | + { + user(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + user(id: "1") { + name + id + } + } +deliveryGroups: + - id: 0 + path: $.user + label: outer + - id: 1 + path: $.user + label: inner + parentId: 0 +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + batchingGroupId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 + - id: 3 + type: Operation + schema: b + operation: | + query Op_defer_4($__fusion_4_id: ID!) { + userById(id: $__fusion_4_id) { + nickname + } + } + source: $.userById + target: $.user + batchingGroupId: 1 + requirements: + - name: __fusion_4_id + selectionMap: >- + id + - id: 2 + type: Operation + schema: a + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - deliveryGroupIds: + - 1 + parentNodeId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_nickname + selectionMap: >- + nickname + - name: __fusion_3_id + selectionMap: >- + id + nodes: + - id: 2 + type: Operation + schema: c + operation: | + query Op_defer_3($__fusion_2_nickname: String!, $__fusion_3_id: ID!) { + userById(id: $__fusion_3_id) { + badge(nickname: $__fusion_2_nickname) + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_2_nickname + selectionMap: >- + nickname + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - parentNodeId: 2 + - parentNodeId: 3 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_Should_ResolveAgainstOuterDefer.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_Should_ResolveAgainstOuterDefer.yaml new file mode 100644 index 00000000000..82e4a1e1e85 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_Should_ResolveAgainstOuterDefer.yaml @@ -0,0 +1,90 @@ +operation: + - document: | + { + user(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + user(id: "1") { + name + id + } + } +deliveryGroups: + - id: 0 + path: $.user + label: outer + - id: 1 + path: $.user + label: inner + parentId: 0 +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 + - id: 2 + type: Operation + schema: a + operation: | + query Op_defer_1 { + user(id: "1") { + id + } + } + - deliveryGroupIds: + - 1 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + address + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_Should_ResolveAtOuterScope_When_SameSubgraphAsOuterKey.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_Should_ResolveAtOuterScope_When_SameSubgraphAsOuterKey.yaml new file mode 100644 index 00000000000..745e2143dac --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_NestedDefer_InnerRequirement_Should_ResolveAtOuterScope_When_SameSubgraphAsOuterKey.yaml @@ -0,0 +1,97 @@ +operation: + - document: | + { + user(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + user(id: "1") { + name + id + } + } +deliveryGroups: + - id: 0 + path: $.user + label: outer + - id: 1 + path: $.user + label: inner + parentId: 0 +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 + - id: 2 + type: Operation + schema: a + operation: | + query Op_defer_1 { + user(id: "1") { + id + preferences + } + } + - deliveryGroupIds: + - 1 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_preferences + selectionMap: >- + preferences + nodes: + - id: 1 + type: Operation + schema: c + operation: | + query Op_defer_2($__fusion_1_id: ID!, $__fusion_2_preferences: String!) { + userById(id: $__fusion_1_id) { + customized(preferences: $__fusion_2_preferences) + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_preferences + selectionMap: >- + preferences + dependencies: + - parentNodeId: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_RequirementOnForeignSubgraph_Should_PlanParentScopeLookup.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_RequirementOnForeignSubgraph_Should_PlanParentScopeLookup.yaml new file mode 100644 index 00000000000..b3a3298189d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_RequirementOnForeignSubgraph_Should_PlanParentScopeLookup.yaml @@ -0,0 +1,76 @@ +operation: + - document: | + { + product(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + product(id: "1") { + name + id + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_defer_4($__fusion_4_id: ID!) { + productById(id: $__fusion_4_id) { + productSku + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_4_id + selectionMap: >- + id +deliveryGroups: + - id: 0 + path: $.product +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 2 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_productSku + selectionMap: >- + productSku + - name: __fusion_3_id + selectionMap: >- + id + nodes: + - id: 2 + type: Operation + schema: c + operation: | + query Op_defer_3($__fusion_2_productSku: String!, $__fusion_3_id: ID!) { + productById(id: $__fusion_3_id) { + reviews(productSku: $__fusion_2_productSku) + } + } + source: $.productById + target: $.product + requirements: + - name: __fusion_2_productSku + selectionMap: >- + productSku + - name: __fusion_3_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1 + - parentNodeId: 2 diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_RequirementReachableFromParent_Should_InjectIntoParentOp_When_SameSubgraph.yaml b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_RequirementReachableFromParent_Should_InjectIntoParentOp_When_SameSubgraph.yaml new file mode 100644 index 00000000000..2e4caf51af7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/__snapshots__/DeferPlannerTests.Defer_RequirementReachableFromParent_Should_InjectIntoParentOp_When_SameSubgraph.yaml @@ -0,0 +1,51 @@ +operation: + - document: | + { + user(id: "1") { + name + id @fusion__requirement + } + } + hash: 123456789101112 + searchSpace: 1 + expandedNodes: 1 +nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + user(id: "1") { + name + id + } + } +deliveryGroups: + - id: 0 + path: $.user +incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_2($__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - parentNodeId: 1