Skip to content

Fix defer parentNodeId resolution and pooled path aliasing in operation plan traces - #10291

Merged
michaelstaib merged 17 commits into
mainfrom
fix/operation-plan-defer
Aug 28, 2026
Merged

michaelstaib merged 17 commits into
mainfrom
fix/operation-plan-defer

Conversation

@rstaib

@rstaib rstaib commented Aug 24, 2026

Copy link
Copy Markdown
Member

Fixes two @defer-related bugs in Fusion's operation plan output (extensions.fusion.operationPlan).

IncrementalPlan.ParentNodeId resolves to the wrong root node

When a deferred fragment was anchored under a field resolved by the second (or later) root fetch node, the plan reported the first root node as parentNodeId. All root nodes share Target = $, so the deepest-ancestor selection in ResolveDeferParentNodeId always kept the first one. A candidate now only qualifies if the first field segment of the defer path below its target is a response name in its root ResultSelectionSet; the deepest-target preference among qualifying candidates is kept, with a fallback to the previous behavior when no candidate qualifies (avoids turning exotic shapes into IncrementalPlanParentNotFound).

ExecutionNodeTrace.VariableSets aliases pooled path arrays

Trace variable sets captured CompactPaths wrapping int[]s rented from the store's path pool. After a node's results merged, the arrays were returned and re-rented by a later node, so serializing the trace at request completion printed the later node's requirement paths under the earlier node's variableSets. When telemetry is collected, TrackVariableValueSets now stores detached copies of Path and AdditionalPaths (new CompactPath.Detach() / CompactPathSegment.Detach()); the pooled originals still flow through the request/merge path unchanged, so the hot path is unaffected.

Tests

  • Folded the repro into DeferTests as Defer_Anchored_Under_Second_Root_Node_Should_Report_Correct_ParentNode (snapshot now pins parentNodeId: 2) and added a three-root case pinning parentNodeId: 3.
  • New OperationPlanTelemetryTests asserts an earlier lookup's trace keys are its own target paths after a dependent lookup builds deeper paths; both new tests fail without their fix.
  • Full Fusion.Execution.Tests and Fusion.AspNetCore.Tests suites pass; only the intended snapshot values changed.

@github-actions

github-actions Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Patch coverage

88.6% of changed lines covered (839/947)

File Covered Changed Patch %
…/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs 5 11 45.5% 🔴
…/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs 12 21 57.1% 🔴
…/src/Fusion.Execution/Planning/DeferOperationRewriter.cs 62 78 79.5% 🔴
…/Planning/OperationPlanner.BuildExecutionTree.cs 26 29 89.7% 🟡
…/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs 692 766 90.3% 🟡
…/src/Fusion.Execution/Execution/OperationPlanContext.cs 11 11 100.0% 🟢
…/Planning/OperationPlannerGuardrailException.cs 15 15 100.0% 🟢
…/Fusion/src/Fusion.Execution/Planning/PlanQueue.cs 7 7 100.0% 🟢
…/Fusion/src/Fusion.Execution/Text/Json/CompactPath.cs 9 9 100.0% 🟢
Uncovered changed lines (JSON)
{
  "sha": "f34b125455438e31379fb8476100c5efefc1e502",
  "files": [
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/CompactPathSegment.cs", "ranges": [[66, 66], [68, 71], [73, 73]] },
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs", "ranges": [[265, 270], [277, 279]] },
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Planning/DeferOperationRewriter.cs", "ranges": [[383, 384], [394, 396], [418, 421], [423, 423], [426, 429], [431, 431], [534, 534]] },
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs", "ranges": [[206, 206], [265, 266]] },
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs", "ranges": [[224, 225], [261, 264], [280, 280], [325, 326], [331, 331], [421, 421], [423, 423], [531, 531], [533, 533], [602, 603], [606, 610], [691, 692], [718, 719], [775, 776], [816, 817], [827, 828], [843, 844], [882, 883], [887, 888], [896, 897], [946, 949], [979, 980], [984, 986], [1110, 1112], [1163, 1166], [1168, 1172], [1175, 1175], [1425, 1426], [1538, 1539], [1546, 1547], [1597, 1598], [1670, 1671], [1733, 1733], [1763, 1764]] }
  ]
}

Project coverage: 57.5% (283895/493493 lines)

michaelstaib and others added 2 commits August 25, 2026 12:39
Anchored inside a parent result (a list item, an object), a deferred
fragment was planned by re-seeding a fresh operation rooted at Query
and re-fetching the whole path from root: nothing tied the deferred
selection to the parent fetch beyond position, lists were fetched
twice, and mixed-subgraph deferred selections silently dropped
same-subgraph fields (OperationPlanner.PlanIncrementalPlan always
seeded via CreateQueryPlanBase; ApplyDeferRequirementsToParent assumed
a single self-fetch step at index 0).

PlanIncrementalPlan now seeds a Lookup work item rooted at the anchor's
own type for any non-root-anchored defer (falling back to a
NodeFieldWorkItem for a `node(id)` anchor, and to the prior root
re-fetch when the anchor type has no reachable lookup at all), letting
PlanQueue pick a keyed lookup off the parent's own key. PlanQueue needed
one narrow addition: EnqueueLookupPlanNodes's self-reentry exclusion
could leave a schema that owns both a list field and the keyed lookup
for its own item type with zero candidate plans, since the parent-path
walk was skipped along with the lookup attempt; the walk is now still
tried for that one case, since it is a plain field re-fetch, never a
lookup call.

ApplyDeferRequirementsToParent no longer assumes the self-fetch is
step 0: it finds every step that exists purely to relay a key value
toward the deferred selection (target at the anchor or a bootstrapped
root re-fetch, every field it selects consumed by its own dependents),
routes each through the existing parent-scope walker, and sees through
a chain of such relays to the step that actually grounds the value
instead of promoting an intermediate pass-through hop.

Pins five new planner shapes (list anchor with a sibling lookup on
multiple root fields, same+foreign subgraph split, no-lookup object
anchor, root anchor, and a key-only defer where the only lookup is
self-cyclic) plus two new end-to-end tests, and updates existing defer
snapshots whose defer-operation numbering shifted under the new
seeding order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHsog5kcxQ2rMZ2LiYCuSs
@michaelstaib michaelstaib self-assigned this Aug 25, 2026

@michaelstaib michaelstaib left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am working on changes ... so do not merge

michaelstaib and others added 14 commits August 25, 2026 15:07
RemoveReroutedInPlanEdges used to sever a provider-to-dependent edge
wholesale whenever any one of the dependent's requirements was rerouted
to the parent scope, even when another requirement from the same
dependent still needed that provider. Sever the edge only when none of
the dependent's remaining requirements still resolves back to the same
provider, and keep dropping only the rerouted field once nothing else
consumes it.

Adds a regression test covering a downstream step with two
requirements from the same in-plan provider where only the one at the
defer anchor path reroutes to the parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2TfmiTonT2kyPi3S6Rjjy
Reduce the multi-sentence design-justification comments in PlanQueue's
self-reentry candidate-schema check to 1-2 sentences that state what
the code does, and trim the matching three-line arrange/assert
descriptions in the two newest DeferTests to a single line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2TfmiTonT2kyPi3S6Rjjy
BuildIncrementalPlanOperation forced OperationType.Query on every
incremental plan operation, so a mutation-anchored defer's internal
operation lied about its own root type. That broke root-type
resolution for BuildIncrementalPlans/AddTypeNameToAbstractSelections
and made PlannerExtensions.GetPossibleLookupsThroughPath treat the
mutation's own root field as reachable through Query, producing a
KeyNotFoundException instead of a keyed lookup.

Fix:
- DeferOperationRewriter no longer forces the incremental operation to
  Query; it keeps the original operation type, which is all
  BuildIncrementalPlans and the executor's delivery-path walk need.
- A defer wrapping the operation's own root field(s) (Path.IsRoot) on
  a mutation has no entity to key a lookup off, so planning now throws
  DeferredMutationLookupRequiredException instead of silently forcing
  Query and reaching a KeyNotFoundException deep in the query-root
  machinery.
- A defer anchored inside a mutation result's own object graph cannot
  walk back to the operation root for a lookup key the way a
  query-anchored defer does: GetPossibleLookupsThroughPath refuses
  that walk for non-Query operations to avoid re-running the mutation.
  The incremental plan is instead seeded like the main plan's own
  mutation root fetch (CreateMutationPlanBase), so the key field is
  resolved directly on the mutation's own step; the resulting
  key-only producer step is still recognized and dropped by
  ApplyDeferRequirementsToParent, which routes it to the parent scope
  so the mutation runs exactly once.
- A mutation-typed step that survives that routing pass carries real
  deferred output rather than just a key, meaning it could only be
  produced by invoking the mutation root field a second time. That is
  now rejected with the same guardrail instead of ever reaching the
  executor.

Un-skips DeferPlannerTests.Defer_OnMutationResult_Should_ProduceDeferredGroup
and DeferTests.Defer_On_Mutation_Result_Should_Return_Incremental_Response,
and adds two planner tests covering the "no lookup available" guardrail
(root-anchored and result-anchored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2TfmiTonT2kyPi3S6Rjjy
BuildIncrementalPlanOperation built the incremental plan's operation from
the root operation and only replaced the selection set, so it copied the
root operation's variable definitions wholesale even when the reconstructed
deferred selection referenced only a subset (or none) of them. This left
unused variables declared on the skeleton operation that BuildDeferredExecutionNodes
consumes, and inflated the hasVariables check derived from it.

Collect the variable names actually referenced by the reconstructed
selection set (field arguments including nested list/object values, and
directives on fields and inline fragments) and keep only the matching
VariableDefinitionNodes, preserving their original document order. The
already-stripped @defer(if: $var) directive cannot keep a definition alive
on its own, since collection runs after StripDeferDirective removes it.

Add DeferPlannerTests cases asserting the incremental plan operation drops
an unused root variable and keeps one referenced by the deferred selection.
The safety-net throw in RouteIncrementalPlans reported the operation's
root type name (Mutation) instead of the surviving step's real anchor
type, unlike the other DeferredMutationLookupRequiredException throw
site. Resolve the anchor type from the descriptor's own path when
reachable, falling back to the operation root type name otherwise, and
correct the DeferPlannerTests guardrail test that pinned TypeName ==
"Mutation" for a non-root anchor.

Also moves the DeferredMutationLookupRequiredException message into
FusionExecutionResources (matching the existing guardrail-exception
pattern) and validates path/typeName before the message is formatted.

Adds a DeferPlannerTests coverage case for a defer nested one level
below the mutation's own root field (createUser.child): the planner
already produces a keyed lookup incremental plan that depends on the
mutation node, with the mutation executing exactly once, so no
guardrail follow-up is needed here.

Comment trims adjacent to the anchor-type fix in
OperationPlanner.Defer.cs (same hunks) were folded into this commit
since git commit -- <pathspec> stages the whole file, not just the
touched hunks.
DeferOperationRewriter.BuildIncrementalPlanOperation pruned unused
variable definitions only when the used-variable count differed from
the definition count, which coincidentally matches in some cases even
when the referenced set is not the same. Always build the filtered
list and swap it in whenever its length differs from the original.

CollectUsedVariables did not visit FragmentSpreadNode directives, an
unreachable case today (DeferPartitioner inlines spreads before this
runs) but a latent trap for future callers; it now visits them like
the other selection kinds.

Trims a few multi-sentence, rationale-heavy comments and XML docs down
to the 1-2 sentence contract style, and a "// assert: ..." comment in
DeferTests down to a bare "// assert" with the detail moved beneath.
A deferred selection whose fields the enclosing plan scope already has to
fetch anyway produces no incremental benefit: honoring @defer for it only
adds a redundant fetch (or, in the worst case, a wasteful root re-fetch
when the anchor type's only lookup is self-cyclic on the deferred key
itself). The incremental delivery spec explicitly allows a server to skip
deferring in that case: it delivers the fields in the initial data with no
pending entry and no delivery group.

Add TryAbsorbFullyRedundantDefer, a post-planning check run after each
incremental plan descriptor is routed. It recognizes two redundant shapes:
a plain (non-lookup) producer step whose fields are exactly the anchor
type's own key fields, which any schema exposing the entity resolves for
free; and a genuine keyed lookup whose entire output is already selected
on the enclosing step as a fusion__requirement for an unrelated reason. In
both cases the fields are made visible (any fusion__requirement marker is
stripped) on the enclosing step and its internal operation, and the caller
skips producing an incremental plan and delivery group for the descriptor.
The optimization is restricted to leaf descriptors so nested @defer
children keep a valid registered enclosing scope, and skips any step
guarded by @skip/@include so a variable-conditioned defer's eager main-plan
copy is never mistaken for unconditionally available data.

OperationPlanner.cs filters the compiled plan's delivery groups to the set
still referenced by a routed descriptor, so an absorbed descriptor's
delivery group never surfaces as a pending entry with nothing to complete
it. RemoveUnconsumedEntityField is refactored to share the new
WithEntitySelectionSet helper with the new code path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2TfmiTonT2kyPi3S6Rjjy
Replace Defer_KeyOnlyField_Should_KeepRootRefetch_When_OnlyReachableLookupIsSelfCyclic
(which pinned a wasteful root re-fetch of the whole list to re-obtain a key
the parent already reaches) with
Defer_KeyOnlyField_Should_NotDefer_When_OnlyReachableLookupIsSelfCyclic,
pinning the new not-deferred shape: no incremental plans, no delivery
groups, and the key visible directly in the main operation document.

Add Defer_FieldAlreadyRequiredByParent_Should_NotDefer, covering the other
redundant shape: a field already fetched as a fusion__requirement for an
unrelated sibling field's cross-subgraph requirement is not deferred a
second time; its fusion__requirement marker is stripped instead so it
appears in the initial data.

Add the matching end-to-end case, Defer_KeyOnlyField_Should_Return_NonStreamed_Result,
pinning a single non-streamed payload with the key in the initial data and
no pending entry, comparable to the existing @defer(if: false) shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2TfmiTonT2kyPi3S6Rjjy
…eaks

MakeFieldsVisible could only guess, from selection-set shape, whether a
lone __typename in a redundant defer's parent target was the defer
split's synthetic placeholder or a field the client actually selected;
the guess dropped both, silently discarding client-requested __typename
siblings. It also masked a second bug: because DeferOperationRewriter's
placeholder carried no marker, it was already leaking into the
client-visible response whenever a deferred fragment was a field's only
content, independent of the redundant-defer absorb path.

DeferOperationRewriter now marks its two synthesized __typename
placeholders with the existing internal fusion__empty directive, the
same marker OperationCompiler and RemoveInternalDirectives already
understand for excluding synthetic selections from outgoing requests and
client responses. MakeFieldsVisible no longer needs the shape guess: a
client-selected __typename (no directive) survives merges untouched, and
the marked placeholder is stripped downstream once real fields join it.

Also hardens TryAbsorbFullyRedundantDefer to use the failure-signalling
TryLocateSelectionSetAtPath instead of the unchecked
LocateSelectionSetAtPath, so a parent step whose definition does not
actually reach the anchor path declines the optimization instead of
silently dropping the deferred fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pins that a client selecting __typename alongside a redundant
key-only @defer keeps that field in the initial payload (planner and
end-to-end), and reviews the resulting snapshot churn on two existing
defer tests now that the synthetic __typename placeholder is correctly
excluded from source requests and client responses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the absorb-schema match

TryCollectFlatFieldNames unconditionally skipped every __typename, including one the
client explicitly selected inside a deferred fragment, so TryAbsorbFullyRedundantDefer
dropped it entirely once the incremental plan collapsed into the parent step. It now
skips only the synthetic fusion__empty placeholder and declines the optimization for any
field carrying a real @skip/@include-style directive instead of silently absorbing it.
MakeFieldsVisible can now upgrade an existing placeholder __typename node into a plain
one, and __typename is exempted from the already-present/key-field checks since every
schema resolves it for free.

The parent-step search also required the candidate to sit on the same schema as the
surviving incremental step, which meant a defer whose only surviving producer routed to
a schema the parent never separately visits (e.g. a self-cyclic lookup falling back to a
different schema once __typename widens reachability) could never be absorbed even
though its output was already available elsewhere in the parent scope. The search now
prefers a same-schema candidate first and falls back to any step covering the anchor
path, while the key-field fallback branch (no exact field match) still requires schema
identity, since only that schema is guaranteed to resolve the missing key fields for
free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The XML doc on s_placeholderTypeNameField explained why the placeholder
exists instead of stating its contract. Reword it to plain, direct
sentences per AGENTS.md's doc comment rules.

Also clear two analyzer findings in the same file: mark
StripDeferFromSelectionSet static since it touches no instance state,
and replace a foreach-Add loop with AddRange.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants