Skip to content

feat: add initial defer support#1365

Draft
devsergiy wants to merge 79 commits intomasterfrom
feat/eng-7770-add-defer-support
Draft

feat: add initial defer support#1365
devsergiy wants to merge 79 commits intomasterfrom
feat/eng-7770-add-defer-support

Conversation

@devsergiy
Copy link
Copy Markdown
Member

@devsergiy devsergiy commented Jan 20, 2026

Defer Support Implementation

This branch adds end-to-end support for the @defer directive in GraphQL execution.

pkg/astnormalization

new rules

  • inlineFragmentExpandDefer - expands @defer into per-field inline internal defer directives
  • deferEnsureTypename - injects typename selection in case all fields in a selection set are deferred

updated rules

  • directive_include_skip - shares a helper to add an internal typename placeholder
  • field_deduplication - now merges defer directives during merging duplicated fields
  • inline_fragment_selection_merging - same as field_deduplication - takes defer into account

operation normalizer

The order of the rules is slightly changed

pkg/ast

Added defer-related helpers to ast_field.go, ast_directive.go, ast_argument.go, ast_inline_fragment.go

pkg/asttransform

Added internal defer directive definitions to the base schema and updated fixtures

pkg/engine/plan

File Change What
plan.go added New DeferResponsePlan type and DeferResponsePlanKind constant
planner_configuration.go modified pathConfiguration gets deferID; path index stores full config instead of empty struct; new DeferID() / PathWithFieldRef() accessors
path_builder_visitor.go major Fields now could be planned multiple times per single ds suggestion (deferred path, defer-parent path, normal path); refactor to use currentFieldInfo struct instead of passing separate arguments; objectFetchConfiguration now have deferID
node_selection_visitor.go modified Requirement tracking structs gain deferInfo / parentFieldDeferID; deduplication key is now per defer scope
required_fields_visitor.go major Required fields updates alias logic and handles applying @__defer_internal annotations to the fields to put them into the correct scope; new resolveDeferredAlias() helper is added to create proper defer alias
datasource_filter_node_suggestions.go modified NodeSuggestion gets DeferInfo; new ProcessDefer() propagates defer IDs to nodes parents up to the root query node or root entity nodes which requires a key - e.g. to nearest query entry point
datasource_filter_collect_nodes_visitor.go modified Reads @__defer_internal during tree building to populate DeferInfo on suggestions
abstract_selection_rewriter*.go modified typeNameSelection() takes a deferID to give an injected __typename a proper scope
node_selection_builder.go modified Calls ProcessDefer() after process if node selection is finished - to propagate defer ids to parents
visitor.go refactored Emits DeferResponsePlan; misc cleanup
analyze_plan_kind.go deleted Removed; we now determining if the is deffered based on fetches having defer id

pkg/postprocess

postprocess.go

  • Refactored the Processor struct: processors are now grouped into FetchTreeProcessors and ResponseTreeProcessors named structs instead of anonymous slices.
  • Processing is split into two explicit phases: processFlatFetchTree (dedupe → fetchID → templates → deps → concrete types) and organizeFetchTree (order by deps → parallelize).
  • A new DeferResponsePlan case was added — it processes the initial response tree normally, calls extractDeferFetches to split out deferred fetch groups, then runs organizeFetchTree on the initial response and on each deferred group separately.
  • New DisableExtractDeferFetches() option added.

new processor extract_defer_fetches.go

Splits a flat DeferResponsePlan fetch tree into two buckets:

  • fetches without a deferID stay in the root sequence;
  • fetches with a deferID are grouped by that ID into DeferFetchGroup entries on Response.Defers, sorted in natural numeric order.

pkg/engine/postprocess

postprocess.go

  • Refactored the Processor struct: processors are now grouped into FetchTreeProcessors and ResponseTreeProcessors named structs instead of anonymous slices.
  • Processing is split into two explicit phases: processFlatFetchTree (dedupe → fetchID → templates → deps → concrete types) and organizeFetchTree (order by deps → parallelize).
  • A new DeferResponsePlan case was added — it processes the initial response tree normally, calls extractDeferFetches to split out deferred fetch groups, then runs organizeFetchTree on the initial response and on each deferred group separately.
  • New DisableExtractDeferFetches() option added.

new processor extract_defer_fetches.go

Splits a flat DeferResponsePlan fetch tree into two buckets: - fetches without a deferID stay in the root sequence; - fetches with a deferID are grouped by that ID into DeferFetchGroup entries on Response.Defers, sorted in natural numeric order.

pkg/engine/resolve

response.go, fetch.go, node_object.go, const.go

  • Added supporting data structures for defer: GraphQLDeferResponse, DeferFetchGroup, DeferResponseWriter interface
  • DeferID added to FetchDependencies and Field via DeferField struct

loader.go

  • Init() extracted as a public method;
  • resolveFetchNode made public as ResolveFetchNode so the resolver can call it separately for the initial and each deferred fetch group.

resolvable.go

  • New method ResolveDefer() is added to render a single incremental response chunk ({"incremental":[...],"hasNext":...}) for one defer group. Two-pass approach: dry-run first to catch auth errors, then real render.
  • New state fields deferMode, deferID, enableDeferRender are added to gate which fields get rendered per resolve pass.

resolve.go

New entry point ResolveGraphQLDeferResponse() is added:

  • It orchestrates full defer execution: loads and flushes the initial response
  • Iterates over DeferFetchGroups. Each group executes fetches, resolves data for the give group and flushing a chunk of data

Integration tests

  • execution/engine/execution_engine_defer_test.go — large end-to-end test suite
  • v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go — datasource-level defer tests
  • Introspection fixtures updated to include @defer in schema

closes ENG-8799
closes ENG-7978
closes ENG-7976

Checklist

  • I have discussed my proposed changes in an issue and have received approval to proceed.
  • I have followed the coding standards of the project.
  • Tests or benchmarks have been added or updated.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 20, 2026

📝 Walkthrough

Walkthrough

This pull request implements comprehensive support for the GraphQL @defer directive, enabling incremental response delivery. It adds AST normalization for deferred inline fragments, execution engine support for streaming deferred responses, federation-aware defer planning, and defer-aware field merging. Schema artifacts and test fixtures are updated to reflect the new @defer and @__defer_internal directives.

Changes

Cohort / File(s) Summary
AST & Directive Utilities
v2/pkg/ast/ast_argument.go, v2/pkg/ast/ast_directive.go, v2/pkg/ast/ast_field.go, v2/pkg/ast/ast_inline_fragment.go
Added helper methods for directive and argument manipulation: AddStringArgument, HasDirectiveByNameBytes, RemoveDirectiveByRef, MergeFieldsDefer, AddDeferInternalDirectiveToField, FieldInternalDeferID, and InlineFragmentDirectiveByName. Updated DirectiveSetsAreEqual to treat @__defer_internal-only sets as equivalent to empty sets and perform order-independent directive matching.
AST Normalization Passes
v2/pkg/astnormalization/astnormalization.go, v2/pkg/astnormalization/inline_fragment_expand_defer.go, v2/pkg/astnormalization/defer_ensure_typename.go
Introduced WithInlineDefer() option and two new normalization passes: inlineFragmentExpandDefer expands @defer directives on inline fragments into explicit selections with @__defer_internal annotations; deferEnsureTypename adds placeholder __typename fields to preserve non-empty selection sets when all siblings are deferred.
Normalization Tests & Helpers
v2/pkg/astnormalization/astnormalization_test.go, v2/pkg/astnormalization/inline_fragment_expand_defer_test.go, v2/pkg/astnormalization/defer_ensure_typename_test.go, v2/pkg/astnormalization/field_deduplication.go, v2/pkg/astnormalization/field_deduplication_test.go
Extended test coverage for defer functionality with new test cases validating inline defer expansion, typename placeholder insertion, and field deduplication with defer directives. Updated field deduplication to call MergeFieldsDefer when merging equivalent leaf fields. Refactored test helpers to use options structs instead of variadic booleans.
Additional Normalization Updates
v2/pkg/astnormalization/directive_include_skip.go, v2/pkg/astnormalization/directive_include_skip_test.go, v2/pkg/astnormalization/fragment_spread_inlining_test.go, v2/pkg/astnormalization/inline_fragment_selection_merging.go, v2/pkg/astnormalization/inline_fragment_selection_merging_test.go, v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go
Refactored typename placeholder insertion (renamed alias from __internal__typename_placeholder to ___typename), added defer-aware field merging in MergeFieldsDefer calls, and extended test cases for defer interaction scenarios.
Base Schema & Fixtures
v2/pkg/asttransform/base.graphql, v2/pkg/asttransform/internal.graphql, v2/pkg/asttransform/baseschema.go, v2/pkg/asttransform/baseschema_test.go
Introduced embedded GraphQL schema files defining standard scalars, directives (@defer), and introspection types. Updated MergeDefinitionWithBaseSchema to delegate to new MergeDefinitionWithBaseSchemaWithInternal function for conditional internal-directive inclusion.
Golden Fixtures
v2/pkg/asttransform/fixtures/*.golden, v2/pkg/asttransform/stream.graphql
Updated all golden schema fixtures with backtick-formatted scalar descriptions, enhanced @specifiedBy and new @defer/@__defer_internal directive definitions. Added stream.graphql with @stream directive support.
Introspection & Federation
v2/pkg/introspection/generator.go, v2/pkg/introspection/generator_test.go, v2/pkg/introspection/testdata/starwars.schema.graphql, v2/pkg/introspection/fixtures/starwars_introspected.golden, v2/pkg/federation/schema.go, v2/pkg/federation/fixtures/federated_schema.golden
Updated introspection generator to filter out __-prefixed directives, removed redundant introspection types from test schema (now sourced from base schema), and updated federation schema to use MergeDefinitionWithBaseSchemaWithInternal. Updated golden fixtures with @defer, @oneOf, @specifiedBy directives and scalar description formatting.
Engine Introspection Fixtures
execution/engine/testdata/full_introspection*.json, v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection*.golden
Updated introspection JSON/golden outputs to include @defer directive metadata, backtick-formatted scalar descriptions, and enhanced @specifiedBy argument descriptions.
Execution Engine Core
execution/engine/execution_engine.go, execution/engine/config_factory_proxy_test.go, execution/engine/engine_config_test.go
Added inline-defer normalization in operation pipeline. Updated execution dispatch to handle *plan.DeferResponsePlan via ResolveGraphQLDeferResponse. Enhanced schema introspection with @defer and @__defer_internal directives in embedded test data.
Execution Engine Tests
execution/engine/execution_engine_test.go, execution/engine/execution_engine_helpers_test.go, execution/engine/execution_engine_defer_test.go
Added streaming response support to test framework with withStreamingResponse() option. Enhanced mock-response helpers to track request usage and support assertion logging. Introduced comprehensive execution_engine_defer_test.go (2308 lines) validating defer across scalar/nested fields, federation scenarios, and error handling.
Plan Types & Configuration
v2/pkg/engine/plan/plan.go, v2/pkg/engine/plan/configuration.go, v2/pkg/engine/plan/datasource_configuration.go
Added DeferResponsePlanKind and DeferResponsePlan type implementing Plan interface. Added DisableCalculateFieldDependencies configuration flag. Removed FetchID from DataSourcePlannerConfiguration and deprecated OverrideFieldPathFromAlias.
Node Selection & Filtering
v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go, v2/pkg/engine/plan/datasource_filter_node_suggestions.go, v2/pkg/engine/plan/node_selection_builder.go, v2/pkg/engine/plan/node_selection_visitor.go
Added defer metadata extraction through DeferInfo type and propagation to node suggestions. Implemented ProcessDefer to propagate defer parent information up node trees. Added defer-aware field requirement context with deferInfo and parentFieldDeferID tracking. Extended node visitor with defer-ID awareness for pending requirements.
Path & Planner Configuration
v2/pkg/engine/plan/abstract_selection_rewriter.go, v2/pkg/engine/plan/abstract_selection_rewriter_helpers.go, v2/pkg/engine/plan/abstract_selection_rewriter_info.go, v2/pkg/engine/plan/planner_configuration.go, v2/pkg/engine/plan/path_builder.go, v2/pkg/engine/plan/path_builder_visitor.go
Added DeferID() string method to PlannerConfiguration. Extended selectionSetInfo with typenameFieldDeferId. Updated typeNameSelection to accept and apply deferred directives. Refactored selection rewriting to track typename defer IDs and attach appropriate directives. Enhanced debug output for field dependencies.
Required Fields & Visitor
v2/pkg/engine/plan/required_fields_visitor.go, v2/pkg/engine/plan/required_fields_visitor_test.go, v2/pkg/engine/plan/visitor.go
Introduced comprehensive defer-aware required-fields handling with deferred aliasing (__internal_* aliases with @__defer_internal directives). Added resolveDeferredAlias for reuse vs new-alias decisions. Extended test coverage to 400+ lines for defer scenarios including cross-scope conflicts and directive propagation. Refactored visitor field-stack tracking and plan-kind determination to replace AnalyzePlanKind with planner-based defer/subscription detection.
Removed Analysis
v2/pkg/engine/plan/analyze_plan_kind.go, v2/pkg/engine/plan/analyze_plan_kind_test.go
Removed AnalyzePlanKind function and corresponding test file (186 lines), consolidating plan-kind logic into visitor-based detection.
Resolution & Response Types
v2/pkg/engine/resolve/fetch.go, v2/pkg/engine/resolve/node_object.go, v2/pkg/engine/resolve/response.go, v2/pkg/engine/resolve/loader.go
Added DeferID field to FetchDependencies and updated DeferField struct to hold DeferID. Introduced GraphQLDeferResponse and DeferFetchGroup types with DeferResponseWriter interface. Exported ResolveFetchNode method in Loader.
Deferred Response Resolution
v2/pkg/engine/resolve/resolvable.go, v2/pkg/engine/resolve/resolve.go, v2/pkg/engine/resolve/const.go
Added two-pass defer rendering in ResolveDefer with envelope/path generation via helper methods. Introduced defer-mode flags and field filtering logic for initial vs incremental responses. Implemented ResolveGraphQLDeferResponse in Resolver to orchestrate defer-group processing. Added incrementalHasNext and hasNext literals.
Post-Processing
v2/pkg/engine/postprocess/extract_defer_fetches.go, v2/pkg/engine/postprocess/postprocess.go
Added extractDeferFetches processor to extract and group fetches by DeferID. Refactored Processor.Process with grouped processor structs and new DeferResponsePlan branch handling fetch tree processing and defer extraction.
Datasource & Testing
v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go, v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go, v2/pkg/engine/datasourcetesting/datasourcetesting.go
Added guard in planner to skip DEFER_INTERNAL directive propagation. Introduced comprehensive defer-planning tests (690 lines) validating fetch ordering and federation scenarios. Refactored datasource testing to support single post-processor and added WithDefer option with WithCalculateFieldDependencies split from field-dependencies flag.
Lexer & Literals
v2/pkg/lexer/literal/literal.go
Added DEFER_INTERNAL and LABEL byte literals.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Engine as Execution Engine
    participant Normalizer as AST Normalizer
    participant Planner as Query Planner
    participant Resolver as Response Resolver
    participant Writer as Response Writer

    Client->>Engine: Execute(operation with `@defer`)
    Engine->>Normalizer: Normalize with WithInlineDefer()
    Normalizer->>Normalizer: inlineFragmentExpandDefer<br/>(expand `@defer` to `@__defer_internal`)
    Normalizer->>Normalizer: deferEnsureTypename<br/>(add __typename placeholders)
    Normalizer-->>Engine: Normalized operation
    
    Engine->>Planner: Plan deferred operation
    Planner->>Planner: collectNodesDSVisitor<br/>(extract defer metadata)
    Planner->>Planner: ProcessDefer<br/>(propagate defer parents)
    Planner-->>Engine: DeferResponsePlan<br/>(root fetch + deferred groups)
    
    Engine->>Resolver: ResolveGraphQLDeferResponse
    Resolver->>Resolver: Resolve initial fetches
    Resolver->>Writer: Resolve(initial data)
    Writer-->>Client: Initial response
    
    loop For each deferred group
        Resolver->>Resolver: Fetch deferred nodes
        Resolver->>Resolver: ResolveDefer(group data)
        Resolver->>Writer: Flush(incremental chunk)
        Writer-->>Client: Incremental response
    end
    
    Resolver->>Writer: Complete()
    Writer-->>Client: hasNext: false
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eng-7770-add-defer-support

@devsergiy devsergiy changed the title Feat/eng 7770 add defer support feat: add initial defer support Jan 26, 2026
@devsergiy devsergiy mentioned this pull request Feb 16, 2026
@devsergiy devsergiy force-pushed the feat/eng-7770-add-defer-support branch from ee840d7 to 49ce82b Compare February 17, 2026 18:47
Comment on lines +1329 to 1332
if len(options) > 0 {
opts = options[0]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why do we drop everything except first?

@devsergiy
Copy link
Copy Markdown
Member Author

@claude review

@devsergiy devsergiy marked this pull request as ready for review April 8, 2026 13:46
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
v2/pkg/engine/plan/planner_configuration.go (1)

140-156: ⚠️ Potential issue | 🟡 Minor

Pointer to function parameter may not reference the intended slice element.

Line 154 stores &configuration in the map, but configuration is the function parameter (a copy). After AddPath returns, this pointer references a value that was copied from the caller, not the element stored in p.paths at line 142.

If you intend to reference the actual path stored in the slice, you should point to the slice element instead:

🔧 Suggested fix
 func (p *plannerPathsConfiguration) AddPath(configuration pathConfiguration) {
 	// fmt.Println("[plannerConfiguration.AddPath] parentPath:", p.parentPath, "path:", configuration.String())
 	p.paths = append(p.paths, configuration)
 	idx := len(p.paths) - 1

 	p.index[configuration.path] = append(p.index[configuration.path], idx)

 	if configuration.parentPath != "" {
 		p.nonLeafPaths[configuration.parentPath] = struct{}{}
 	}
 	if configuration.pathType == PathTypeFragment {
 		p.fragmentPaths[configuration] = struct{}{}
 	}
 	if configuration.pathType == PathTypeField {
-		p.indexByFieldRef[configuration.fieldRef] = &configuration
+		p.indexByFieldRef[configuration.fieldRef] = &p.paths[idx]
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/planner_configuration.go` around lines 140 - 156, The
AddPath method of plannerPathsConfiguration stores &configuration into
p.indexByFieldRef but configuration is the method parameter (a copy) so the
pointer will not reference the element actually stored in p.paths; instead,
after appending, take the address of the slice element (e.g. &p.paths[idx]) and
store that in p.indexByFieldRef[configuration.fieldRef], ensuring the map points
to the actual element in p.paths; update plannerPathsConfiguration.AddPath to
use that slice-element address and keep all other logic (p.paths, p.index,
p.nonLeafPaths, p.fragmentPaths) unchanged.
execution/engine/execution_engine_test.go (1)

110-155: ⚠️ Potential issue | 🟡 Minor

Reject mixed streaming/non-streaming assertion modes.

When streamingResponse is enabled, only expectedResponse uses streamingBuf; indentJSON, expectedFixture, and expectedJSONResponse still validate resultWriter.String(). That makes those combinations assert a different artifact than the streamed payload.

Suggested guard
 		streamingBuf := bytes.NewBuffer(nil)
 		if opts.streamingResponse {
 			resultWriter.SetFlushCallback(func(data []byte) {
 				streamingBuf.Write(data)
 				streamingBuf.Write([]byte{'\n'})
 			})
 		}
+		if opts.streamingResponse && (testCase.indentJSON || testCase.expectedFixture != "" || testCase.expectedJSONResponse != "") {
+			t.Fatalf("streamingResponse cannot be combined with indentJSON, expectedFixture, or expectedJSONResponse")
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@execution/engine/execution_engine_test.go` around lines 110 - 155, When
opts.streamingResponse is true the test currently mixes streamed output
(streamingBuf populated in the SetFlushCallback) with non-streamed assertions
(using resultWriter.String(), json.Indent, goldie.Assert(), and assert.JSONEq on
actualResponse), so ensure the assertions use the same artifact: if
opts.streamingResponse (or testCase.streamingResponse) is true, read and assert
against streamingBuf.String() for expectedResponse, expectedFixture,
expectedJSONResponse and indentJSON flows (and skip or fail fast for assertions
that are incompatible), otherwise continue using resultWriter.String(); update
the test's branches around streamingBuf, actualResponse, indentJSON,
expectedFixture, and expectedJSONResponse to consistently use the streamed value
when streamingResponse is enabled and call engine.Execute as before.
v2/pkg/engine/plan/visitor.go (1)

220-235: ⚠️ Potential issue | 🟠 Major

Always populate fieldPlanners for deferred fields.

assignDefer depends on v.fieldPlanners[fieldRef], but this block stops filling that map when DisableCalculateFieldDependencies is enabled. In that mode deferred fields never get currentField.Defer, so the resolver treats them as part of the initial payload.

🐛 Proposed fix
-		if !v.Config.DisableCalculateFieldDependencies && kind == astvisitor.LeaveField {
-			// we don't need to do this twice, so we only do it on leave
-
-			// store which fields are planned on which planners
-			if v.plannerFields[visitorID] == nil {
-				v.plannerFields[visitorID] = []int{ref}
-			} else {
-				v.plannerFields[visitorID] = append(v.plannerFields[visitorID], ref)
-			}
-
-			// store which planners a field was planned on
-			if v.fieldPlanners[ref] == nil {
-				v.fieldPlanners[ref] = []int{visitorID}
-			} else {
-				v.fieldPlanners[ref] = append(v.fieldPlanners[ref], visitorID)
-			}
+		if kind == astvisitor.LeaveField {
+			if !v.Config.DisableCalculateFieldDependencies {
+				// store which fields are planned on which planners
+				if v.plannerFields[visitorID] == nil {
+					v.plannerFields[visitorID] = []int{ref}
+				} else {
+					v.plannerFields[visitorID] = append(v.plannerFields[visitorID], ref)
+				}
+			}
+
+			// assignDefer also depends on this map
+			if v.fieldPlanners[ref] == nil {
+				v.fieldPlanners[ref] = []int{visitorID}
+			} else {
+				v.fieldPlanners[ref] = append(v.fieldPlanners[ref], visitorID)
+			}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/visitor.go` around lines 220 - 235, The code currently
skips populating v.fieldPlanners when v.Config.DisableCalculateFieldDependencies
is true, causing assignDefer to not see planners for deferred fields (so
currentField.Defer is never set); update the logic in the visitor leave handling
(the block guarded by v.Config.DisableCalculateFieldDependencies and kind ==
astvisitor.LeaveField) to always append to v.fieldPlanners[ref] for the current
visitorID even when DisableCalculateFieldDependencies is true (you may still
skip planner-specific dependency calculations but must still perform the
v.fieldPlanners[ref] insertion), ensuring v.plannerFields[visitorID] and
v.fieldPlanners[ref] remain consistent for use by assignDefer and
currentField.Defer.
v2/pkg/engine/plan/path_builder_visitor.go (1)

1301-1333: ⚠️ Potential issue | 🟠 Major

Make missing-path tracking defer-aware.

Lines 1321-1325 only match by currentPath and datasource hash. Once query.user is planned on the non-deferred planner, the same path on a deferred planner is treated as already satisfied, so the second walk can skip the missing defer-parent/deferred path entirely. The missing-path trackers need the same deferID dimension as the planners now do.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/path_builder_visitor.go` around lines 1301 - 1333, The
missing-path tracking in handleMissingPath currently keys only by
field.currentPath and matches planned suggestions by DataSourceHash, causing
deferred and non-deferred planners to collide; update the logic to include defer
ID when recording and checking missing paths (use the same defer identifier used
by planners/suggestions), i.e., change potentiallyMissingPathTracker to use a
composite key of field.currentPath + deferID (or a struct key) and when checking
planners (the loop using c.planners and HasPath) ensure you compare both
DataSourceHash and the planner's defer ID to suggestion.deferID so a path
planned on a non-deferred planner does not incorrectly mark the deferred
planner’s path as satisfied. Ensure suggestions lookup
(nodeSuggestions.SuggestionsForPath) and any break/return conditions account for
the defer dimension consistently.
🧹 Nitpick comments (5)
v2/pkg/engine/resolve/loader.go (1)

575-577: Consider clarifying the TODO comment.

The TODO comment "unclear why we doing this" highlights a code path that could use documentation. This branch handles the case when there are no parent items to merge into (len(items) == 0), so it sets responseData as the root data directly. Consider documenting this intent:

// When no parent items exist (e.g., root query response),
// set the response as the top-level data.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/loader.go` around lines 575 - 577, The TODO comment
"unclear why we doing this" should be replaced with a clear explanatory comment:
document that when there are no parent items to merge into (i.e., the branch
where len(items) == 0), the loader assigns responseData as the top-level/root
response by setting l.resolvable.data = responseData; update the comment at that
location near l.resolvable.data and responseData to reflect that intent (e.g.,
"When no parent items exist (root query response), set responseData as the
top-level data").
v2/pkg/engine/resolve/response.go (1)

66-88: Inconsistent indentation in query plan formatting.

The indent function adds 4 spaces for indentation, but the format string literal uses tabs. This may result in misaligned output depending on tab width settings.

💡 Suggested fix for consistent indentation
 func (r *GraphQLDeferResponse) QueryPlanString() string {
 	indent := func(s string) string {
-		return strings.ReplaceAll(s, "\n", "\n    ")
+		return strings.ReplaceAll(s, "\n", "\n\t")
 	}

 	primary := indent(r.Response.Fetches.QueryPlan().PrettyPrint())
 	var secondary []string

 	for _, g := range r.Defers {
-		secondary = append(secondary, strings.ReplaceAll(g.Fetches.QueryPlan().PrettyPrint(), "\n", "\n    "))
+		secondary = append(secondary, strings.ReplaceAll(g.Fetches.QueryPlan().PrettyPrint(), "\n", "\n\t"))
 	}

 	return fmt.Sprintf(`
 QueryPlan {
   Primary {
 	%s
   }
   Deferred [
     %s
   ]
 }
 `, primary, strings.Join(secondary, "\n"))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/response.go` around lines 66 - 88, The QueryPlanString
method has inconsistent indentation: the indent helper injects four spaces but
the multi-line format string uses tabs, causing misalignment; update
GraphQLDeferResponse.QueryPlanString to use spaces consistently (replace tabs in
the format literal with the same four-space indentation) and ensure the
secondary entries are indented via the same indent() helper (or by applying
strings.ReplaceAll with "\n" -> "\n    ") so both primary and deferred query
plans align uniformly.
v2/pkg/engine/postprocess/extract_defer_fetches.go (2)

50-50: Minor typo: deffereddeferred.

🔧 Suggested fix
-func (d *extractDeferFetches) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deffered map[string][]*resolve.FetchTreeNode) {
+func (d *extractDeferFetches) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deferred map[string][]*resolve.FetchTreeNode) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/postprocess/extract_defer_fetches.go` at line 50, The parameter
and return variable name "deffered" in function fetchGroups (in type
extractDeferFetches) is misspelled; rename it to "deferred" across the function
signature and all usages (including the return map type and any references
inside fetchGroups and its callers) so the signature becomes fetchGroups(... )
(root []*resolve.FetchTreeNode, deferred map[string][]*resolve.FetchTreeNode)
and update every reference to deffered -> deferred to keep names consistent.

29-34: Sorting assumes numeric defer IDs.

If strconv.Atoi fails for non-numeric defer IDs, both values default to 0, causing all non-numeric IDs to compare equal. This could result in unstable ordering if defer IDs aren't guaranteed to be numeric strings.

If defer IDs may be non-numeric, consider falling back to lexicographic comparison when parsing fails:

🔧 Alternative with fallback
 	deferIds := slices.SortedFunc(maps.Keys(fetchGroups), func(a, b string) int {
-		an, _ := strconv.Atoi(a)
-		bn, _ := strconv.Atoi(b)
-		return cmp.Compare(an, bn)
+		an, errA := strconv.Atoi(a)
+		bn, errB := strconv.Atoi(b)
+		if errA == nil && errB == nil {
+			return cmp.Compare(an, bn)
+		}
+		return cmp.Compare(a, b) // fallback to lexicographic
 	})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/postprocess/extract_defer_fetches.go` around lines 29 - 34, The
current sorting of deferIds using slices.SortedFunc with strconv.Atoi and
cmp.Compare assumes numeric IDs and treats parse errors as zero, which makes
different non-numeric IDs compare equal; update the comparison inside the
slices.SortedFunc (where deferIds is computed from maps.Keys(fetchGroups)) to
attempt strconv.Atoi for both a and b and if either parse fails fall back to a
deterministic lexicographic compare (e.g., strings.Compare) so non-numeric defer
IDs are ordered stably instead of being treated as equal.
v2/pkg/astnormalization/defer_ensure_typename.go (1)

59-67: Add a nested-inline-fragment regression here.

SelectionSetFieldSelections(ref) only sees direct fields. A case like ... @defer { ... on User { title } } would lock down that the placeholder still gets injected at the intended depth when the defer-only subtree is wrapped in inline fragments.

Based on learnings, validation runs after normalization here, so fragment spreads have already been expanded into inline fragments or fields.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/astnormalization/defer_ensure_typename.go` around lines 59 - 67,
SelectionSetFieldSelections(ref) only returns direct child fields, so nested
inline fragments like "... `@defer` { ... on User { title } }" are missed and the
placeholder injection logic fails; update the logic that builds
fieldSelectionRefs (used with SelectionSetFieldSelections(ref) and
parentDeferID()) to recursively traverse the selection set and include fields
found inside nested inline fragments (expanded fragment spreads), i.e.,
implement or call a helper that walks SelectionSet nodes depth-first, collects
Field selections within InlineFragment nodes as well as direct fields, and use
that collected list in place of the current SelectionSetFieldSelections(ref)
result so parentDeferID() and the defer-intersection checks operate on fields
inside inline fragments too.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@execution/engine/execution_engine_helpers_test.go`:
- Around line 77-85: The shared map used must be synchronized because RoundTrip
may run concurrently: add "sync" to imports, create a mutex (e.g., usedMu :=
&sync.Mutex{} or var usedMu sync.Mutex) next to the used map, wrap any writes to
used (the handler that marks used[key] = true) with usedMu.Lock()/Unlock(), and
also lock usedMu while reading/iterating used in the t.Cleanup block that checks
testCase.responses so the read is protected; keep the same map and keys (used,
testCase.responses) and only add the mutex and locking/unlocking around
accesses.

In `@v2/pkg/ast/ast_field.go`:
- Around line 203-206: The code currently ignores strconv.Atoi errors when
converting leftDeferIdValue.Ref and rightDeferIdValue.Ref into leftId and
rightId (variables created in the snippet) and leaves a TODO about parent id
handling; update the conversion to check and handle the errors from strconv.Atoi
(e.g., propagate an error up from the containing function or use a clear
fallback and log) instead of silently treating failures as 0, and implement or
call existing logic to consider parent IDs when comparing/merging defer IDs
(update the comparison logic that uses leftId/rightId to also incorporate parent
ID fields or return an explicit error if parent IDs are required but missing).
Ensure you modify the function that contains leftId/rightId so its signature and
callers handle the new error/behavior.

In `@v2/pkg/asttransform/internal.graphql`:
- Around line 4-5: The GraphQL field description for the "label" field is
incorrect (it currently reads "A unique identifier for the results." which
describes "id"); update the description for the label field in the schema so it
accurately describes its purpose (e.g., a human-readable label or name for the
result) by editing the string above the label: String declaration to a correct
sentence referencing "label" instead of "id".

In `@v2/pkg/engine/datasourcetesting/datasourcetesting.go`:
- Around line 42-45: The withPrintPlan branch currently assumes p.Plan(...)
returns *plan.SynchronousResponsePlan and will panic when WithDefer() is set
because p.Plan may return *plan.DeferResponsePlan; update the branch that prints
the plan (and the similar logic around the other occurrence) to capture the
result of p.Plan(...) into a variable and use a type switch on its concrete type
(handling *plan.SynchronousResponsePlan and *plan.DeferResponsePlan) so both can
be printed safely; refer to WithDefer()/testOptions.withDefer, the p.Plan(...)
call, and the plan.SynchronousResponsePlan and plan.DeferResponsePlan types when
making the change.

In `@v2/pkg/engine/plan/datasource_filter_node_suggestions.go`:
- Around line 215-224: The loop that scans parent items (referencing
f.items[parentIdx], parentIdx, f.items[i].deferInfo.ID, deferInfo, and deferIDs)
incorrectly uses break when a parent is already in the same defer scope or
already contains the ID, which stops scanning further same-source parents and
misses updating deferParentPath/deferIDs; change those break statements so the
loop skips only the current parent and continues scanning remaining parents (use
continue semantics) and ensure when a parent is eligible you still append/update
deferParentPath and deferIDs for that parent rather than exiting the loop early.

In `@v2/pkg/engine/plan/node_selection_visitor.go`:
- Around line 292-310: The function wrappingFieldDeferID currently stops
scanning ancestors and returns "" as soon as it encounters a field ancestor
without the `@__defer_internal` directive; change the control flow in
wrappingFieldDeferID so that when Directives.HasDirectiveByNameBytes on
c.operation.Fields[ancestor.Ref] returns exists==false you continue scanning
earlier ancestors (i.e., replace the early return with continue), and only
return "" after the loop if no wrapping defer directive was found; keep the
existing logic that extracts the id via c.operation.DirectiveArgumentValueByName
and c.operation.StringValueContentString when a directive is found.

In `@v2/pkg/engine/plan/required_fields_visitor.go`:
- Around line 579-599: The applyDeferInternalDirective function currently
returns early when v.config.deferInfo == nil which prevents applying
parentFieldDeferID for key fields; change the logic in
applyDeferInternalDirective (used with v.config.deferInfo, v.config.isKey,
v.config.parentFieldDeferID) so that if deferInfo is nil you still check
v.config.parentFieldDeferID and, when non-empty and v.config.isKey is true, call
v.config.operation.AddDeferInternalDirectiveToField(fieldRef,
v.config.parentFieldDeferID, "", ""); otherwise, if deferInfo exists keep the
existing behavior of using deferInfo.ID/Label/ParentID for non-key fields and
for keys when appropriate.

In `@v2/pkg/engine/plan/visitor.go`:
- Around line 1000-1037: The code sets response.Info.OperationType from
v.Operation.OperationDefinitions[0] and then overwrites it to Query in
defer/default branches, causing wrong root types for non-first operations;
update the logic to read the operation type from the selected operation
reference (use the existing opRef/operationRef selected earlier, not
OperationDefinitions[0]) and only set Info.OperationType based on that actual
opRef value (do not unconditionally set ast.OperationTypeQuery in the
DeferResponsePlan and SynchronousResponsePlan branches); change the three places
where v.response.Info is assigned (initial block, Defer case, default case) to
derive OperationType from the selected operation reference so
mutations/subscriptions preserve the correct root type.

In `@v2/pkg/engine/postprocess/extract_defer_fetches.go`:
- Around line 53-54: The fetchGroups method in extractDeferFetches may iterate a
nil Fetches and panic; add a nil-check for deferPlan.Response.Response.Fetches
at the start of extractDeferFetches.fetchGroups (before the for loop over
Fetches.ChildNodes) and return nil for root and an empty map for fetchGroups
when Fetches is nil; keep the rest of the logic unchanged (use the existing
symbols deferPlan.Response.Response.Fetches, Fetches.ChildNodes, and fetchGroups
map).

In `@v2/pkg/engine/resolve/fetch.go`:
- Around line 110-113: The equality check in EqualSingleFetch must include the
DeferID from FetchDependencies so fetches across different defer boundaries
aren't treated as equivalent; update the EqualSingleFetch implementation (the
method that compares two SingleFetch/FetchItem objects) to compare the DeferID
field (e.g., item.FetchDeps.DeferID vs other.FetchDeps.DeferID), handling
nil/empty dependency structs safely, so that differing DeferID values make the
items unequal and prevent merging initial and deferred fetches.

In `@v2/pkg/engine/resolve/node_object.go`:
- Around line 182-184: Field.Copy() currently assigns the DeferField by
reference (Defer: f.Defer) causing copied trees to share the same DeferField
pointer; change Field.Copy() to allocate a new DeferField and copy its contents
(e.g., create &DeferField{DeferID: f.Defer.DeferID} when f.Defer != nil) and
assign that to the copied Field's Defer so each copy gets an independent
DeferField instance.

In `@v2/pkg/engine/resolve/resolvable.go`:
- Around line 257-259: The deferred delivery termination is incorrectly driven
by r.hasErrors() (which reuses r.errors across chunks) so repeated old errors
can make hasNext false; update ResolveDefer to stop using r.hasErrors() for
hasNext and instead derive the "more deferred payloads exist" signal from the
deferred-work state (e.g., a new per-chunk flag or a check on the deferred
queue/iterator like r.deferQueue/remainingCount or a helper r.hasMoreDeferred())
and call r.printHasNext(true) when that derived condition is true; ensure
r.errors is not used to decide continuation across chunks (or clear per-chunk
errors) and apply the same change to the other occurrences in the 266-306 range.

In `@v2/pkg/engine/resolve/resolve.go`:
- Around line 456-468: Currently all rendering is gated by
ctx.ExecutionOptions.SkipLoader; change control flow so loader initialization
and ResolveFetchNode are only skipped when SkipLoader is true, but
t.resolvable.Resolve is always called to render the initial response shape.
Concretely, keep the t.loader.Init(...) and the ResolveFetchNode(...) calls
inside an if !ctx.ExecutionOptions.SkipLoader branch, but move the lines that
set t.resolvable.deferMode, t.resolvable.deferID and the call to
t.resolvable.Resolve(...) outside that branch so they execute regardless of
SkipLoader; apply the same refactor wherever similar gating appears (the blocks
around t.loader.Init / ResolveFetchNode and t.resolvable.Resolve noted in this
file).

---

Outside diff comments:
In `@execution/engine/execution_engine_test.go`:
- Around line 110-155: When opts.streamingResponse is true the test currently
mixes streamed output (streamingBuf populated in the SetFlushCallback) with
non-streamed assertions (using resultWriter.String(), json.Indent,
goldie.Assert(), and assert.JSONEq on actualResponse), so ensure the assertions
use the same artifact: if opts.streamingResponse (or testCase.streamingResponse)
is true, read and assert against streamingBuf.String() for expectedResponse,
expectedFixture, expectedJSONResponse and indentJSON flows (and skip or fail
fast for assertions that are incompatible), otherwise continue using
resultWriter.String(); update the test's branches around streamingBuf,
actualResponse, indentJSON, expectedFixture, and expectedJSONResponse to
consistently use the streamed value when streamingResponse is enabled and call
engine.Execute as before.

In `@v2/pkg/engine/plan/path_builder_visitor.go`:
- Around line 1301-1333: The missing-path tracking in handleMissingPath
currently keys only by field.currentPath and matches planned suggestions by
DataSourceHash, causing deferred and non-deferred planners to collide; update
the logic to include defer ID when recording and checking missing paths (use the
same defer identifier used by planners/suggestions), i.e., change
potentiallyMissingPathTracker to use a composite key of field.currentPath +
deferID (or a struct key) and when checking planners (the loop using c.planners
and HasPath) ensure you compare both DataSourceHash and the planner's defer ID
to suggestion.deferID so a path planned on a non-deferred planner does not
incorrectly mark the deferred planner’s path as satisfied. Ensure suggestions
lookup (nodeSuggestions.SuggestionsForPath) and any break/return conditions
account for the defer dimension consistently.

In `@v2/pkg/engine/plan/planner_configuration.go`:
- Around line 140-156: The AddPath method of plannerPathsConfiguration stores
&configuration into p.indexByFieldRef but configuration is the method parameter
(a copy) so the pointer will not reference the element actually stored in
p.paths; instead, after appending, take the address of the slice element (e.g.
&p.paths[idx]) and store that in p.indexByFieldRef[configuration.fieldRef],
ensuring the map points to the actual element in p.paths; update
plannerPathsConfiguration.AddPath to use that slice-element address and keep all
other logic (p.paths, p.index, p.nonLeafPaths, p.fragmentPaths) unchanged.

In `@v2/pkg/engine/plan/visitor.go`:
- Around line 220-235: The code currently skips populating v.fieldPlanners when
v.Config.DisableCalculateFieldDependencies is true, causing assignDefer to not
see planners for deferred fields (so currentField.Defer is never set); update
the logic in the visitor leave handling (the block guarded by
v.Config.DisableCalculateFieldDependencies and kind == astvisitor.LeaveField) to
always append to v.fieldPlanners[ref] for the current visitorID even when
DisableCalculateFieldDependencies is true (you may still skip planner-specific
dependency calculations but must still perform the v.fieldPlanners[ref]
insertion), ensuring v.plannerFields[visitorID] and v.fieldPlanners[ref] remain
consistent for use by assignDefer and currentField.Defer.

---

Nitpick comments:
In `@v2/pkg/astnormalization/defer_ensure_typename.go`:
- Around line 59-67: SelectionSetFieldSelections(ref) only returns direct child
fields, so nested inline fragments like "... `@defer` { ... on User { title } }"
are missed and the placeholder injection logic fails; update the logic that
builds fieldSelectionRefs (used with SelectionSetFieldSelections(ref) and
parentDeferID()) to recursively traverse the selection set and include fields
found inside nested inline fragments (expanded fragment spreads), i.e.,
implement or call a helper that walks SelectionSet nodes depth-first, collects
Field selections within InlineFragment nodes as well as direct fields, and use
that collected list in place of the current SelectionSetFieldSelections(ref)
result so parentDeferID() and the defer-intersection checks operate on fields
inside inline fragments too.

In `@v2/pkg/engine/postprocess/extract_defer_fetches.go`:
- Line 50: The parameter and return variable name "deffered" in function
fetchGroups (in type extractDeferFetches) is misspelled; rename it to "deferred"
across the function signature and all usages (including the return map type and
any references inside fetchGroups and its callers) so the signature becomes
fetchGroups(... ) (root []*resolve.FetchTreeNode, deferred
map[string][]*resolve.FetchTreeNode) and update every reference to deffered ->
deferred to keep names consistent.
- Around line 29-34: The current sorting of deferIds using slices.SortedFunc
with strconv.Atoi and cmp.Compare assumes numeric IDs and treats parse errors as
zero, which makes different non-numeric IDs compare equal; update the comparison
inside the slices.SortedFunc (where deferIds is computed from
maps.Keys(fetchGroups)) to attempt strconv.Atoi for both a and b and if either
parse fails fall back to a deterministic lexicographic compare (e.g.,
strings.Compare) so non-numeric defer IDs are ordered stably instead of being
treated as equal.

In `@v2/pkg/engine/resolve/loader.go`:
- Around line 575-577: The TODO comment "unclear why we doing this" should be
replaced with a clear explanatory comment: document that when there are no
parent items to merge into (i.e., the branch where len(items) == 0), the loader
assigns responseData as the top-level/root response by setting l.resolvable.data
= responseData; update the comment at that location near l.resolvable.data and
responseData to reflect that intent (e.g., "When no parent items exist (root
query response), set responseData as the top-level data").

In `@v2/pkg/engine/resolve/response.go`:
- Around line 66-88: The QueryPlanString method has inconsistent indentation:
the indent helper injects four spaces but the multi-line format string uses
tabs, causing misalignment; update GraphQLDeferResponse.QueryPlanString to use
spaces consistently (replace tabs in the format literal with the same four-space
indentation) and ensure the secondary entries are indented via the same indent()
helper (or by applying strings.ReplaceAll with "\n" -> "\n    ") so both primary
and deferred query plans align uniformly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0c3f5e5a-342f-48e0-89d5-8c0011309338

📥 Commits

Reviewing files that changed from the base of the PR and between f3974f6 and 081cac6.

📒 Files selected for processing (81)
  • execution/engine/config_factory_proxy_test.go
  • execution/engine/engine_config_test.go
  • execution/engine/execution_engine.go
  • execution/engine/execution_engine_defer_test.go
  • execution/engine/execution_engine_helpers_test.go
  • execution/engine/execution_engine_test.go
  • execution/engine/testdata/full_introspection.json
  • execution/engine/testdata/full_introspection_with_deprecated.json
  • execution/engine/testdata/full_introspection_with_typenames.json
  • v2/pkg/ast/ast_argument.go
  • v2/pkg/ast/ast_directive.go
  • v2/pkg/ast/ast_field.go
  • v2/pkg/ast/ast_inline_fragment.go
  • v2/pkg/astnormalization/astnormalization.go
  • v2/pkg/astnormalization/astnormalization_test.go
  • v2/pkg/astnormalization/defer_ensure_typename.go
  • v2/pkg/astnormalization/defer_ensure_typename_test.go
  • v2/pkg/astnormalization/directive_include_skip.go
  • v2/pkg/astnormalization/directive_include_skip_test.go
  • v2/pkg/astnormalization/field_deduplication.go
  • v2/pkg/astnormalization/field_deduplication_test.go
  • v2/pkg/astnormalization/fragment_spread_inlining_test.go
  • v2/pkg/astnormalization/inline_fragment_expand_defer.go
  • v2/pkg/astnormalization/inline_fragment_expand_defer_test.go
  • v2/pkg/astnormalization/inline_fragment_selection_merging.go
  • v2/pkg/astnormalization/inline_fragment_selection_merging_test.go
  • v2/pkg/astnormalization/inline_selections_from_inline_fragments_test.go
  • v2/pkg/asttransform/base.graphql
  • v2/pkg/asttransform/baseschema.go
  • v2/pkg/asttransform/baseschema_test.go
  • v2/pkg/asttransform/fixtures/complete.golden
  • v2/pkg/asttransform/fixtures/custom_query_name.golden
  • v2/pkg/asttransform/fixtures/mutation_only.golden
  • v2/pkg/asttransform/fixtures/mutation_only_no_internal.golden
  • v2/pkg/asttransform/fixtures/schema_missing.golden
  • v2/pkg/asttransform/fixtures/simple.golden
  • v2/pkg/asttransform/fixtures/subscription_only.golden
  • v2/pkg/asttransform/fixtures/subscription_renamed.golden
  • v2/pkg/asttransform/fixtures/with_mutation_subscription.golden
  • v2/pkg/asttransform/internal.graphql
  • v2/pkg/asttransform/stream.graphql
  • v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go
  • v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go
  • v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden
  • v2/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection_with_custom_root_operation_types.golden
  • v2/pkg/engine/datasourcetesting/datasourcetesting.go
  • v2/pkg/engine/plan/abstract_selection_rewriter.go
  • v2/pkg/engine/plan/abstract_selection_rewriter_helpers.go
  • v2/pkg/engine/plan/abstract_selection_rewriter_info.go
  • v2/pkg/engine/plan/abstract_selection_rewriter_test.go
  • v2/pkg/engine/plan/analyze_plan_kind.go
  • v2/pkg/engine/plan/analyze_plan_kind_test.go
  • v2/pkg/engine/plan/configuration.go
  • v2/pkg/engine/plan/datasource_configuration.go
  • v2/pkg/engine/plan/datasource_filter_collect_nodes_visitor.go
  • v2/pkg/engine/plan/datasource_filter_node_suggestions.go
  • v2/pkg/engine/plan/node_selection_builder.go
  • v2/pkg/engine/plan/node_selection_visitor.go
  • v2/pkg/engine/plan/path_builder.go
  • v2/pkg/engine/plan/path_builder_visitor.go
  • v2/pkg/engine/plan/plan.go
  • v2/pkg/engine/plan/planner_configuration.go
  • v2/pkg/engine/plan/required_fields_visitor.go
  • v2/pkg/engine/plan/required_fields_visitor_test.go
  • v2/pkg/engine/plan/visitor.go
  • v2/pkg/engine/postprocess/extract_defer_fetches.go
  • v2/pkg/engine/postprocess/postprocess.go
  • v2/pkg/engine/resolve/const.go
  • v2/pkg/engine/resolve/fetch.go
  • v2/pkg/engine/resolve/loader.go
  • v2/pkg/engine/resolve/node_object.go
  • v2/pkg/engine/resolve/resolvable.go
  • v2/pkg/engine/resolve/resolve.go
  • v2/pkg/engine/resolve/response.go
  • v2/pkg/federation/fixtures/federated_schema.golden
  • v2/pkg/federation/schema.go
  • v2/pkg/introspection/fixtures/starwars_introspected.golden
  • v2/pkg/introspection/generator.go
  • v2/pkg/introspection/generator_test.go
  • v2/pkg/introspection/testdata/starwars.schema.graphql
  • v2/pkg/lexer/literal/literal.go
💤 Files with no reviewable changes (2)
  • v2/pkg/engine/plan/analyze_plan_kind.go
  • v2/pkg/engine/plan/analyze_plan_kind_test.go

Comment on lines +77 to +85
used := make(map[string]bool)
if testCase.reportUnused {
t.Cleanup(func() {
for key := range testCase.responses {
if !used[key] {
t.Logf("UNUSED MOCK [%s]: %s", testCase.expectedHost, key)
}
}
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard used against concurrent RoundTrip calls.

RoundTrip can be hit from multiple goroutines, and the new used map is written in the handler and read again in t.Cleanup without synchronization. That will trip -race in the new incremental/defer tests and make this helper flaky.

🛠 Proposed fix
+	var usedMu sync.Mutex
 	used := make(map[string]bool)
 	if testCase.reportUnused {
 		t.Cleanup(func() {
+			usedMu.Lock()
+			defer usedMu.Unlock()
 			for key := range testCase.responses {
 				if !used[key] {
 					t.Logf("UNUSED MOCK [%s]: %s", testCase.expectedHost, key)
 				}
 			}
@@
 		if testCase.reportUnused {
+			usedMu.Lock()
 			used[string(gotBody)] = true
+			usedMu.Unlock()
 		}

Also add sync to the imports.

Also applies to: 113-115

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@execution/engine/execution_engine_helpers_test.go` around lines 77 - 85, The
shared map used must be synchronized because RoundTrip may run concurrently: add
"sync" to imports, create a mutex (e.g., usedMu := &sync.Mutex{} or var usedMu
sync.Mutex) next to the used map, wrap any writes to used (the handler that
marks used[key] = true) with usedMu.Lock()/Unlock(), and also lock usedMu while
reading/iterating used in the t.Cleanup block that checks testCase.responses so
the read is protected; keep the same map and keys (used, testCase.responses) and
only add the mutex and locking/unlocking around accesses.

Comment thread v2/pkg/ast/ast_field.go
Comment on lines +203 to +206
leftId, _ := strconv.Atoi(d.StringValueContentString(leftDeferIdValue.Ref))
rightId, _ := strconv.Atoi(d.StringValueContentString(rightDeferIdValue.Ref))

// TODO: need to handle parent id too
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Silently ignored strconv.Atoi errors and pending TODO.

The strconv.Atoi errors are discarded. If defer IDs are ever non-numeric strings, both will default to 0, causing incorrect merge behavior (both would be "equal"). Additionally, the TODO indicates parent ID handling is incomplete.

🛡️ Suggested defensive handling
-		leftId, _ := strconv.Atoi(d.StringValueContentString(leftDeferIdValue.Ref))
-		rightId, _ := strconv.Atoi(d.StringValueContentString(rightDeferIdValue.Ref))
+		leftIdStr := d.StringValueContentString(leftDeferIdValue.Ref)
+		rightIdStr := d.StringValueContentString(rightDeferIdValue.Ref)
+		leftId, leftErr := strconv.Atoi(leftIdStr)
+		rightId, rightErr := strconv.Atoi(rightIdStr)
+		if leftErr != nil || rightErr != nil {
+			// Fall back to string comparison if IDs are not numeric
+			if leftIdStr <= rightIdStr {
+				return // left wins or equal
+			}
+			// right wins
+			d.Fields[left].Directives.RemoveDirectiveByRef(leftDeferDirectiveRef)
+			d.Fields[left].Directives.Refs = append(d.Fields[left].Directives.Refs, rightDeferDirectiveRef)
+			return
+		}

Note: The past review comments mentioned converting IDs to integers internally. If IDs are guaranteed to be numeric strings, consider documenting this invariant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/ast/ast_field.go` around lines 203 - 206, The code currently ignores
strconv.Atoi errors when converting leftDeferIdValue.Ref and
rightDeferIdValue.Ref into leftId and rightId (variables created in the snippet)
and leaves a TODO about parent id handling; update the conversion to check and
handle the errors from strconv.Atoi (e.g., propagate an error up from the
containing function or use a clear fallback and log) instead of silently
treating failures as 0, and implement or call existing logic to consider parent
IDs when comparing/merging defer IDs (update the comparison logic that uses
leftId/rightId to also incorporate parent ID fields or return an explicit error
if parent IDs are required but missing). Ensure you modify the function that
contains leftId/rightId so its signature and callers handle the new
error/behavior.

Comment on lines +4 to +5
"A unique identifier for the results."
label: String
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix the label description.

The description on Line 4 describes id, not label. That makes internal schema/introspection output misleading.

🛠️ Proposed fix
-    "A unique identifier for the results."
+    "A client-provided label for the deferred payload."
     label: String
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"A unique identifier for the results."
label: String
"A client-provided label for the deferred payload."
label: String
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/asttransform/internal.graphql` around lines 4 - 5, The GraphQL field
description for the "label" field is incorrect (it currently reads "A unique
identifier for the results." which describes "id"); update the description for
the label field in the schema so it accurately describes its purpose (e.g., a
human-readable label or name for the result) by editing the string above the
label: String declaration to a correct sentence referencing "label" instead of
"id".

Comment on lines +42 to 45
func WithDefer() func(*testOptions) {
return func(o *testOptions) {
o.postProcessors = postProcessors
o.withDefer = true
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle WithPrintPlan() for defer responses too.

Once WithDefer() is enabled, p.Plan(...) can return a *plan.DeferResponsePlan, but the withPrintPlan branch below still hard-asserts *plan.SynchronousResponsePlan. Any defer test that enables plan printing will panic before it reaches the assertion; a small type switch there would avoid that.

Also applies to: 213-215

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/datasourcetesting/datasourcetesting.go` around lines 42 - 45,
The withPrintPlan branch currently assumes p.Plan(...) returns
*plan.SynchronousResponsePlan and will panic when WithDefer() is set because
p.Plan may return *plan.DeferResponsePlan; update the branch that prints the
plan (and the similar logic around the other occurrence) to capture the result
of p.Plan(...) into a variable and use a type switch on its concrete type
(handling *plan.SynchronousResponsePlan and *plan.DeferResponsePlan) so both can
be printed safely; refer to WithDefer()/testOptions.withDefer, the p.Plan(...)
call, and the plan.SynchronousResponsePlan and plan.DeferResponsePlan types when
making the change.

Comment on lines +215 to +224
if f.items[parentIdx].deferInfo != nil && f.items[parentIdx].deferInfo.ID == f.items[i].deferInfo.ID {
// if parent item is in the same defer -
// we should not mark it as a defer parent,
// because defer parents are planned twice - in a deffered planner and regular
break
}

if slices.Contains(f.items[parentIdx].deferIDs, f.items[i].deferInfo.ID) {
// no need to update already contains this defer id
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't stop the parent scan on the first non-updatable match.

treeNode.GetParent().GetData() can legitimately contain multiple duplicate suggestions for the same response node. If the first same-source parent is already in this defer scope or already has the ID, these breaks skip later same-source parents, so the actual ancestor path can miss deferParentPath / deferIDs.

💡 Minimal fix
 			if f.items[parentIdx].deferInfo != nil && f.items[parentIdx].deferInfo.ID == f.items[i].deferInfo.ID {
 				// if parent item is in the same defer -
 				// we should not mark it as a defer parent,
 				// because defer parents are planned twice - in a deffered planner and regular
-				break
+				continue
 			}

 			if slices.Contains(f.items[parentIdx].deferIDs, f.items[i].deferInfo.ID) {
 				// no need to update already contains this defer id
-				break
+				continue
 			} else {
 				parentIdToUpdate = parentIdx
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/datasource_filter_node_suggestions.go` around lines 215 -
224, The loop that scans parent items (referencing f.items[parentIdx],
parentIdx, f.items[i].deferInfo.ID, deferInfo, and deferIDs) incorrectly uses
break when a parent is already in the same defer scope or already contains the
ID, which stops scanning further same-source parents and misses updating
deferParentPath/deferIDs; change those break statements so the loop skips only
the current parent and continues scanning remaining parents (use continue
semantics) and ensure when a parent is eligible you still append/update
deferParentPath and deferIDs for that parent rather than exiting the loop early.

Comment on lines +53 to +54
for _, fetch := range deferPlan.Response.Response.Fetches.ChildNodes {
deferID := fetch.Item.Fetch.Dependencies().DeferID
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how Process is called and if Fetches could be nil
rg -n "extractDeferFetches" --type go -A 5 -B 5

Repository: wundergraph/graphql-go-tools

Length of output: 4781


🏁 Script executed:

#!/bin/bash
# Find the DeferResponsePlan type definition
rg -n "type DeferResponsePlan" --type go -A 20

Repository: wundergraph/graphql-go-tools

Length of output: 1194


🏁 Script executed:

#!/bin/bash
# Check the Response type and Fetches field
rg -n "Response.*Response" v2/pkg/engine/postprocess/extract_defer_fetches.go --type go -A 2 -B 2

Repository: wundergraph/graphql-go-tools

Length of output: 601


🏁 Script executed:

#!/bin/bash
# Look at the entire fetchGroups method to see if there are nil checks
sed -n '50,80p' v2/pkg/engine/postprocess/extract_defer_fetches.go

Repository: wundergraph/graphql-go-tools

Length of output: 573


🏁 Script executed:

#!/bin/bash
# Find the plan package location and look for Response type
fd "plan" v2/pkg/engine/ --type d | head -1

Repository: wundergraph/graphql-go-tools

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Find GraphQLDeferResponse type definition and its Response field
rg -n "type GraphQLDeferResponse" --type go -A 15

Repository: wundergraph/graphql-go-tools

Length of output: 1129


🏁 Script executed:

#!/bin/bash
# Look for where DeferResponsePlan is created or initialized
rg -n "DeferResponsePlan{" --type go -B 5 -A 10

Repository: wundergraph/graphql-go-tools

Length of output: 171


🏁 Script executed:

#!/bin/bash
# Check if there's any initialization of Response.Fetches before calling Process
sed -n '17,48p' v2/pkg/engine/postprocess/extract_defer_fetches.go

Repository: wundergraph/graphql-go-tools

Length of output: 912


🏁 Script executed:

#!/bin/bash
# Look at the context around line 194 in postprocess.go where Process is called
sed -n '180,210p' v2/pkg/engine/postprocess/postprocess.go

Repository: wundergraph/graphql-go-tools

Length of output: 1250


🏁 Script executed:

#!/bin/bash
# Check if there are any nil checks for Fetches in the codebase before accessing it
rg -n "Fetches.*nil\|if.*Fetches\|Fetches.*==" --type go v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for where Response.Fetches is first initialized
rg -n "Response.Fetches\s*=" --type go -B 2 -A 2 v2/pkg/engine/postprocess/ v2/pkg/engine/resolve/

Repository: wundergraph/graphql-go-tools

Length of output: 521


🏁 Script executed:

#!/bin/bash
# Look at createFetchTree to confirm it initializes Fetches
rg -n "func.*createFetchTree" --type go -A 20 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 1595


🏁 Script executed:

#!/bin/bash
# Check the GraphQLResponse type to see if Fetches field is ever nullable
rg -n "type GraphQLResponse struct" --type go -A 20

Repository: wundergraph/graphql-go-tools

Length of output: 1223


🏁 Script executed:

#!/bin/bash
# Double-check the access pattern - is processFlatFetchTree accessing Fetches safely?
rg -n "processFlatFetchTree" --type go -A 5 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 2453


🏁 Script executed:

#!/bin/bash
# Get the full createFetchTree implementation to see where Fetches is assigned
sed -n '220,280p' v2/pkg/engine/postprocess/postprocess.go

Repository: wundergraph/graphql-go-tools

Length of output: 1589


🏁 Script executed:

#!/bin/bash
# Check if processFlatFetchTree has nil safety checks
rg -n "func.*processFlatFetchTree\|func.*ProcessFetchTree" --type go -A 10 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check if disableExtractFetches affects whether Fetches gets initialized
rg -n "disableExtractFetches" --type go -B 2 -A 2 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 1807


🏁 Script executed:

#!/bin/bash
# Check what processFlatFetchTree does with the fetches parameter
rg -n "func.*dedupe.*ProcessFetchTree\|func.*ProcessFetchTree" --type go -A 5 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for nil checks in the fetch tree processors
rg -n "if.*fetches.*nil\|if fetches ==" --type go v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check if there's an early guard when disableExtractFetches is true
sed -n '188,195p' v2/pkg/engine/postprocess/postprocess.go

Repository: wundergraph/graphql-go-tools

Length of output: 384


🏁 Script executed:

#!/bin/bash
# Search for the dedupe processor implementation
rg -n "type dedupe struct" --type go -A 20

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for ProcessFetchTree implementations in other files
fd "*.go" v2/pkg/engine/postprocess/ | xargs grep -l "ProcessFetchTree" | head -5

Repository: wundergraph/graphql-go-tools

Length of output: 355


🏁 Script executed:

#!/bin/bash
# Check the dedupe processor to see if it handles nil
rg -n "func.*dedupe.*ProcessFetchTree" --type go -A 15

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# See if there are any conditional checks before calling processFlatFetchTree
sed -n '180,200p' v2/pkg/engine/postprocess/postprocess.go | cat -n

Repository: wundergraph/graphql-go-tools

Length of output: 1061


🏁 Script executed:

#!/bin/bash
# Find the dedupe and other processor implementations
rg -n "type.*Processors struct\|ProcessFetchTree" --type go v2/pkg/engine/postprocess/ | head -30

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for the implementation of all processor methods called in processFlatFetchTree
cat v2/pkg/engine/postprocess/postprocess.go | sed -n '39,50p'

Repository: wundergraph/graphql-go-tools

Length of output: 797


🏁 Script executed:

#!/bin/bash
# Check if there's a test that shows the behavior with nil Fetches
fd --glob "*.go" -x grep -l "disableExtractFetches.*true\|Fetches.*nil" {} \; v2/pkg/engine/postprocess/ 2>/dev/null

Repository: wundergraph/graphql-go-tools

Length of output: 112


🏁 Script executed:

#!/bin/bash
# Look at what happens when the flag is disabled - search tests
rg -n "disableExtractFetches" --type go -B 3 -A 3

Repository: wundergraph/graphql-go-tools

Length of output: 2505


🏁 Script executed:

#!/bin/bash
# Check the actual processors to see if they handle nil Fetches
rg -n "func.*dedupe.*ProcessFetchTree\|func.*appendFetchID.*ProcessFetchTree\|func.*resolveInputTemplates.*ProcessFetchTree" --type go -A 5 v2/pkg/engine/postprocess/ v2/pkg/engine/resolve/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for the dedupe processor type and its ProcessFetchTree method
fd --glob "dedupe*.go" -x cat {} \; 2>/dev/null | head -100

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Search more broadly for ProcessFetchTree implementations
rg -n "ProcessFetchTree" --type go -B 3 -A 10 v2/pkg/engine/postprocess/ | head -80

Repository: wundergraph/graphql-go-tools

Length of output: 6663


🏁 Script executed:

#!/bin/bash
# Check the traverseNode implementation to see if it handles nil
rg -n "func.*traverseNode" --type go -A 10 v2/pkg/engine/postprocess/resolve_input_templates.go

Repository: wundergraph/graphql-go-tools

Length of output: 451


🏁 Script executed:

#!/bin/bash
# Look for the dedupe processor ProcessFetchTree implementation
rg -n "type dedupe\|func.*dedupe.*ProcessFetchTree" --type go -A 15 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check if any of these processors have nil checks at the start
rg -n "if.*root.*nil\|if root ==" --type go v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check if there's a test case that passes nil to ProcessFetchTree
rg -n "ProcessFetchTree.*nil\|nil.*ProcessFetchTree" --type go v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check the dedupe processor
rg -n "type dedupe\|ProcessFetchTree" --type go -A 20 v2/pkg/engine/postprocess/ | grep -A 20 "type dedupe"

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look for all ProcessFetchTree implementations and see which ones have nil checks
rg -n "func.*ProcessFetchTree" --type go -A 3 v2/pkg/engine/postprocess/

Repository: wundergraph/graphql-go-tools

Length of output: 4357


🏁 Script executed:

#!/bin/bash
# Check if there's any guard in the main Process loop before calling processFlatFetchTree with nil Fetches
sed -n '183,210p' v2/pkg/engine/postprocess/postprocess.go

Repository: wundergraph/graphql-go-tools

Length of output: 1123


🏁 Script executed:

#!/bin/bash
# Check what happens when disableExtractFetches is true - does Fetches remain nil?
# Look at the assign statement in createFetchTree
sed -n '218,260p' v2/pkg/engine/postprocess/postprocess.go

Repository: wundergraph/graphql-go-tools

Length of output: 1256


🏁 Script executed:

#!/bin/bash
# Check if there's any nil guard in fetchGroups before accessing Fetches
sed -n '50,70p' v2/pkg/engine/postprocess/extract_defer_fetches.go

Repository: wundergraph/graphql-go-tools

Length of output: 573


🏁 Script executed:

#!/bin/bash
# Look for any tests that verify behavior with disableExtractFetches=true
rg -n "disableExtractFetches.*true" --type go -B 5 -A 10

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check the relationship between disableExtractFetches and disableExtractDeferFetches
rg -n "disableExtractFetches\|disableExtractDeferFetches" --type go v2/pkg/engine/postprocess/postprocess.go | head -20

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check if disableExtractFetches and disableExtractDeferFetches are the same or different flags
rg -n "type processorOptions\|disableExtract" --type go v2/pkg/engine/postprocess/postprocess.go -A 5

Repository: wundergraph/graphql-go-tools

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Look at the full Process method to understand the control flow
sed -n '17,25p' v2/pkg/engine/postprocess/extract_defer_fetches.go

Repository: wundergraph/graphql-go-tools

Length of output: 335


🏁 Script executed:

#!/bin/bash
# Check what disableExtractFetches controls in the Processor struct and how it relates to extractDeferFetches.disable
sed -n '60,180p' v2/pkg/engine/postprocess/postprocess.go | grep -A 5 -B 5 "disableExtract"

Repository: wundergraph/graphql-go-tools

Length of output: 1459


🏁 Script executed:

#!/bin/bash
# Get the full initialization of Processor to see how extractDeferFetches.disable is set
sed -n '130,180p' v2/pkg/engine/postprocess/postprocess.go

Repository: wundergraph/graphql-go-tools

Length of output: 1948


🏁 Script executed:

#!/bin/bash
# Check if extractDeferFetches has a nil check for Fetches
sed -n '13,65p' v2/pkg/engine/postprocess/extract_defer_fetches.go

Repository: wundergraph/graphql-go-tools

Length of output: 1466


Add a nil safety check for Fetches in fetchGroups method.

If Fetches is not initialized (e.g., when disableExtractFetches is true), the iteration at line 53 would panic on a nil pointer dereference. Add a guard to handle the case where Fetches is nil:

Suggested fix
func (d *extractDeferFetches) fetchGroups(deferPlan *plan.DeferResponsePlan) (root []*resolve.FetchTreeNode, deffered map[string][]*resolve.FetchTreeNode) {
	fetchGroups := make(map[string][]*resolve.FetchTreeNode)

	if deferPlan.Response.Response.Fetches == nil {
		return nil, fetchGroups
	}

	for _, fetch := range deferPlan.Response.Response.Fetches.ChildNodes {
		deferID := fetch.Item.Fetch.Dependencies().DeferID
		if deferID == "" {
			root = append(root, fetch)
			continue
		}

		fetchGroups[deferID] = append(fetchGroups[deferID], fetch)
	}

	return root, fetchGroups
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/postprocess/extract_defer_fetches.go` around lines 53 - 54, The
fetchGroups method in extractDeferFetches may iterate a nil Fetches and panic;
add a nil-check for deferPlan.Response.Response.Fetches at the start of
extractDeferFetches.fetchGroups (before the for loop over Fetches.ChildNodes)
and return nil for root and an empty map for fetchGroups when Fetches is nil;
keep the rest of the logic unchanged (use the existing symbols
deferPlan.Response.Response.Fetches, Fetches.ChildNodes, and fetchGroups map).

Comment on lines 110 to +113
type FetchDependencies struct {
FetchID int
DependsOnFetchIDs []int
DeferID string
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Include DeferID in FetchItem.EqualSingleFetch().

DeferID changes when a fetch runs, so two otherwise-identical SingleFetch items are no longer equivalent across defer boundaries. Leaving equality unchanged can merge initial and deferred fetches into the same bucket.

🛠️ Proposed fix
 func (f *FetchItem) EqualSingleFetch(other *FetchItem) bool {
+	if f.Fetch.Dependencies().DeferID != other.Fetch.Dependencies().DeferID {
+		return false
+	}
+
 	if len(f.FetchPath) != len(other.FetchPath) {
 		return false
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/fetch.go` around lines 110 - 113, The equality check in
EqualSingleFetch must include the DeferID from FetchDependencies so fetches
across different defer boundaries aren't treated as equivalent; update the
EqualSingleFetch implementation (the method that compares two
SingleFetch/FetchItem objects) to compare the DeferID field (e.g.,
item.FetchDeps.DeferID vs other.FetchDeps.DeferID), handling nil/empty
dependency structs safely, so that differing DeferID values make the items
unequal and prevent merging initial and deferred fetches.

Comment on lines +182 to +184
type DeferField struct {
DeferID string
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Deep-copy DeferField in Field.Copy().

DeferField now carries state. Keeping Defer: f.Defer makes copied response trees share the same defer metadata pointer.

🛠️ Proposed fix
 func (f *Field) Copy() *Field {
+	var deferField *DeferField
+	if f.Defer != nil {
+		copied := *f.Defer
+		deferField = &copied
+	}
+
 	return &Field{
 		Name:        f.Name,
 		Value:       f.Value.Copy(),
 		Position:    f.Position,
-		Defer:       f.Defer,
+		Defer:       deferField,
 		Stream:      f.Stream,
 		OnTypeNames: f.OnTypeNames,
 		Info:        f.Info,
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/node_object.go` around lines 182 - 184, Field.Copy()
currently assigns the DeferField by reference (Defer: f.Defer) causing copied
trees to share the same DeferField pointer; change Field.Copy() to allocate a
new DeferField and copy its contents (e.g., create &DeferField{DeferID:
f.Defer.DeferID} when f.Defer != nil) and assign that to the copied Field's
Defer so each copy gets an independent DeferField instance.

Comment on lines +257 to +259
if r.deferMode && !r.hasErrors() {
r.printHasNext(true)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't let the accumulated error buffer terminate incremental delivery.

hasNext is derived from r.hasErrors(), and ResolveDefer reuses the same r.errors buffer across chunks. After the first non-fatal error, later patches can repeat old errors and emit hasNext:false even when more deferred payloads still exist.

Also applies to: 266-306

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/resolvable.go` around lines 257 - 259, The deferred
delivery termination is incorrectly driven by r.hasErrors() (which reuses
r.errors across chunks) so repeated old errors can make hasNext false; update
ResolveDefer to stop using r.hasErrors() for hasNext and instead derive the
"more deferred payloads exist" signal from the deferred-work state (e.g., a new
per-chunk flag or a check on the deferred queue/iterator like
r.deferQueue/remainingCount or a helper r.hasMoreDeferred()) and call
r.printHasNext(true) when that derived condition is true; ensure r.errors is not
used to decide continuation across chunks (or clear per-chunk errors) and apply
the same change to the other occurrences in the 266-306 range.

Comment on lines +456 to +468
if !ctx.ExecutionOptions.SkipLoader {
t.loader.Init(ctx, response.Response.Info, t.resolvable)

// fetch initial response
if err := t.loader.ResolveFetchNode(response.Response.Fetches); err != nil {
return nil, err
}

t.resolvable.deferMode = true
t.resolvable.deferID = ""

// render initial response
err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Render defer responses even when SkipLoader is enabled.

All output generation currently sits behind if !ctx.ExecutionOptions.SkipLoader, so this method returns without writing even the initial payload in skip-loader mode. That diverges from ResolveGraphQLResponse and the subscription paths, which still render the response shape without fetching.

Suggested fix
-	if !ctx.ExecutionOptions.SkipLoader {
-		t.loader.Init(ctx, response.Response.Info, t.resolvable)
-
-		// fetch initial response
-		if err := t.loader.ResolveFetchNode(response.Response.Fetches); err != nil {
-			return nil, err
-		}
-
-		t.resolvable.deferMode = true
-		t.resolvable.deferID = ""
-
-		// render initial response
-		err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer)
+	if !ctx.ExecutionOptions.SkipLoader {
+		t.loader.Init(ctx, response.Response.Info, t.resolvable)
+		if err := t.loader.ResolveFetchNode(response.Response.Fetches); err != nil {
+			return nil, err
+		}
+	}
+
+	t.resolvable.deferMode = true
+	t.resolvable.deferID = ""
+
+	// render initial response
+	err = t.resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer)
 		if err != nil {
 			return nil, err
 		}
@@
-		for i, deferGroup := range response.Defers {
-			if err := t.loader.ResolveFetchNode(deferGroup.Fetches); err != nil {
-				return nil, err
-			}
+	for i, deferGroup := range response.Defers {
+		if !ctx.ExecutionOptions.SkipLoader {
+			if err := t.loader.ResolveFetchNode(deferGroup.Fetches); err != nil {
+				return nil, err
+			}
+		}
 
-			t.resolvable.deferID = deferGroup.DeferID
+		t.resolvable.deferID = deferGroup.DeferID
@@
-		writer.Complete()
-	}
+	writer.Complete()

Also applies to: 482-491, 508-511

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/resolve.go` around lines 456 - 468, Currently all
rendering is gated by ctx.ExecutionOptions.SkipLoader; change control flow so
loader initialization and ResolveFetchNode are only skipped when SkipLoader is
true, but t.resolvable.Resolve is always called to render the initial response
shape. Concretely, keep the t.loader.Init(...) and the ResolveFetchNode(...)
calls inside an if !ctx.ExecutionOptions.SkipLoader branch, but move the lines
that set t.resolvable.deferMode, t.resolvable.deferID and the call to
t.resolvable.Resolve(...) outside that branch so they execute regardless of
SkipLoader; apply the same refactor wherever similar gating appears (the blocks
around t.loader.Init / ResolveFetchNode and t.resolvable.Resolve noted in this
file).

@devsergiy devsergiy marked this pull request as draft April 9, 2026 16:58
Comment on lines +184 to +188
// when the deffered field is on the entity and the parent field is on the same datasource
// we won't have hasRequiresKey set.
// but in case this field has requires directive it will be resolved by entity call,
// and it will have requires key configuration
if !hasRequiresKey && fieldRequirementsConfigs != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

	// When the deferred field is on the entity, and the parent field is on the same datasource, hasRequiresKey will be false.
	// But if this field has the "requires" directive, it will be resolved by entity call,
	// and it will have the "requires" key configuration.

Comment on lines +199 to +202
if f.items[i].IsRootNode && hasRequiresKey || hasKeyDependency {
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

double checking, did you mean this?

	if (f.items[i].IsRootNode && hasRequiresKey) || hasKeyDependency {

Comment on lines +216 to +219
// if parent item is in the same defer -
// we should not mark it as a defer parent,
// because defer parents are planned twice - in a deffered planner and regular
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pls fix this comment :)

possibleTypeNames []string

deferInfo *DeferInfo
deferParentPath bool
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

does this variable signal this "include me in the deferred planner
as a structural ancestor" ? If yes, then maybe we could rename it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe isDeferAncestor ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is it possible that this deferParentPath var is set to true when len(deferIDs) == 0? if not possible, then it is a redundant variable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, I think this var can be derived from len(deferIDs)


deferInfo *DeferInfo
deferParentPath bool
deferIDs []string
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

kind of vague name. I think, this variable means the defer IDs of descendant deferred fields that flow through this node. Maybe descendantDeferIDs or childDeferIDs ?

treeNodeId uint
possibleTypeNames []string

deferInfo *DeferInfo
Copy link
Copy Markdown
Contributor

@ysmolski ysmolski Apr 22, 2026

Choose a reason for hiding this comment

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

maybe ?

ownDeferInfo      *DeferInfo  // this node's own defer directive

current = parentIdToUpdate
}

for _, parentIdx := range parentIndexesToAddDeferID {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why do you collect them first and mark&add later?

currentFields []objectFields
currentObjectFields []objectFields
currentField *resolve.Field
currentFields []*resolve.Field
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what about fieldAncestors or fieldStack? the same is true for currentObjectFields that looks like a stack containing objectFields of ancestors.

*v.currentFields[len(v.currentFields)-1].fields = append(*v.currentFields[len(v.currentFields)-1].fields, v.currentField)
*v.currentObjectFields[len(v.currentObjectFields)-1].fields = append(*v.currentObjectFields[len(v.currentObjectFields)-1].fields, v.currentField)

// append the current field to the list of current fields
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this and previous comments are like redundant.

Comment on lines +849 to +854
// When the current field has an object type, we need to push its fields slice to the stack.
// However, we can do that only after the field, which we are currently creating, will be added to the parent object fields.
// So we defer this action to be executed right after the current field is added to the parent object fields slice.
// This is more simple than analyzing resolve.Node, because this object could be nested in a list.
v.Walker.DefferOnEnterField(func() {
v.currentFields = append(v.currentFields, objectFields{
v.currentObjectFields = append(v.currentObjectFields, objectFields{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The DefferOnEnterField name is not the best. Not mentioning the "deffer" unrelated to the "Defer" feature, maybe better name would be PostEnterField or RunAfterEnterField.

@@ -329,6 +345,7 @@ func (c *pathBuilderVisitor) EnterDocument(operation, definition *ast.Document)

c.fieldDependenciesForPlanners = make(map[int][]int)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this field is not used anymore

return false
}

return slices.ContainsFunc(treeNodeChildren(node), func(child int) bool {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For a field with many children and many defer groups, this could scan the same children list multiple times per field. Is it possible for some user to have many defer groups in a very big query? Will this linear search affect the performance of such queries?

@@ -675,19 +749,23 @@ func (c *pathBuilderVisitor) hasFieldsWaitingForDependency() bool {
// in case current field has @requires directive, and we were able to plan it - it means that all fields from requires selection set was planned before that.
// So we need to notify planner of current fieldRef about dependencies on those other fields
// we know where fields were planned, because we record planner id of each planned field
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

 // addFieldDependencies adds dependencies between planners based on the @requires directive.
  // If the current field has a @requires directive and we were able to plan it, it means that all fields
  // from the requires selection set were planned before it.
  // Hence, we need to notify the planner of the current fieldRef about dependencies on those other fields.
  // We know where fields were planned because we record the planner ID of each planned field.

Comment on lines 790 to 799
notified := slices.Contains(fetchConfiguration.dependsOnFetchIDs, plannerIdx)
if !notified {

fetchConfiguration.dependsOnFetchIDs = append(fetchConfiguration.dependsOnFetchIDs, plannerIdx)
// sort
slices.Sort(fetchConfiguration.dependsOnFetchIDs)
// remove consecutive duplicates
fetchConfiguration.dependsOnFetchIDs = slices.Compact(fetchConfiguration.dependsOnFetchIDs)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The notified check should already prevent duplicates being added. Or if you want to sort the result then you should sort at the exit.

// resolveDeferredAlias decides how to alias a deferred required field.
// Precondition: v.config.deferInfo != nil && v.isRootLevel().
//
// Decision table:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

slight reformat

//   - __internal_{fieldName} absent                                                        → addAlias
//   - __internal_{fieldName} present, same scope                                           → reuseFieldRef
//   - __internal_{fieldName} present, diff scope, __internal_{deferID}_{fieldName} absent  → addAlias, includeDeferID
//   - __internal_{fieldName} present, diff scope, __internal_{deferID}_{fieldName} present → reuseFieldRef

return v.config.operation.StringValueContentString(val.Ref)
}
return ""
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isn't the function below (from ast_field.go) doing the same?

func (d *Document) FieldInternalDeferID(fieldRef int) (id string, exists bool) {
	directiveRef, exists := d.Fields[fieldRef].Directives.HasDirectiveByNameBytes(d, literal.DEFER_INTERNAL)
	if !exists {
		return "", false
	}
	idValue, exists := d.DirectiveArgumentValueByName(directiveRef, []byte("id"))
	if !exists {
		return "", false
	}
	return d.StringValueContentString(idValue.Ref), true
}

v.OperationNodes = append(v.OperationNodes, selectionSetNode)
}

func (v *requiredFieldsVisitor) fieldHasDeferInternal(fieldRef int) bool {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You could use Document.FieldInternalDeferID from ast instead

}

func (v *requiredFieldsVisitor) isRootLevel() bool {
return len(v.OperationNodes) == 1
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i cannot grasp it... does it mean that we do not handle deeply nested @requires fields in deferred context?


// we are skipping adding __typename field to the required fields,
// because we want to depend only on the regular key fields, not the __typename field
if !bytes.Equal(fieldName, typeNameFieldBytes) || (bytes.Equal(fieldName, typeNameFieldBytes) && v.config.isTypeNameForEntityInterface) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

noticed, that this check can be simplified to

if !bytes.Equal(fieldName, typeNameFieldBytes) || v.config.isTypeNameForEntityInterface {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants