Skip to content

draft: add response caching support - #1628

Open
devsergiy wants to merge 3 commits into
masterfrom
caching-poc
Open

draft: add response caching support#1628
devsergiy wants to merge 3 commits into
masterfrom
caching-poc

Conversation

@devsergiy

Copy link
Copy Markdown
Member

Squash of the full tmp-caching series plus follow-ups: envelope types map removed, key format v1, comment hygiene, and the docs/caching documentation set.

@coderabbitai summary

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.

Open Source AI Manifesto

This project follows the principles of the Open Source AI Manifesto. Please ensure your contribution aligns with its principles.

Squash of the full tmp-caching series plus follow-ups: envelope types
map removed, key format v1, comment hygiene, and the docs/caching
documentation set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@devsergiy
devsergiy requested a review from a team as a code owner August 11, 2026 09:29
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Added a complete GraphQL response-caching system with declarative configuration, entity and root-field caching, L1/L2 storage, privacy partitions, cache-control handling, batching, partial loads, tracing, client metadata, planner integration, execution hooks, test infrastructure, and extensive documentation.

Changes

Response caching

Layer / File(s) Summary
Configuration and planning
v2/pkg/engine/plan/..., v2/pkg/engine/resolve/cache_config.go
Added cache configuration cascades, directives, representation metadata, planner cache data, root-field isolation, and cache-aware fetch contracts.
Keying and cache data
v2/pkg/engine/cache/...
Added canonical entity and root-field keys, argument digests, alias normalization, coverage checks, envelopes, privacy partitions, TTL handling, negative entries, and invalidation tags.
Controller and resolver integration
v2/pkg/engine/cache/controller.go, v2/pkg/engine/resolve/...
Added request-scoped L1/L2 caching, batching, partial loads, shadow mode, cache-control decisions, deferred writes, privacy handling, response aggregation, and request cleanup.
Engine wiring and validation
execution/engine/..., v2/pkg/engine/postprocess/..., execution/cachingtesting/...
Connected caching to execution and postprocessing. Added stores, subgraph doubles, composed fixtures, benchmarks, unit tests, and end-to-end tests.
Caching documentation
docs/caching/*
Added reference, behavior, onboarding, Redis adapter, live-subgraph testing, and open-issues documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ExecutionEngine
  participant Planner
  participant CacheController
  participant Subgraph
  participant Store

  Client->>ExecutionEngine: Execute GraphQL operation
  ExecutionEngine->>Planner: Build cache-aware plan
  Planner-->>ExecutionEngine: Return configured fetch plan
  ExecutionEngine->>CacheController: Prepare fetch
  CacheController->>Store: Read cache entries
  Store-->>CacheController: Return hits and misses
  CacheController->>Subgraph: Fetch missing data
  Subgraph-->>CacheController: Return data and cache headers
  CacheController->>Store: Persist cache entries
  CacheController-->>ExecutionEngine: Return merged data and metadata
  ExecutionEngine-->>Client: Return GraphQL response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: adding response caching support.
Description check ✅ Passed The description summarizes the response-caching implementation, follow-up changes, and documentation included in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch caching-poc

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 15

🧹 Nitpick comments (22)
v2/pkg/engine/plan/representationvariable/representation_variable.go (2)

24-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the stale TODO. remapPaths is implemented at Lines 244-248 and applied to the field path. The TODO no longer describes missing work.

♻️ Proposed cleanup
-// TODO: add support for remapping path
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/representationvariable/representation_variable.go` at line
24, Remove the stale TODO comment about adding remapping path support, since
remapPaths is already implemented and applied to the field path.

97-120: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Discarded type assertions can panic on a shape mismatch. mergeArrays and mergeObjects ignore the assertion result. If right is not the same node kind as left (the pairing is decided by fieldsHasField, which compares only Name and OnTypeNames), rightArray or rightObject is nil and the merge dereferences nil. Two sources that select the same field of the same type should always produce the same node kind, so this is defensive only. Consider returning left unchanged when the assertion fails.

🛡️ Proposed guard
 func mergeArrays(left, right resolve.Node) resolve.Node {
-	leftArray, _ := left.(*resolve.Array)
-	rightArray, _ := right.(*resolve.Array)
-
-	if leftArray.Item.NodeKind() == resolve.NodeKindObject {
+	leftArray, ok := left.(*resolve.Array)
+	if !ok {
+		return left
+	}
+	rightArray, ok := right.(*resolve.Array)
+	if !ok {
+		return leftArray
+	}
+	if leftArray.Item != nil && rightArray.Item != nil && leftArray.Item.NodeKind() == resolve.NodeKindObject {
 		leftArray.Item = mergeObjects(leftArray.Item, rightArray.Item)
 	}
 	return leftArray
 }
 
 func mergeObjects(left, right resolve.Node) resolve.Node {
-	leftObject, _ := left.(*resolve.Object)
-	rightObject, _ := right.(*resolve.Object)
-
+	leftObject, ok := left.(*resolve.Object)
+	if !ok {
+		return left
+	}
+	rightObject, ok := right.(*resolve.Object)
+	if !ok {
+		return leftObject
+	}
 	for _, field := range rightObject.Fields {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/representationvariable/representation_variable.go` around
lines 97 - 120, Guard the type assertions in mergeArrays and mergeObjects: if
either operand cannot be asserted to the expected resolve.Array or
resolve.Object type, return the original left node unchanged before
dereferencing it. Preserve the existing merge behavior when both operands have
the expected node kind.
v2/pkg/engine/plan/representationvariable/representation_variable_test.go (1)

14-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the parse report in the test helpers. astparser.ParseGraphqlDocumentString returns a report that both helpers discard (Line 15 here and Line 873 in build). A typo in a schema fixture then produces an empty document and a confusing node-mismatch failure instead of a clear parse error.

💚 Proposed fix
-		definition, _ := astparser.ParseGraphqlDocumentString(definitionStr)
+		definition, parseReport := astparser.ParseGraphqlDocumentString(definitionStr)
+		require.False(t, parseReport.HasErrors(), "parse definition: %s", parseReport.Error())

Apply the same change in build at Line 873.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/representationvariable/representation_variable_test.go`
around lines 14 - 24, Update the test helpers around runTest and build to
capture the parse report returned by astparser.ParseGraphqlDocumentString and
assert it contains no errors before using the parsed definition. Apply the same
validation in both helpers so malformed schema fixtures fail with a clear parse
error instead of a node mismatch.
v2/pkg/engine/resolve/fetchtree.go (1)

202-209: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the defer with a direct statement. The inner switch contains no return, so the deferred closure adds no control-flow benefit. It only delays the assignment to function exit and calls CacheConfig() twice. Placing the code after the switch is equivalent and easier to follow.

♻️ Proposed simplification
 	case FetchTreeNodeKindSingle:
-		defer func() {
-			// The cache config reads through the Fetch interface (never a
-			// switch over concrete fetch types); an uncached fetch leaves the
-			// field empty and the plan output unchanged.
-			if queryPlan.Fetch != nil && n.Item.Fetch.CacheConfig() != nil {
-				queryPlan.Fetch.Cache = n.Item.Fetch.CacheConfig().String()
-			}
-		}()
 		switch f := n.Item.Fetch.(type) {

and after the inner switch closes:

 		default:
 		}
+		// The cache config reads through the Fetch interface (never a switch
+		// over concrete fetch types); an uncached fetch leaves the field empty
+		// and the plan output unchanged.
+		if cfg := n.Item.Fetch.CacheConfig(); queryPlan.Fetch != nil && cfg != nil {
+			queryPlan.Fetch.Cache = cfg.String()
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/fetchtree.go` around lines 202 - 209, Replace the
deferred closure around the cache assignment with a direct statement after the
inner switch in the surrounding fetch-resolution flow. Call
n.Item.Fetch.CacheConfig() once, guard against nil queryPlan.Fetch and nil cache
configuration, then assign its String() value to queryPlan.Fetch.Cache.
v2/pkg/engine/postprocess/postprocess.go (1)

293-307: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

The sequence heuristic silently degrades when the first child adds more than one tree. childParent is set to before only when walk(node.ChildNodes[0], parentIndex) appended exactly one tree. If ChildNodes[0] is itself a Sequence or Parallel node that contributes several trees, the nested groups are parented to the enclosing parent instead of the real parent group, and the L1 narrowing pass then uses a wrong ancestry. The comment states that buildDeferTree always emits a Single node in that slot. Consider making that invariant explicit, so a future change to buildDeferTree fails loudly instead of producing a wrong parent index.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/postprocess/postprocess.go` around lines 293 - 307, The
Sequence handling in walk should enforce that ChildNodes[0] is the single parent
group emitted by buildDeferTree, rather than inferring childParent from the
number of appended trees. Validate that walking the first child adds exactly one
tree and fail loudly when the invariant is violated; then use that newly
appended tree as childParent before walking the remaining children.
execution/cachingtesting/art_e2e_test.go (1)

557-582: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add assertions for the writes this subtest describes.

The comment states that both fetches miss and both write. The test asserts only the trace, which shows the reads. Add store.Ops() assertions for the two SetMany items, or at least assert products.Requests(). Without them a regression that stops writing entries still passes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/art_e2e_test.go` around lines 557 - 582, Extend the
“two representations of one entity are two independent entries” subtest to
assert the writes, not only the trace reads. Use store.Ops() to verify both
expected SetMany entries, or products.Requests() to verify both product fetches
occurred, preserving the distinct sku and upc representations.
v2/pkg/engine/cache/fetch_cache_configurator_types_test.go (1)

126-149: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document why this batch is not a mixed-type decline.

buildConfig declines caching when info.RootFields carry different TypeName values. This row still receives a config because the two concrete types arrive through the representation's OnTypeNames, while FetchInfo.RootFields stays single-typed. Add one sentence stating that distinction. It separates this row from the "mixed entity types decline caching" row in v2/pkg/engine/cache/fetch_cache_configurator_test.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/fetch_cache_configurator_types_test.go` around lines 126
- 149, Add a one-sentence comment to the “a batch over two concrete types takes
the shortest declared lifetime” test explaining that the concrete types come
through the representation’s OnTypeNames while FetchInfo.RootFields remains
single-typed, so buildConfig does not classify it as mixed-type caching.
execution/cachingtesting/compose.sh (1)

1-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the wgc version and anchor the working directory.

wgc@latest makes fixture regeneration non-reproducible. A later wgc release can change config.json (the committed fixture records compatibilityVersion 1:0.62.2) and silently break the pinned e2e expectations. The relative paths also require the caller to run the script from execution/cachingtesting.

♻️ Proposed refactor
 #!/usr/bin/env bash
 set -euo pipefail
 
+cd "$(dirname "${BASH_SOURCE[0]}")"
+
+WGC_VERSION="${WGC_VERSION:-0.x.y}" # pin the version that produced config.json
+
 echo "Composing caching test subgraphs"
 
-npx -y wgc@latest router compose -i graph.yaml -o config.json
+npx -y "wgc@${WGC_VERSION}" router compose -i graph.yaml -o config.json
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/compose.sh` around lines 1 - 10, Update the compose
script to invoke the wgc release matching the committed fixture’s
compatibilityVersion (1:0.62.2) instead of latest, and anchor execution to the
script’s own directory before using graph.yaml and config.json so callers can
run it from any working directory.
v2/pkg/engine/cache/cache_control.go (1)

118-121: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Align Scope with its documented contract.

The doc states that Scope is empty when the result may not be stored at all. resolveCaching always sets Scope from entryScope(in.Private), including when in.L2 is false and when UncacheablePrivate is true. Both cases permit no store write. Either clear Scope on those return paths or correct the comment.

♻️ Proposed change
 	if !in.L2 || decision.UncacheablePrivate {
+		decision.Scope = ""
 		return decision
 	}

Also applies to: 142-152

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/cache_control.go` around lines 118 - 121, Update
resolveCaching so Scope is empty whenever the result cannot be stored,
specifically on the in.L2-disabled and UncacheablePrivate return paths; only
assign entryScope(in.Private) when a store write is permitted, preserving the
documented CacheControl contract.
execution/cachingtesting/defer_l1_e2e_test.go (1)

35-48: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider a fallback that closes the gate.

release closes only from the flush callback at len(frames) == 2. executeDeferOnFlush appends the residual writer buffer at Line 45 without calling onFlush. If a regression delivers the second frame through that path, release never closes and the gated subgraph handler blocks until the Go test timeout. A t.Cleanup that closes the gate once turns that hang into a normal assertion failure.

♻️ Proposed hardening
 	release := make(chan struct{})
+	var releaseOnce sync.Once
+	closeGate := func() { releaseOnce.Do(func() { close(release) }) }
+	t.Cleanup(closeGate)
 	gated := &SubgraphRule{

Then call closeGate() in the flush callback instead of close(release).

Also applies to: 268-287, 353-373

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/defer_l1_e2e_test.go` around lines 35 - 48, Harden
executeDeferOnFlush and the corresponding release-gate tests so the gate always
closes even when the final frame comes from writer.String() rather than the
flush callback. Add a one-time cleanup fallback using the existing closeGate
mechanism, and have the flush callback invoke closeGate instead of directly
closing release; apply the same pattern to the other referenced test cases.
v2/pkg/engine/plan/root_field_isolation.go (2)

19-21: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Share the query root path constant.

Extract "query" from isParentPathIsRootOperationPath and reuse it in root_field_isolation.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/root_field_isolation.go` around lines 19 - 21, Update
isParentPathIsRootOperationPath to expose or reuse a shared query-root path
constant, then replace the hardcoded "query" comparison in
root_field_isolation.go with that constant. Keep the existing root-path check
behavior unchanged.

22-23: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the resolved configuration by datasource ID if profiling shows this gate is hot. Resolve rebuilds scalar fields and applies subgraph overrides, but it does not merge type or root-field tiers. RootField then performs a linear scan over EffectiveSubgraphConfig.RootFields. A per-datasource cache removes repeated Resolve work, but it does not remove the RootField scan.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/root_field_isolation.go` around lines 22 - 23, Profile the
root-field gate around Resolve and RootField, and if it is hot, cache the
resolved configuration keyed by datasource ID (field.ds.Id()) to avoid repeated
Resolve work. Preserve the RootField lookup behavior and note that caching
Resolve does not eliminate its linear EffectiveSubgraphConfig.RootFields scan.
v2/pkg/engine/plan/cacheconfig/cache_directive_test.go (1)

20-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make omitted-scope expectations explicit.

CacheScopePublic is the zero value. Add Scope: CacheScopePublic to every expected TypeCacheConfig for an @cache declaration without scope, including lines 20–39 and 60–80.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/cacheconfig/cache_directive_test.go` around lines 20 - 39,
Update every expected TypeCacheConfig for `@cache` declarations that omit scope in
the cache directive tests, including the cases near the existing declarations
and those later in the file, to explicitly set Scope: CacheScopePublic. Preserve
all existing MaxAge and warning expectations.
v2/pkg/engine/cache/optimize_l1_cache.go (1)

295-330: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Include OnTypeNames in the narrowing identity.

Fields with the same schema name and arguments can have different inline-fragment type conditions across provider and consumer trees. Add a canonical sorted representation of OnTypeNames to fieldNarrowingName; runtime coverage remains the final guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/optimize_l1_cache.go` around lines 295 - 330, Update
fieldNarrowingName to include a canonical sorted representation of
field.OnTypeNames in the generated identity, alongside the schema name and
argument bindings. Ensure identical type-condition sets produce the same
representation regardless of input order, while preserving the existing argument
handling and findFieldByNarrowingName lookup behavior.
v2/pkg/engine/cache/controller_shadow_test.go (1)

36-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort the recorded compares to keep multi-item rows deterministic.

CompareShadow ranges over h.ShadowStash, which is a map. Go randomizes map iteration order. Every current row stashes one item, so obs.compares[0] is stable today. A future row with two stashed items would flake.

Collect the entries by ascending item index, or sort o.compares by key before the assertions read it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/controller_shadow_test.go` around lines 36 - 52, The
CompareShadow method records map entries in nondeterministic iteration order;
make the recorded compares deterministic by sorting o.compares by key before
returning, or by collecting ShadowStash entries in ascending item-index order.
Preserve the existing comparison fields and behavior.
v2/pkg/engine/cache/cache_key_template.go (1)

440-472: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Root-field keys include every request variable, also unused ones.

canonicalVariables renders the whole variables object. A variable that the cached root field does not consume still changes the key. Two otherwise identical requests then miss each other. The result stays correct; only the hit rate drops.

If the plan already knows the variables a root-field fetch depends on, restrict the preimage to that set. Otherwise document the behavior next to the PRECONDITION note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/cache_key_template.go` around lines 440 - 472, Update
canonicalVariables to include only variables consumed by the cached root-field
fetch when the execution plan exposes that dependency set, preserving
deterministic sorting and serialization for the selected variables. If no
dependency metadata is available, document next to the existing PRECONDITION
note that all request variables remain part of the cache key.
v2/pkg/engine/cache/controller_store_test.go (1)

467-474: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the misalignment error by message, not by struct equality.

assert.Equal compares the two error values with reflect.DeepEqual. It passes today because both are *errors.errorString with the same text. It breaks unreadably if the controller wraps the error with fmt.Errorf("...: %w", err).

Assert the stable fields, then assert the message separately.

♻️ Proposed change
-		assert.Equal(t, []StoreError{
-			{
-				Op:       "GetMany",
-				Subgraph: "",
-				KeyCount: 2,
-				Err:      errors.New("store returned 1 entries for 2 keys"),
-			},
-		}, obs.storeErrors)
+		require.Len(t, obs.storeErrors, 1)
+		assert.Equal(t, "GetMany", obs.storeErrors[0].Op)
+		assert.Equal(t, "", obs.storeErrors[0].Subgraph)
+		assert.Equal(t, 2, obs.storeErrors[0].KeyCount)
+		assert.ErrorContains(t, obs.storeErrors[0].Err, "store returned 1 entries for 2 keys")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/controller_store_test.go` around lines 467 - 474, Update
the GetMany misalignment assertion in the relevant test to compare stable
StoreError fields separately, then assert the contained error’s message
independently rather than using assert.Equal on the entire struct. Preserve the
expected operation, subgraph, and key count while allowing the controller to
wrap the underlying error.
v2/pkg/engine/cache/partial_test.go (1)

272-275: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant closure.

The immediately invoked function returns partialConfig(t) unchanged. Pass partialConfig(t) directly.

♻️ Proposed simplification
-		writeThrough(t, NewController(store, nil).BeginRequest(nil), func() *resolve.FetchCacheConfig {
-			plain := partialConfig(t)
-			return plain
-		}(), productItem(t, "2"), fresh("2"))
+		writeThrough(t, NewController(store, nil).BeginRequest(nil), partialConfig(t), productItem(t, "2"), fresh("2"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/partial_test.go` around lines 272 - 275, Remove the
redundant immediately invoked closure in the writeThrough call and pass
partialConfig(t) directly as the fetch cache configuration argument, leaving the
surrounding NewController, productItem, and fresh arguments unchanged.
v2/pkg/engine/cache/controller.go (1)

517-551: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the inner loop variable to avoid shadowing state.

Line 517 declares state *handleState. Line 540 declares another state of type resolve.ItemCacheState inside the loop. The outer variable is used again at line 562. The code is correct, but the reuse of one name for two different types reduces readability.

♻️ Proposed rename
 	for _, item := range in.Items {
-		state := resolve.ItemCacheState{
+		itemState := resolve.ItemCacheState{
 			Item:        item,
 			RenderedKey: key,
 			Tags:        tags,
 			FromCache:   fromCache,
 		}
 		if fromCache != nil {
-			state.RemainingTTL = entry.RemainingTTL
-			state.ServedFreshness = servedFreshness
+			itemState.RemainingTTL = entry.RemainingTTL
+			itemState.ServedFreshness = servedFreshness
 		}
-		items = append(items, state)
+		items = append(items, itemState)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/controller.go` around lines 517 - 551, Rename the
resolve.ItemCacheState variable declared inside the loop over in.Items to a
distinct name, such as itemState, while preserving its field assignments and
append to items. Keep the outer handleState variable state unchanged for its
later use.
v2/pkg/engine/resolve/cache_transaction.go (1)

42-54: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

The StructuralCopy error fallback returns the aliased source.

The doc comment promises value isolation. On line 51 the heap-mode fallback returns v itself when the re-parse fails, so the caller receives an alias and the promised isolation is silently lost. A round-trip of a valid *astjson.Value should not fail, so this path is unlikely. Returning nil on that path would surface the failure at the caller instead of producing hidden merge aliasing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/cache_transaction.go` around lines 42 - 54, The error
fallback in CacheTransaction.StructuralCopy must preserve value isolation
instead of returning the aliased input. Replace the parse-failure return of v
with nil, while keeping the DeepCopy path and successful copied result
unchanged.
v2/pkg/engine/cache/controller_test.go (2)

55-108: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

testStore has no synchronization.

GetMany and SetMany mutate s.data and s.ops without a mutex. Every test in this file drives the controller sequentially, so this is safe today. If a future test exercises parallel fetch groups against one store, the race detector will fire. Consider adding a sync.Mutex now to keep the double usable for concurrent rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/controller_test.go` around lines 55 - 108, Add
synchronization to testStore using a sync.Mutex, and lock access in GetMany,
SetMany, and value while reading or mutating s.data and s.ops. Ensure all
early-return paths release the lock so the test double remains safe for
concurrent controller operations.

386-397: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rows [F3] and [F4] build identical inputs.

Both rows produce MergeInput{Items: ..., FetchFailed: true, StatusCode: 200}. They therefore assert the same behavior, and neither models an empty body or a parse failure distinctly. Row [F2] already covers FetchFailed: true. Consider differentiating the inputs so each row exercises the signal its name describes, for example an empty-body row with ResponseData: nil and a parse-failure row with a non-2xx status.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/controller_test.go` around lines 386 - 397, Differentiate
the [F3] and [F4] cases in the test inputs: make the empty-body case use
ResponseData: nil while retaining a successful status, and make the
parse-failure case use a non-2xx status with appropriate response data. Avoid
duplicating the [F2] FetchFailed-only input so each row exercises its named
condition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/caching/6-open-issues.md`:
- Around line 19-21: Implement the root-field cache fix so its key is derived
from the fetch’s rendered argument values, matching entity-key behavior and
distinguishing aliased calls with different arguments. If that implementation is
not available, reject or disable root-field cache configuration rather than
relying solely on the documented warning.

In `@execution/cachingtesting/cache_control_e2e_test.go`:
- Around line 228-235: Update the comment above the ops assertions to state that
there are two lookups, neither served, followed by one flush carrying only the
deferred fetch’s value; remove the claim that there was no write and correct any
reference to the write’s position from the second op to the flush represented by
ops[2].

In `@execution/cachingtesting/client_headers_e2e_test.go`:
- Around line 325-338: Guard the op-log accesses in the affected test with
require.Len checks before indexing: require at least one operation before
reading ops[len(ops)-1], then require at least two items before accessing
writes.Items[0] and writes.Items[1]. Follow the existing require.Len pattern
used by the other subtests so failures remain scoped to the subtest.

In `@execution/cachingtesting/privacy_e2e_test.go`:
- Around line 255-257: Update the seeded envelope in the privacy E2E test around
store.Seed to derive the JSON "created" timestamp from the current clock, using
fmt as needed, so it remains fresh within the declared 60-second TTL. Preserve
the private scope and existing store TTL, ensuring ScopeMismatch is the
fixture’s only discard cause.

In `@execution/cachingtesting/shadow_e2e_test.go`:
- Around line 37-59: Synchronize access to inventoryRule.Response between the
request handler and the test’s mutation before the second Execute call. Update
the SubgraphRule response read/write path, or use a synchronized per-request
response mechanism, so the handler’s read cannot race with the test goroutine’s
assignment while preserving the stock=7 response for the second request.

In `@v2/pkg/engine/cache/cache_control.go`:
- Around line 55-60: Clamp the parsed seconds in the max-age handling before
converting and multiplying by time.Second: update the seconds validation in the
surrounding cache-control parsing flow to cap values at the maximum
representable duration in seconds, while preserving rejection of parse errors
and negatives. Then set MaxAge and HasMaxAge from the safely bounded value.

In `@v2/pkg/engine/cache/cache_key_template.go`:
- Around line 391-405: Update renderCacheKey and its hashHex helper to use a
collision-resistant 128-bit digest, preferably truncated SHA-256 consistent with
sha256Hex, instead of unkeyed xxhash64; preserve the existing prefix/preimage
structure while producing the wider digest. Bump cacheFormatVersion to v1 so the
changed key layout is established before production entries exist.
- Around line 153-180: Validate or JSON-escape typeName before appending it in
cacheKeyTemplate.renderRepresentation, since it comes from itemTypeName and is
inserted directly into the quoted key preimage. Prefer the existing GraphQL name
validation helper if available, rejecting invalid names; otherwise use the
established escaping mechanism so hostile __typename values cannot create
ambiguous keys.

In `@v2/pkg/engine/cache/cachetesting/fakes.go`:
- Around line 342-347: Update FakeStore.Ops to deep-copy each StoreOp before
returning, including the Keys, Hits, and Items slices and every item's Tags
slice. Preserve the existing locking and return a cloned operation log so
NormalizeStoreOpsClock mutations cannot alter s.ops.

In `@v2/pkg/engine/cache/coverage.go`:
- Around line 67-102: Update coversNode to explicitly handle *resolve.Null,
matching the fallback nodes emitted by createFieldValue for unresolved or
unsupported schema types. Define the branch so these nodes can be considered
covered instead of falling through to the default false result; do not alter the
existing Scalar, Object, or Array behavior.

In `@v2/pkg/engine/cache/observer.go`:
- Around line 144-176: Update TraceObserver’s StoreErrors and UncacheablePrivate
accessors to atomically drain their recorded event slices after returning them,
clearing the buffers under the existing mutex while preserving occurrence order.
Ensure both buffers no longer retain events for the observer lifetime; use the
same drain behavior for store errors and uncacheable-private results.

In `@v2/pkg/engine/cache/transform.go`:
- Around line 58-70: Update computeArgSuffix to distinguish an omitted argument
from an explicitly supplied null when resolving ctx.Variables, using a distinct
absent-value marker or the argument’s effective schema default instead of
hashing omitted values as null. Preserve the existing null serialization for
explicitly provided null values and keep variable remapping behavior unchanged.

In `@v2/pkg/engine/plan/representationvariable/representation_variable.go`:
- Around line 356-417: The resolveOnTypeNames method must not panic when the
inline fragment type condition resolves to a union. Replace the union branch’s
panic with the established StopWithInternalErr(...) planning-error path, or
reuse the union-member handling from plan.Visitor.resolveOnTypeNames, while
preserving the existing interface and concrete-type behavior.

In `@v2/pkg/engine/resolve/fetch.go`:
- Around line 26-59: Keep the exported resolve.Fetch interface
backward-compatible by removing the newly added requirements from the interface,
or document the intentional breaking change in v2/CHANGELOG.md if those methods
must remain. Update the interface declaration around CacheConfig,
SetCacheConfig, IsEntityFetch, IsBatchEntityFetch, RepresentationInputTemplate,
LoadTrace, and SetDataSource without changing unrelated behavior.

In `@v2/pkg/engine/resolve/resolve.go`:
- Around line 529-537: Update the deferred-response flow around
ctx.deferredResponse and the initial frame rendering to call
ctx.flushCacheTraces() before the trace extension is rendered, ensuring cache
events from the initial fetch are included in that response trace. Keep
ctx.endCacheRequest() deferred until request processing completes.

---

Nitpick comments:
In `@execution/cachingtesting/art_e2e_test.go`:
- Around line 557-582: Extend the “two representations of one entity are two
independent entries” subtest to assert the writes, not only the trace reads. Use
store.Ops() to verify both expected SetMany entries, or products.Requests() to
verify both product fetches occurred, preserving the distinct sku and upc
representations.

In `@execution/cachingtesting/compose.sh`:
- Around line 1-10: Update the compose script to invoke the wgc release matching
the committed fixture’s compatibilityVersion (1:0.62.2) instead of latest, and
anchor execution to the script’s own directory before using graph.yaml and
config.json so callers can run it from any working directory.

In `@execution/cachingtesting/defer_l1_e2e_test.go`:
- Around line 35-48: Harden executeDeferOnFlush and the corresponding
release-gate tests so the gate always closes even when the final frame comes
from writer.String() rather than the flush callback. Add a one-time cleanup
fallback using the existing closeGate mechanism, and have the flush callback
invoke closeGate instead of directly closing release; apply the same pattern to
the other referenced test cases.

In `@v2/pkg/engine/cache/cache_control.go`:
- Around line 118-121: Update resolveCaching so Scope is empty whenever the
result cannot be stored, specifically on the in.L2-disabled and
UncacheablePrivate return paths; only assign entryScope(in.Private) when a store
write is permitted, preserving the documented CacheControl contract.

In `@v2/pkg/engine/cache/cache_key_template.go`:
- Around line 440-472: Update canonicalVariables to include only variables
consumed by the cached root-field fetch when the execution plan exposes that
dependency set, preserving deterministic sorting and serialization for the
selected variables. If no dependency metadata is available, document next to the
existing PRECONDITION note that all request variables remain part of the cache
key.

In `@v2/pkg/engine/cache/controller_shadow_test.go`:
- Around line 36-52: The CompareShadow method records map entries in
nondeterministic iteration order; make the recorded compares deterministic by
sorting o.compares by key before returning, or by collecting ShadowStash entries
in ascending item-index order. Preserve the existing comparison fields and
behavior.

In `@v2/pkg/engine/cache/controller_store_test.go`:
- Around line 467-474: Update the GetMany misalignment assertion in the relevant
test to compare stable StoreError fields separately, then assert the contained
error’s message independently rather than using assert.Equal on the entire
struct. Preserve the expected operation, subgraph, and key count while allowing
the controller to wrap the underlying error.

In `@v2/pkg/engine/cache/controller_test.go`:
- Around line 55-108: Add synchronization to testStore using a sync.Mutex, and
lock access in GetMany, SetMany, and value while reading or mutating s.data and
s.ops. Ensure all early-return paths release the lock so the test double remains
safe for concurrent controller operations.
- Around line 386-397: Differentiate the [F3] and [F4] cases in the test inputs:
make the empty-body case use ResponseData: nil while retaining a successful
status, and make the parse-failure case use a non-2xx status with appropriate
response data. Avoid duplicating the [F2] FetchFailed-only input so each row
exercises its named condition.

In `@v2/pkg/engine/cache/controller.go`:
- Around line 517-551: Rename the resolve.ItemCacheState variable declared
inside the loop over in.Items to a distinct name, such as itemState, while
preserving its field assignments and append to items. Keep the outer handleState
variable state unchanged for its later use.

In `@v2/pkg/engine/cache/fetch_cache_configurator_types_test.go`:
- Around line 126-149: Add a one-sentence comment to the “a batch over two
concrete types takes the shortest declared lifetime” test explaining that the
concrete types come through the representation’s OnTypeNames while
FetchInfo.RootFields remains single-typed, so buildConfig does not classify it
as mixed-type caching.

In `@v2/pkg/engine/cache/optimize_l1_cache.go`:
- Around line 295-330: Update fieldNarrowingName to include a canonical sorted
representation of field.OnTypeNames in the generated identity, alongside the
schema name and argument bindings. Ensure identical type-condition sets produce
the same representation regardless of input order, while preserving the existing
argument handling and findFieldByNarrowingName lookup behavior.

In `@v2/pkg/engine/cache/partial_test.go`:
- Around line 272-275: Remove the redundant immediately invoked closure in the
writeThrough call and pass partialConfig(t) directly as the fetch cache
configuration argument, leaving the surrounding NewController, productItem, and
fresh arguments unchanged.

In `@v2/pkg/engine/plan/cacheconfig/cache_directive_test.go`:
- Around line 20-39: Update every expected TypeCacheConfig for `@cache`
declarations that omit scope in the cache directive tests, including the cases
near the existing declarations and those later in the file, to explicitly set
Scope: CacheScopePublic. Preserve all existing MaxAge and warning expectations.

In `@v2/pkg/engine/plan/representationvariable/representation_variable_test.go`:
- Around line 14-24: Update the test helpers around runTest and build to capture
the parse report returned by astparser.ParseGraphqlDocumentString and assert it
contains no errors before using the parsed definition. Apply the same validation
in both helpers so malformed schema fixtures fail with a clear parse error
instead of a node mismatch.

In `@v2/pkg/engine/plan/representationvariable/representation_variable.go`:
- Line 24: Remove the stale TODO comment about adding remapping path support,
since remapPaths is already implemented and applied to the field path.
- Around line 97-120: Guard the type assertions in mergeArrays and mergeObjects:
if either operand cannot be asserted to the expected resolve.Array or
resolve.Object type, return the original left node unchanged before
dereferencing it. Preserve the existing merge behavior when both operands have
the expected node kind.

In `@v2/pkg/engine/plan/root_field_isolation.go`:
- Around line 19-21: Update isParentPathIsRootOperationPath to expose or reuse a
shared query-root path constant, then replace the hardcoded "query" comparison
in root_field_isolation.go with that constant. Keep the existing root-path check
behavior unchanged.
- Around line 22-23: Profile the root-field gate around Resolve and RootField,
and if it is hot, cache the resolved configuration keyed by datasource ID
(field.ds.Id()) to avoid repeated Resolve work. Preserve the RootField lookup
behavior and note that caching Resolve does not eliminate its linear
EffectiveSubgraphConfig.RootFields scan.

In `@v2/pkg/engine/postprocess/postprocess.go`:
- Around line 293-307: The Sequence handling in walk should enforce that
ChildNodes[0] is the single parent group emitted by buildDeferTree, rather than
inferring childParent from the number of appended trees. Validate that walking
the first child adds exactly one tree and fail loudly when the invariant is
violated; then use that newly appended tree as childParent before walking the
remaining children.

In `@v2/pkg/engine/resolve/cache_transaction.go`:
- Around line 42-54: The error fallback in CacheTransaction.StructuralCopy must
preserve value isolation instead of returning the aliased input. Replace the
parse-failure return of v with nil, while keeping the DeepCopy path and
successful copied result unchanged.

In `@v2/pkg/engine/resolve/fetchtree.go`:
- Around line 202-209: Replace the deferred closure around the cache assignment
with a direct statement after the inner switch in the surrounding
fetch-resolution flow. Call n.Item.Fetch.CacheConfig() once, guard against nil
queryPlan.Fetch and nil cache configuration, then assign its String() value to
queryPlan.Fetch.Cache.
🪄 Autofix

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: c3a3c510-ac6c-4031-8387-30634fbac28e

📥 Commits

Reviewing files that changed from the base of the PR and between 16d309c and 279c752.

📒 Files selected for processing (126)
  • docs/caching/1-readme.md
  • docs/caching/2-behavior.md
  • docs/caching/3-onboarding.md
  • docs/caching/4-redis-adapter-report.md
  • docs/caching/5-testing-on-js-subgraphs.md
  • docs/caching/6-open-issues.md
  • execution/cachingtesting/args_key_e2e_test.go
  • execution/cachingtesting/art_e2e_test.go
  • execution/cachingtesting/batch_e2e_test.go
  • execution/cachingtesting/cache_control_e2e_test.go
  • execution/cachingtesting/cachingtesting.go
  • execution/cachingtesting/cachingtesting_test.go
  • execution/cachingtesting/cascade_e2e_test.go
  • execution/cachingtesting/client_headers_e2e_test.go
  • execution/cachingtesting/compose.sh
  • execution/cachingtesting/config.json
  • execution/cachingtesting/defer_l1_e2e_test.go
  • execution/cachingtesting/enginetesting.go
  • execution/cachingtesting/entity_config_test.go
  • execution/cachingtesting/entity_l2_test.go
  • execution/cachingtesting/envelope_e2e_test.go
  • execution/cachingtesting/graph.yaml
  • execution/cachingtesting/isolation_e2e_test.go
  • execution/cachingtesting/l1_e2e_test.go
  • execution/cachingtesting/loader_bench_test.go
  • execution/cachingtesting/negative_e2e_test.go
  • execution/cachingtesting/normalization_e2e_test.go
  • execution/cachingtesting/partial_e2e_test.go
  • execution/cachingtesting/privacy_e2e_test.go
  • execution/cachingtesting/provides_data_test.go
  • execution/cachingtesting/rootfield_e2e_test.go
  • execution/cachingtesting/shadow_e2e_test.go
  • execution/cachingtesting/store_batching_e2e_test.go
  • execution/cachingtesting/subgraphs/deals.graphql
  • execution/cachingtesting/subgraphs/inventory.graphql
  • execution/cachingtesting/subgraphs/products.graphql
  • execution/cachingtesting/subgraphs/reviews.graphql
  • execution/cachingtesting/subgraphs/users.graphql
  • execution/cachingtesting/tags_e2e_test.go
  • execution/cachingtesting/type_declaration_e2e_test.go
  • execution/engine/engine_caching_config_test.go
  • execution/engine/engine_config.go
  • execution/engine/execution_engine.go
  • execution/engine/testdata/complex_nesting_query_with_art.json
  • execution/federationtesting/gateway/httphandler/http.go
  • v2/pkg/engine/cache/cache_control.go
  • v2/pkg/engine/cache/cache_control_test.go
  • v2/pkg/engine/cache/cache_key_args_test.go
  • v2/pkg/engine/cache/cache_key_builder.go
  • v2/pkg/engine/cache/cache_key_builder_test.go
  • v2/pkg/engine/cache/cache_key_partition_test.go
  • v2/pkg/engine/cache/cache_key_template.go
  • v2/pkg/engine/cache/cache_key_template_test.go
  • v2/pkg/engine/cache/cache_response.go
  • v2/pkg/engine/cache/cache_response_test.go
  • v2/pkg/engine/cache/cache_tags.go
  • v2/pkg/engine/cache/cache_tags_test.go
  • v2/pkg/engine/cache/cachetesting/fakes.go
  • v2/pkg/engine/cache/cachetesting/fakes_test.go
  • v2/pkg/engine/cache/cachetesting/realish.go
  • v2/pkg/engine/cache/configure_caching.go
  • v2/pkg/engine/cache/configure_caching_test.go
  • v2/pkg/engine/cache/controller.go
  • v2/pkg/engine/cache/controller_batch_test.go
  • v2/pkg/engine/cache/controller_cache_control_test.go
  • v2/pkg/engine/cache/controller_l1_test.go
  • v2/pkg/engine/cache/controller_negative_test.go
  • v2/pkg/engine/cache/controller_privacy_test.go
  • v2/pkg/engine/cache/controller_rootfield_test.go
  • v2/pkg/engine/cache/controller_shadow_test.go
  • v2/pkg/engine/cache/controller_store_test.go
  • v2/pkg/engine/cache/controller_test.go
  • v2/pkg/engine/cache/coverage.go
  • v2/pkg/engine/cache/coverage_test.go
  • v2/pkg/engine/cache/envelope.go
  • v2/pkg/engine/cache/envelope_test.go
  • v2/pkg/engine/cache/fetch_cache_configurator.go
  • v2/pkg/engine/cache/fetch_cache_configurator_rootfield_test.go
  • v2/pkg/engine/cache/fetch_cache_configurator_test.go
  • v2/pkg/engine/cache/fetch_cache_configurator_types_test.go
  • v2/pkg/engine/cache/observer.go
  • v2/pkg/engine/cache/observer_test.go
  • v2/pkg/engine/cache/optimize_l1_cache.go
  • v2/pkg/engine/cache/optimize_l1_cache_test.go
  • v2/pkg/engine/cache/partial.go
  • v2/pkg/engine/cache/partial_test.go
  • v2/pkg/engine/cache/transform.go
  • v2/pkg/engine/cache/transform_test.go
  • v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go
  • v2/pkg/engine/plan/cache_provides_data_visitor.go
  • v2/pkg/engine/plan/cache_provides_data_visitor_port_test.go
  • v2/pkg/engine/plan/cache_provides_data_visitor_test.go
  • v2/pkg/engine/plan/cacheconfig/cache_directive.go
  • v2/pkg/engine/plan/cacheconfig/cache_directive_test.go
  • v2/pkg/engine/plan/cacheconfig/cacheconfig.go
  • v2/pkg/engine/plan/cacheconfig/cacheconfig_test.go
  • v2/pkg/engine/plan/cacheconfig/type_cache_test.go
  • v2/pkg/engine/plan/configuration.go
  • v2/pkg/engine/plan/path_builder_visitor.go
  • v2/pkg/engine/plan/planner.go
  • v2/pkg/engine/plan/representationvariable/representation_variable.go
  • v2/pkg/engine/plan/representationvariable/representation_variable_test.go
  • v2/pkg/engine/plan/root_field_isolation.go
  • v2/pkg/engine/plan/root_field_isolation_test.go
  • v2/pkg/engine/postprocess/postprocess.go
  • v2/pkg/engine/postprocess/postprocess_caching_test.go
  • v2/pkg/engine/resolve/batch_input_assembly.go
  • v2/pkg/engine/resolve/batch_input_assembly_test.go
  • v2/pkg/engine/resolve/cache_config.go
  • v2/pkg/engine/resolve/cache_config_test.go
  • v2/pkg/engine/resolve/cache_controller.go
  • v2/pkg/engine/resolve/cache_controller_test.go
  • v2/pkg/engine/resolve/cache_fetch_test.go
  • v2/pkg/engine/resolve/cache_node_copy_test.go
  • v2/pkg/engine/resolve/cache_noop_test.go
  • v2/pkg/engine/resolve/cache_response.go
  • v2/pkg/engine/resolve/cache_transaction.go
  • v2/pkg/engine/resolve/context.go
  • v2/pkg/engine/resolve/fetch.go
  • v2/pkg/engine/resolve/fetchtree.go
  • v2/pkg/engine/resolve/loader.go
  • v2/pkg/engine/resolve/node_object.go
  • v2/pkg/engine/resolve/resolve.go
  • v2/pkg/engine/resolve/response.go
  • v2/pkg/engine/resolve/subgraph_request_singleflight_test.go
  • v2/pkg/engine/resolve/tainted_objects_test.go

Comment on lines +19 to +21
Fix direction: derive the key from the argument values the fetch itself sends
(its rendered input), mirroring entity keys.
Until fixed: do not configure root-field caching for fields a single operation may call twice.

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Block root-field caching until the key uses fetch arguments.

The documented failure serves one root-field result to another alias when both calls use different arguments. This changes a valid GraphQL response.

Derive the key from the rendered arguments of this fetch. If that fix cannot ship now, reject or disable root-field cache configuration. Documentation alone cannot prevent this corruption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/caching/6-open-issues.md` around lines 19 - 21, Implement the root-field
cache fix so its key is derived from the fetch’s rendered argument values,
matching entity-key behavior and distinguishing aliased calls with different
arguments. If that implementation is not available, reject or disable root-field
cache configuration rather than relying solely on the documented warning.

Comment on lines +228 to +235
// Two lookups (one per fetch, neither served) and NOT ONE write: the
// deferred fetch answered without a Cache-Control and would have written,
// but it never reached the flush because its own result is a fresh miss
// whose write is the second op.
ops := store.Ops()
require.Len(t, ops, 3)
require.Len(t, ops[0].Keys, 1)
key := ops[0].Keys[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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the contradictory comment.

The comment states "NOT ONE write", but the assertion below expects three ops including a SetMany. The comment also calls the write "the second op" while ops[2] holds it. Rewrite the comment to describe what the test asserts: two lookups, neither served, and one flush that carries the deferred fetch's value only.

📝 Proposed comment fix
-		// Two lookups (one per fetch, neither served) and NOT ONE write: the
-		// deferred fetch answered without a Cache-Control and would have written,
-		// but it never reached the flush because its own result is a fresh miss
-		// whose write is the second op.
+		// Two lookups, one per fetch, and neither is served. The no-store result
+		// contributes no write. The single flush carries only the deferred
+		// fetch's value, which answered without a Cache-Control.
 		ops := store.Ops()
 		require.Len(t, ops, 3)
📝 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
// Two lookups (one per fetch, neither served) and NOT ONE write: the
// deferred fetch answered without a Cache-Control and would have written,
// but it never reached the flush because its own result is a fresh miss
// whose write is the second op.
ops := store.Ops()
require.Len(t, ops, 3)
require.Len(t, ops[0].Keys, 1)
key := ops[0].Keys[0]
// Two lookups, one per fetch, and neither is served. The no-store result
// contributes no write. The single flush carries only the deferred
// fetch's value, which answered without a Cache-Control.
ops := store.Ops()
require.Len(t, ops, 3)
require.Len(t, ops[0].Keys, 1)
key := ops[0].Keys[0]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/cache_control_e2e_test.go` around lines 228 - 235,
Update the comment above the ops assertions to state that there are two lookups,
neither served, followed by one flush carrying only the deferred fetch’s value;
remove the claim that there was no write and correct any reference to the
write’s position from the second op to the flush represented by ops[2].

Comment on lines +325 to +338
ops := store.Ops()
writes := ops[len(ops)-1]
assert.Equal(t, "SetMany", writes.Kind)
assert.Equal(t, [][]string{
{
"subgraph:0",
"type:0:Query",
},
{
"subgraph:1",
"type:1:Product",
"entity:1:Product:d3cc039c7a9789e7", // upc "1"
},
}, [][]string{writes.Items[0].Tags, writes.Items[1].Tags})

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the op-log indexing before you read it.

ops[len(ops)-1] panics when store.Ops() is empty. writes.Items[1] panics when the flush carries fewer than two items. A panic aborts the whole package test binary instead of failing this subtest. The other subtests in this file already use require.Len before indexing.

💚 Proposed fix
 	ops := store.Ops()
+	require.NotEmpty(t, ops)
 	writes := ops[len(ops)-1]
 	assert.Equal(t, "SetMany", writes.Kind)
+	require.Len(t, writes.Items, 2)
 	assert.Equal(t, [][]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
ops := store.Ops()
writes := ops[len(ops)-1]
assert.Equal(t, "SetMany", writes.Kind)
assert.Equal(t, [][]string{
{
"subgraph:0",
"type:0:Query",
},
{
"subgraph:1",
"type:1:Product",
"entity:1:Product:d3cc039c7a9789e7", // upc "1"
},
}, [][]string{writes.Items[0].Tags, writes.Items[1].Tags})
ops := store.Ops()
require.NotEmpty(t, ops)
writes := ops[len(ops)-1]
assert.Equal(t, "SetMany", writes.Kind)
require.Len(t, writes.Items, 2)
assert.Equal(t, [][]string{
{
"subgraph:0",
"type:0:Query",
},
{
"subgraph:1",
"type:1:Product",
"entity:1:Product:d3cc039c7a9789e7", // upc "1"
},
}, [][]string{writes.Items[0].Tags, writes.Items[1].Tags})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/client_headers_e2e_test.go` around lines 325 - 338,
Guard the op-log accesses in the affected test with require.Len checks before
indexing: require at least one operation before reading ops[len(ops)-1], then
require at least two items before accessing writes.Items[0] and writes.Items[1].
Follow the existing require.Len pattern used by the other subtests so failures
remain scoped to the subtest.

Comment on lines +255 to +257
store.Seed("v1:1:4f796e3bbd360fce",
[]byte(`{"data":{"__typename":"Product","stock":999},"cc":{"ttl":60,"created":1785852117,"scope":"private"}}`),
time.Minute)

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Derive the seeded created value from the current clock.

The seeded envelope pins "created":1785852117. That epoch is fixed and already in the past, and the envelope declares "ttl":60. The seeded entry is therefore both scope-mismatched and past its own declared freshness window, while the store TTL (time.Minute) keeps it readable.

The assertion at Line 273 only records a ScopeMismatch, so the test passes today. The pass depends on the controller evaluating scope before freshness. If that order ever changes, this fixture fails for a reason unrelated to privacy.

Seed a created value that is fresh relative to the declared TTL. The fixture then isolates the scope mismatch as the single cause of the discard.

🧪 Proposed fix to make the seeded entry fresh
-		store.Seed("v1:1:4f796e3bbd360fce",
-			[]byte(`{"data":{"__typename":"Product","stock":999},"cc":{"ttl":60,"created":1785852117,"scope":"private"}}`),
-			time.Minute)
+		store.Seed("v1:1:4f796e3bbd360fce",
+			[]byte(fmt.Sprintf(
+				`{"data":{"__typename":"Product","stock":999},"cc":{"ttl":60,"created":%d,"scope":"private"}}`,
+				time.Now().Unix())),
+			time.Minute)

Add "fmt" to the import block.

📝 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
store.Seed("v1:1:4f796e3bbd360fce",
[]byte(`{"data":{"__typename":"Product","stock":999},"cc":{"ttl":60,"created":1785852117,"scope":"private"}}`),
time.Minute)
store.Seed("v1:1:4f796e3bbd360fce",
[]byte(fmt.Sprintf(
`{"data":{"__typename":"Product","stock":999},"cc":{"ttl":60,"created":%d,"scope":"private"}}`,
time.Now().Unix())),
time.Minute)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/privacy_e2e_test.go` around lines 255 - 257, Update
the seeded envelope in the privacy E2E test around store.Seed to derive the JSON
"created" timestamp from the current clock, using fmt as needed, so it remains
fresh within the declared 60-second TTL. Preserve the private scope and existing
store TTL, ensuring ScopeMismatch is the fixture’s only discard cause.

Comment on lines +37 to +59
inventoryRule := Rule("", `{"data":{"_entities":[{"__typename":"Product","stock":5}]}}`)
inventory := Rules(inventoryRule)
executionEngine := NewEngine(t, inventoryShadowCaching(), Subgraphs{"users": users, "products": products, "inventory": inventory})

// Request 1: shadow MISS — plain fetch + write.
firstObserver := &cachetesting.RecordingObserver{}
firstBody := Execute(t, executionEngine, query, cachetesting.NewRealishCache(store, firstObserver))
assert.Equal(t, `{"data":{"me":{"favoriteProduct":{"upc":"1","stock":5}}}}`, firstBody)
assert.Equal(t, int64(1), inventory.Requests())
assert.Empty(t, firstObserver.Compares())

ops := store.Ops()
require.Len(t, ops, 2)
require.Len(t, ops[0].Keys, 1)
key := ops[0].Keys[0]

// Request 2: L2 HIT under shadow — the subgraph now says stock=7 and the
// response MUST show 7 (fresh served, never the cached 5); the compare
// records the mismatch and L2 is overwritten with the fresh value.
inventoryRule.Response = `{"data":{"_entities":[{"__typename":"Product","stock":7}]}}`
secondObserver := &cachetesting.RecordingObserver{}
secondBody := Execute(t, executionEngine, query, cachetesting.NewRealishCache(store, secondObserver))
assert.Equal(t, `{"data":{"me":{"favoriteProduct":{"upc":"1","stock":7}}}}`, secondBody)

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm SubgraphRule field synchronization and race-detector usage.
set -uo pipefail

echo "=== SubgraphRule definition ==="
ast-grep run --pattern 'type SubgraphRule struct { $$$ }' --lang go .

echo "=== Handler reads of rule.Response / rule.Headers / rule.Gate ==="
rg -nP -C4 '\brule\.(Response|Headers|Gate)\b' --type=go

echo "=== Test-side in-place mutations of a rule's Response field ==="
rg -nP -C3 '\.Response\s*=' --type=go

echo "=== Race detector in CI and Makefiles ==="
fd -H -t f -e yml -e yaml . .github 2>/dev/null | xargs -r rg -n 'go test' 
fd -H -t f -g 'Makefile*' . | xargs -r rg -n 'go test'

Repository: wundergraph/graphql-go-tools

Length of output: 1075


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== enginetesting.go relevant definitions and handler ==="
sed -n '30,130p' execution/cachingtesting/enginetesting.go

echo "=== All Response assignments ==="
rg -n -C3 '\.Response[[:space:]]*=' --glob '*.go' .

echo "=== Target test context ==="
sed -n '1,100p' execution/cachingtesting/shadow_e2e_test.go

echo "=== Race-detector configuration ==="
rg -n -C2 --glob '*.yml' --glob '*.yaml' --glob 'Makefile*' \
  'go test|race|GORACE|-race' .github . 2>/dev/null | head -300

echo "=== Response field access classification ==="
rg -n -C2 --glob '*.go' '\.Response\b|Response:' execution/cachingtesting

Repository: wundergraph/graphql-go-tools

Length of output: 24097


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== Execute implementation and response-body handling ==="
rg -n -C12 'func Execute|func NewEngine' execution/cachingtesting --glob '*.go'

echo "=== Call sites that can run the subgraph handler concurrently ==="
rg -n -C8 'Execute\(t|NewRealishCache|httptest.NewServer' execution/cachingtesting --glob '*.go'

echo "=== Standalone race-detector probe for the same HTTP access pattern ==="
cat >/tmp/http_rule_race_test.go <<'EOF'
package main

import (
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
)

type rule struct{ response string }

func main() {
	r := &rule{response: "old"}
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(r.response))
	}))
	defer s.Close()

	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			resp, err := http.Get(s.URL)
			if err != nil {
				return
			}
			_, _ = io.ReadAll(resp.Body)
			_ = resp.Body.Close()
			if i%2 == 0 {
				r.response = "new"
			}
		}(i)
	}
	wg.Wait()
}
EOF
if command -v go >/dev/null 2>&1; then
	go run -race /tmp/http_rule_race_test.go 2>&1 | head -80
else
	echo "go is unavailable"
fi
rm -f /tmp/http_rule_race_test.go

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -uo pipefail

log=/tmp/coderabbit-shell-logs/shell-output-tnodmf
echo "=== Standalone probe result ==="
rg -n -C3 'WARNING: DATA RACE|Found [0-9]+ data race|go is unavailable|fatal error|race' "$log" || true
tail -80 "$log"

echo "=== Exact Execute implementation ==="
sed -n '178,199p' execution/cachingtesting/enginetesting.go

echo "=== HTTP client body-read path used by execution tests ==="
rg -n -C8 'io.ReadAll|ReadAll|response.Body|http.NewRequest|client.Do' \
  v2/pkg/engine execution --glob '*.go' | head -240

Repository: wundergraph/graphql-go-tools

Length of output: 34012


🏁 Script executed:

#!/bin/bash
set -uo pipefail

cat >/tmp/http_rule_order.go <<'EOF'
package main

import (
	"io"
	"net/http"
	"net/http/httptest"
)

type rule struct{ response string }

func main() {
	r := &rule{response: "old"}
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, _ = w.Write([]byte(r.response))
	}))
	defer s.Close()

	for i := 0; i < 1000; i++ {
		resp, err := http.Get(s.URL)
		if err != nil {
			panic(err)
		}
		if _, err = io.ReadAll(resp.Body); err != nil {
			panic(err)
		}
		_ = resp.Body.Close()

		// Match the target test: mutate only after the complete response body
		// has been read, then issue the next request.
		if i == 0 {
			r.response = "new"
		}
	}
}
EOF

if command -v go >/dev/null 2>&1; then
	go run -race /tmp/http_rule_order.go 2>&1
else
	echo "go is unavailable"
fi
rm -f /tmp/http_rule_order.go

echo "=== HTTP client response-body read ==="
sed -n '234,270p' v2/pkg/engine/datasource/httpclient/nethttpclient.go

Repository: wundergraph/graphql-go-tools

Length of output: 1306


Synchronize SubgraphRule.Response before mutating it. The handler reads this plain string, while line 56 writes it from the test goroutine. Execute waits for the HTTP body but does not synchronize access to this field. Protect the access with a mutex or atomic accessor, or select responses through a synchronized per-request mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@execution/cachingtesting/shadow_e2e_test.go` around lines 37 - 59,
Synchronize access to inventoryRule.Response between the request handler and the
test’s mutation before the second Execute call. Update the SubgraphRule response
read/write path, or use a synchronized per-request response mechanism, so the
handler’s read cannot race with the test goroutine’s assignment while preserving
the stock=7 response for the second request.

Comment on lines +391 to +405
func renderCacheKey(prefix string, payload []byte) string {
preimage := make([]byte, 0, len(prefix)+1+len(payload))
preimage = append(preimage, prefix...)
preimage = append(preimage, ':')
preimage = append(preimage, payload...)
return prefix + ":" + hashHex(preimage)
}

func hashHex(value []byte) string {
h := pool.Hash64.Get()
_, _ = h.Write(value)
sum := h.Sum64()
pool.Hash64.Put(h)
return hex64(sum)
}

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.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Consider a collision-resistant digest for L2 keys.

renderCacheKey folds the whole preimage into 64 bits with xxhash64. xxhash is not collision resistant. An attacker who controls a representation value (for example an id or an argument bound to a variable) can compute a second value that produces the same digest. Both entities then share one L2 entry, so the attacker can place chosen data under the key a legitimate entity reads.

The partition segment already uses SHA-256 for the same class of reason (see the comment on sha256Hex). Consider one of:

  • Truncate a SHA-256 (or keyed xxhash with a process-wide secret seed) to 128 bits for the L2 digest.
  • Keep xxhash64 and document that the store must not be shared with untrusted key material.

This changes the key layout, so it belongs with cacheFormatVersion v1 before entries exist in production.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/cache_key_template.go` around lines 391 - 405, Update
renderCacheKey and its hashHex helper to use a collision-resistant 128-bit
digest, preferably truncated SHA-256 consistent with sha256Hex, instead of
unkeyed xxhash64; preserve the existing prefix/preimage structure while
producing the wider digest. Bump cacheFormatVersion to v1 so the changed key
layout is established before production entries exist.

Comment on lines +342 to +347
// Ops returns a copy of the ordered operation log.
func (s *FakeStore) Ops() []StoreOp {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.ops)
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Deep-copy nested operation data.

Ops only clones the outer []StoreOp. NormalizeStoreOpsClock modifies Items[i].Value in its returned value. That mutation also changes the retained log.

Clone Keys, Hits, Items, and each item's Tags before returning. This keeps later assertions independent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/cachetesting/fakes.go` around lines 342 - 347, Update
FakeStore.Ops to deep-copy each StoreOp before returning, including the Keys,
Hits, and Items slices and every item's Tags slice. Preserve the existing
locking and return a cloned operation log so NormalizeStoreOpsClock mutations
cannot alter s.ops.

Comment on lines +67 to +102
func coversNode(ctx *resolve.Context, value *astjson.Value, node resolve.Node) bool {
switch typed := node.(type) {
case *resolve.Scalar:
return value.Type() != astjson.TypeNull || typed.Nullable
case *resolve.Object:
if value.Type() == astjson.TypeNull {
return typed.Nullable
}
if value.Type() != astjson.TypeObject {
return false
}
return covers(ctx, value, typed)
case *resolve.Array:
if value.Type() == astjson.TypeNull {
return typed.Nullable
}
if value.Type() != astjson.TypeArray {
return false
}
if typed.Item == nil {
return true
}
items, err := value.Array()
if err != nil {
return false
}
for _, item := range items {
if !coversNode(ctx, item, typed.Item) {
return false
}
}
return true
default:
return false
}
}

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# What node kinds does the ProvidesData builder produce?
fd -t f 'cache_provides_data_visitor.go|fetch_cache_configurator.go' v2/pkg/engine \
  --exec rg -n -C3 'resolve\.(Scalar|String|Boolean|Integer|Float|BigInt|Null|StaticString|CustomNode|Object|Array)\{'
# All Node implementations in resolve.
ast-grep run --pattern 'func ($_ *$N) NodeKind() NodeKind { $$$ }' --lang go v2/pkg/engine/resolve

Repository: wundergraph/graphql-go-tools

Length of output: 1843


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- builder symbols and createFieldValue ---'
rg -n -C6 'func \(v \*cacheProvidesDataVisitor\) (createFieldValue|visit|enter)|createFieldValue|ProvidesData' v2/pkg/engine/cache v2/pkg/engine

echo '--- resolve node declarations ---'
rg -n -C5 'type (Scalar|String|Boolean|Integer|Float|BigInt|Null|StaticString|CustomNode|Object|Array) struct|NodeKind\(\)' v2/pkg/engine/resolve

echo '--- all builder node constructors ---'
rg -n -C3 '&resolve\.(Scalar|String|Boolean|Integer|Float|BigInt|Null|StaticString|CustomNode|Object|Array)\b' v2/pkg/engine

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -eu

echo '--- matching cache files ---'
fd -t f . v2/pkg/engine/cache | rg 'cache_provides_data_visitor\.go|fetch_cache_configurator\.go|coverage\.go'

echo '--- builder file and createFieldValue ---'
builder=$(fd -t f 'cache_provides_data_visitor.go' v2/pkg/engine | head -n1)
echo "$builder"
rg -n -C8 'createFieldValue|&resolve\.(Scalar|String|Boolean|Integer|Float|BigInt|Null|StaticString|CustomNode|Object|Array)\b' "$builder"

echo '--- resolve node declarations ---'
rg -l '^type (Scalar|String|Boolean|Integer|Float|BigInt|Null|StaticString|CustomNode|Object|Array) struct' v2/pkg/engine/resolve \
  | while read -r f; do
    echo "FILE: $f"
    rg -n -C4 '^type (Scalar|String|Boolean|Integer|Float|BigInt|Null|StaticString|CustomNode|Object|Array) struct|func \([^)]*\) NodeKind\(\)' "$f"
  done

Repository: wundergraph/graphql-go-tools

Length of output: 7971


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

builder = Path("v2/pkg/engine/plan/cache_provides_data_visitor.go").read_text()
coverage = Path("v2/pkg/engine/cache/coverage.go").read_text()

start = builder.index("func (v *cacheProvidesDataVisitor) createFieldValue")
end = builder.index("\n}\n", start) + 2
# Include nested braces by scanning from the function's opening brace.
opening = builder.index("{", start)
depth = 0
for i in range(opening, len(builder)):
    if builder[i] == "{":
        depth += 1
    elif builder[i] == "}":
        depth -= 1
        if depth == 0:
            end = i + 1
            break
body = builder[start:end]

produced = sorted(set(re.findall(r"&resolve\.([A-Za-z]+)\s*\{", body)))
handled = sorted(set(re.findall(r"case \*resolve\.([A-Za-z]+):", coverage)))
print("createFieldValue constructors:", ", ".join(produced))
print("coversNode handled cases:", ", ".join(handled))
print("unhandled constructors:", ", ".join(sorted(set(produced) - set(handled))))
PY

Repository: wundergraph/graphql-go-tools

Length of output: 302


Handle the *resolve.Null fallback in coversNode

createFieldValue emits *resolve.Null for unresolved or unsupported schema types, but coversNode handles only Scalar, Object, and Array. The fallback therefore makes the affected data permanently uncoverable. Add an explicit *resolve.Null branch or prevent the builder from emitting this node.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/coverage.go` around lines 67 - 102, Update coversNode to
explicitly handle *resolve.Null, matching the fallback nodes emitted by
createFieldValue for unresolved or unsupported schema types. Define the branch
so these nodes can be considered covered instead of falling through to the
default false result; do not alter the existing Scalar, Object, or Array
behavior.

Comment on lines +26 to +59

// CacheConfig returns the per-fetch cache config; nil means "not cached".
// All caching code reads it through this method (never via a switch over
// concrete fetch types).
CacheConfig() *FetchCacheConfig

// SetCacheConfig sets the per-fetch cache config; the caching planner passes
// call it after the concrete fetch types are created.
SetCacheConfig(cfg *FetchCacheConfig)

// IsEntityFetch reports whether this is a single-entity fetch (an _entities
// fetch on an object field).
IsEntityFetch() bool

// IsBatchEntityFetch reports whether this is a batched entity fetch (an
// _entities fetch over array items).
IsBatchEntityFetch() bool

// RepresentationInputTemplate returns the input template holding the
// fetch's `representations` element — the ResolvableObjectVariable segment
// whose node IS the merged representation the fetch sends. It is nil for
// fetches that send no representation (plain single fetches). Caching reads
// it through this method (never via a switch over concrete fetch types).
RepresentationInputTemplate() *InputTemplate

// LoadTrace returns the fetch's ART trace destination; nil when tracing is
// disabled for the request. Caching/observability code reads it through
// this method (never via a switch over concrete fetch types).
LoadTrace() *DataSourceLoadTrace

// SetDataSource replaces the fetch's transport, e.g. to swap in an
// in-process fake; it exists so no caller needs a switch over concrete
// fetch types.
SetDataSource(ds DataSource)

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all types implementing resolve.Fetch by locating FetchKind() implementations.
rg -nP -C 3 'func \([a-zA-Z_]+ \*?\w+\) FetchKind\(\)' --type=go
# Check which of them declare the new methods.
rg -nP 'func \([a-zA-Z_]+ \*?\w+\) (CacheConfig|SetCacheConfig|IsEntityFetch|IsBatchEntityFetch|RepresentationInputTemplate|LoadTrace|SetDataSource)\(' --type=go

Repository: wundergraph/graphql-go-tools

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Fetch interface and related declarations ---'
rg -n -C 8 'type Fetch interface|FetchKind\(\)|CacheConfig\(\)|RepresentationInputTemplate\(\)' --glob '*.go' .
printf '%s\n' '--- Concrete fetch types and compile-time interface assertions ---'
rg -n -C 4 'type .*Fetch|resolve\.Fetch|Fetch\)' --glob '*.go' .

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- Fetch interface ---'
cat -n v2/pkg/engine/resolve/fetch.go | sed -n '1,110p'

printf '%s\n' '--- FetchKind declarations and usages ---'
rg -n 'FetchKind' --glob '*.go' . | head -n 300

printf '%s\n' '--- Implementations of the added methods ---'
rg -n 'func .* (CacheConfig|SetCacheConfig|IsEntityFetch|IsBatchEntityFetch|RepresentationInputTemplate|LoadTrace|SetDataSource)\(' --glob '*.go' . | head -n 300

printf '%s\n' '--- Module and release-note files ---'
sed -n '1,80p' go.mod 2>/dev/null || true
find . -maxdepth 3 -type f \( -iname '*changelog*' -o -iname '*release*' -o -iname '*news*' \) -print

Repository: wundergraph/graphql-go-tools

Length of output: 11177


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- Current v2 changelog structure ---'
sed -n '1,100p' v2/CHANGELOG.md

printf '%s\n' '--- Public Fetch references outside implementation files ---'
rg -n 'resolve\.Fetch|Fetch interface|type .*Fetch interface' \
  --glob '!v2/pkg/engine/resolve/fetch.go' \
  --glob '!**/*_test.go' \
  --glob '*.go' --glob '*.md' . | head -n 300

printf '%s\n' '--- Fetch interface assignments and function parameters ---'
rg -n 'Fetch[[:space:]]*=' --glob '*.go' v2/pkg/engine/resolve
rg -n '\bFetch\b' --glob '*.go' v2/pkg/engine/resolve | grep -vE 'FetchKind|SingleFetch|BatchEntityFetch|EntityFetch|fetch\.go' | head -n 300

Repository: wundergraph/graphql-go-tools

Length of output: 50384


Avoid widening the exported resolve.Fetch interface. The interface now requires seven methods, not six. Repository implementations and test stubs are updated, but downstream implementations will fail to compile. Keep the interface stable or document this breaking API change in v2/CHANGELOG.md.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/fetch.go` around lines 26 - 59, Keep the exported
resolve.Fetch interface backward-compatible by removing the newly added
requirements from the interface, or document the intentional breaking change in
v2/CHANGELOG.md if those methods must remain. Update the interface declaration
around CacheConfig, SetCacheConfig, IsEntityFetch, IsBatchEntityFetch,
RepresentationInputTemplate, LoadTrace, and SetDataSource without changing
unrelated behavior.

Comment on lines +529 to +537
// EndRequest runs after the request arenas are released; it is arena-free
// by contract (see RequestCache.EndRequest).
defer ctx.endCacheRequest()

// Marked before the first fetch, which is what lazily begins the request's
// cache surface: parts of this response resolve after the initial frame is
// written, so no client cache answer may be computed for it.
ctx.deferredResponse = 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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Flush cache traces before the initial deferred frame.

The initial frame renders at Lines 592-603. ctx.endCacheRequest() runs only after the deferred response completes. Unlike Lines 398-402 and Lines 479-484, this path does not call ctx.flushCacheTraces() before the trace extension renders. Cache events from the initial fetch therefore cannot appear in that response trace.

Proposed fix
 		resolvable.deferMode = true
 		resolvable.currentDefer = nil
 		resolvable.deferDescriptors = response.DeferDescriptors
 
+		if ctx.TracingOptions.Enable && ctx.TracingOptions.IncludeTraceOutputInResponseExtensions {
+			ctx.flushCacheTraces()
+		}
+
 		// render initial response
 		err = resolvable.Resolve(ctx.ctx, response.Response.Data, response.Response.Fetches, writer)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/resolve/resolve.go` around lines 529 - 537, Update the
deferred-response flow around ctx.deferredResponse and the initial frame
rendering to call ctx.flushCacheTraces() before the trace extension is rendered,
ensuring cache events from the initial fetch are included in that response
trace. Keep ctx.endCacheRequest() deferred until request processing completes.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

🛑 Comments failed to post (3)
v2/pkg/engine/cache/observer.go (1)

144-176: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find every construction and consumer of TraceObserver, and check for any reset/drain of its recorded slices.
rg -nP -C5 '\bNewTraceObserver\s*\(' 
rg -nP -C3 '\.(StoreErrors|UncacheablePrivate|ScopeMismatches)\s*\('

Repository: wundergraph/graphql-go-tools

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
fd -i 'observer\.go$|traceobserver|observer' .
printf '%s\n' '--- symbols and constructors ---'
rg -n -C4 'type TraceObserver|NewTraceObserver|func \(.*TraceObserver.*\)(StoreErrors|UncacheablePrivate|ScopeMismatches)|storeErrors|uncacheablePrivate' .
printf '%s\n' '--- all observer method calls ---'
rg -n -C3 'StoreErrors|UncacheablePrivate|OnStoreError|OnUncacheablePrivate|TraceObserver' .

Repository: wundergraph/graphql-go-tools

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- observer implementation ---'
sed -n '1,215p' v2/pkg/engine/cache/observer.go
printf '%s\n' '--- production references outside the cache package ---'
rg -n --glob '*.go' --glob '!**/*_test.go' 'NewTraceObserver|TraceObserver|\.StoreErrors\(|\.UncacheablePrivate\(' .
printf '%s\n' '--- request lifecycle and observer interface references ---'
rg -n -C4 --glob '*.go' 'CacheObserver|BeginRequest\(|EndRequest\(|OnFetchObserved\(' v2/pkg/engine

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

tracked = [Path(p) for p in __import__("subprocess").check_output(
    ["git", "ls-files", "*.go"], text=True
).splitlines()]

symbols = {
    "constructor": re.compile(r"\bNewTraceObserver\s*\("),
    "store_accessor": re.compile(r"\.StoreErrors\s*\("),
    "private_accessor": re.compile(r"\.UncacheablePrivate\s*\("),
    "store_append": re.compile(r"\bstoreErrors\s*=\s*append\s*\("),
    "private_append": re.compile(r"\buncacheablePrivate\s*=\s*append\s*\("),
    "store_reset": re.compile(r"\bstoreErrors\s*=\s*(?:nil|[^;\n]+)"),
    "private_reset": re.compile(r"\buncacheablePrivate\s*=\s*(?:nil|[^;\n]+)"),
}

for name, pattern in symbols.items():
    matches = []
    for path in tracked:
        if path.as_posix() == "v2/pkg/engine/cache/observer.go":
            continue
        for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
            if pattern.search(line):
                matches.append(f"{path}:{lineno}:{line.strip()}")
    print(f"--- {name}: {len(matches)} ---")
    print("\n".join(matches[:20]) or "(none)")
PY

Repository: wundergraph/graphql-go-tools

Length of output: 2411


Bound or drain the TraceObserver event buffers

TraceObserver serves many concurrent requests. StoreErrors() and UncacheablePrivate() clone their slices without clearing them. Each store failure or private result therefore remains retained for the observer lifetime and can grow memory without bound. Cap both buffers with overflow counts, or make both accessors atomically drain their buffers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/observer.go` around lines 144 - 176, Update
TraceObserver’s StoreErrors and UncacheablePrivate accessors to atomically drain
their recorded event slices after returning them, clearing the buffers under the
existing mutex while preserving occurrence order. Ensure both buffers no longer
retain events for the observer lifetime; use the same drain behavior for store
errors and uncacheable-private results.
v2/pkg/engine/cache/transform.go (1)

58-70: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where variable defaults are injected during normalization/variable extraction.
rg -nP --type=go -C4 '\b(DefaultValue|defaultValue|InjectDefault|variablesDefault)\w*' v2/pkg/astnormalization | head -80
# Confirm CacheFieldArg carries no literal/default fallback.
ast-grep run --pattern 'type CacheFieldArg struct { $$$ }' --lang go v2/pkg/engine/resolve

Repository: wundergraph/graphql-go-tools

Length of output: 7696


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- normalization entry points and default handling ---'
rg -n -P --type=go -C3 '\b(extractVariablesDefaultValue|VariableValues|Variables|DefaultValue|RemapVariables|normalize.*variable|variable.*default)\b' \
  v2/pkg/astnormalization v2/pkg/engine v2/pkg/graphql | head -240

printf '%s\n' '--- cache suffix call graph ---'
rg -n -P --type=go -C5 '\b(computeArgSuffix|CacheFieldArg|RemapVariables|Variables\.Get)\b' \
  v2/pkg/engine v2/pkg | head -260

printf '%s\n' '--- candidate file outlines ---'
for f in $(git ls-files 'v2/pkg/astnormalization/*.go' 'v2/pkg/engine/cache/*.go' 'v2/pkg/engine/resolve/*.go' | head -80); do
  ast-grep outline "$f" 2>/dev/null | head -80
done

Repository: wundergraph/graphql-go-tools

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- variable normalizer order ---'
sed -n '210,285p' v2/pkg/astnormalization/astnormalization.go
sed -n '417,475p' v2/pkg/astnormalization/astnormalization.go

printf '%s\n' '--- variable-default extraction implementation ---'
sed -n '85,125p' v2/pkg/astnormalization/variables_default_value_extraction.go

printf '%s\n' '--- argument default handling ---'
rg -n -P --type=go -C5 'Argument.*Default|DefaultValue.*Argument|InputValueDefinition.*Default|ArgumentValue|FieldArguments' \
  v2/pkg/astnormalization v2/pkg/engine/plan v2/pkg/engine/cache | head -220

printf '%s\n' '--- cache transform and callers ---'
cat -n v2/pkg/engine/cache/transform.go | sed -n '1,125p'
rg -n -P --type=go -C6 'normalizeToSchema|denormalizeFromSchema|normalizedFieldName|computeArgSuffix|CacheArgs' \
  v2/pkg/engine | head -260

printf '%s\n' '--- context construction and variable sources ---'
rg -n -P --type=go -C5 'NewContext\(|VariablesHash|Input\.Variables|operation\.Input\.Variables|RemapVariables' \
  v2/pkg/engine | head -260

Repository: wundergraph/graphql-go-tools

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all cache transform call sites ---'
rg -n -P --type=go 'normalizeToSchema|denormalizeFromSchema|normalizedFieldName|computeArgSuffix' v2/pkg/engine/cache

printf '%s\n' '--- context creation and resolver variables ---'
rg -n -P --type=go 'NewContext|Variables\s*:' v2/pkg/engine | head -180
rg -n -P --type=go -C4 'Input\.Variables|VariablesHash|RemapVariables' v2/pkg/engine | tail -220

printf '%s\n' '--- normalizer construction and use ---'
rg -n -P --type=go -C5 'NewVariablesNormalizer|VariablesNormalizer|NormalizeOperation\(.*definition' v2 | head -220

printf '%s\n' '--- cache argument capture ---'
sed -n '210,250p' v2/pkg/engine/plan/cache_provides_data_visitor.go

Repository: wundergraph/graphql-go-tools

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- assignments to Context.Variables ---'
rg -n -P --type=go -C6 'Variables\s*=' v2/pkg/engine | head -220

printf '%s\n' '--- request variable propagation into fetch contexts ---'
rg -n -P --type=go -C8 'VariablesHash|VariablesView|RemapVariables|astjson\.MustParseBytes|astjson\.Parse' \
  v2/pkg/engine/datasource v2/pkg/engine/resolve v2/pkg/engine/cache | head -280

printf '%s\n' '--- operation normalization around lines 368-415 ---'
sed -n '368,415p' v2/pkg/astnormalization/astnormalization.go

printf '%s\n' '--- default injection visitor registration and behavior ---'
cat -n v2/pkg/astnormalization/inject_input_default_values.go | sed -n '1,145p'
rg -n -P --type=go 'inputFieldDefaultInjectionVisitor|injectInputDefaultValues|With.*Default' v2/pkg/astnormalization

Repository: wundergraph/graphql-go-tools

Length of output: 50384


Preserve omitted and explicit-null argument values in cache keys.

Variable-definition defaults are materialized into ctx.Variables, but field argument schema defaults are not. computeArgSuffix therefore hashes an omitted variable as null, which collides with $v: null even though the field default applies only to the omitted case. Hash the absent value with a distinct marker or its effective argument default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/cache/transform.go` around lines 58 - 70, Update
computeArgSuffix to distinguish an omitted argument from an explicitly supplied
null when resolving ctx.Variables, using a distinct absent-value marker or the
argument’s effective schema default instead of hashing omitted values as null.
Preserve the existing null serialization for explicitly provided null values and
keep variable remapping behavior unchanged.
v2/pkg/engine/plan/representationvariable/representation_variable.go (1)

356-417: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the origin of this logic and any error-returning variant in the planner.
rg -nP -C 6 'resolveOnTypeNames' --type=go
rg -nP -C 4 'union type' --type=go -g '!**/*_test.go'

Repository: wundergraph/graphql-go-tools

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(representation_variable\.go|plan|federation|directive|selection).*\.go$' | head -200

printf '%s\n' '--- resolver references ---'
rg -n -P -C 8 'resolveOnTypeNames|InlineFragmentTypeConditionName|NodeIsUnionMember|ObjectTypeDefinitionImplementsInterface' . --glob '*.go' || true

printf '%s\n' '--- union and federation validation references ---'
rg -n -i -P -C 5 'union type|union.*fragment|fragment.*union|`@key`|`@requires`|RequiredFieldsFragment|cache.key|cache key' . --glob '*.go' | head -500 || true

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- representation-variable call graph ---'
rg -n -P -C 8 'BuildRepresentationVariable|representationVariable|resolveOnTypeNames|RequiredFieldsFragment|Walk\(' \
  v2/pkg/engine/plan/representationvariable \
  v2/pkg/engine/plan \
  --glob '*.go' | head -700

printf '%s\n' '--- field-set parser and validation ---'
rg -n -i -P -C 6 'FieldSet|field set|Parse.*Selection|selection set|InlineFragment' \
  v2/pkg/federation v2/pkg/engine/plan v2/pkg/astvalidation \
  --glob '*.go' | rg -i 'field.?set|key|requires|provides|selection|inline|fragment' | head -700

printf '%s\n' '--- tests containing union fragments in federation field sets ---'
rg -n -i -P -C 5 'union|on [A-Za-z_][A-Za-z0-9_]*\s*\{' \
  v2/pkg/engine/plan v2/pkg/federation execution --glob '*_test.go' | rg -i 'union|`@key`|`@requires`|`@provides`|on .*\\{' | head -700

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- direct builder call sites ---'
rg -n -P -C 12 'BuildRepresentationVariableNode|MergeRepresentationVariableNodes' v2 --glob '*.go'

printf '%s\n' '--- field-set validation and inline-fragment handling ---'
sed -n '1,180p' v2/pkg/engine/plan/required_fields_visitor.go
sed -n '110,190p' v2/pkg/engine/plan/federation_metadata.go
sed -n '60,180p' v2/pkg/astvalidation/operation_rule_fragments.go

printf '%s\n' '--- focused federation field-set test references ---'
rg -n -i -P -C 4 'union|`@key`|`@requires`|SelectionSet:' \
  v2/pkg/engine/plan/representationvariable/representation_variable_test.go \
  v2/pkg/engine/plan/*_test.go \
  v2/pkg/federation/*_test.go \
  --glob '*.go' | head -500

Repository: wundergraph/graphql-go-tools

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- all inline-fragment field-set literals ---'
rg -n -i -P -C 3 '\.\.\.\s*on\s+[A-Za-z_][A-Za-z0-9_]*\s*\{' \
  . --glob '*.go' --glob '*.graphql' --glob '*.gql' --glob '*.json' \
  | head -500

printf '%s\n' '--- federation configuration construction ---'
rg -n -P -C 10 'FederationFieldConfiguration\{|SelectionSet:|RequiredFields:' \
  v2/pkg/engine --glob '*.go' | head -700

printf '%s\n' '--- AST walker behavior around field definitions and stop errors ---'
rg -n -P -C 8 'func \(.*\) FieldDefinition|FieldDefinition\(ref|StopWithInternalErr|RegisterInlineFragmentVisitor|EnterInlineFragment' \
  v2/pkg/astvisitor v2/pkg/engine/plan/representationvariable v2/pkg/engine/plan/visitor.go \
  --glob '*.go' | head -600

Repository: wundergraph/graphql-go-tools

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- walker implementation ---'
fd -i 'walker.*\.go$' v2/pkg/astvisitor v2/pkg | head -50
rg -n -P -C 10 'func \(.*Walker.*Walk|func \(.*Walker.*walk|RegisterFieldVisitor|FieldDefinition\(ref' \
  v2/pkg/astvisitor --glob '*.go' | head -800

printf '%s\n' '--- representation visitor complete relevant methods ---'
sed -n '193,417p' v2/pkg/engine/plan/representationvariable/representation_variable.go

printf '%s\n' '--- union-fragment field-set tests in planner packages ---'
rg -n -i -P -C 8 'fragments on union|on [A-Za-z_][A-Za-z0-9_]* \{.*\}|SelectionSet: `\.\.\. on|fieldSet: `\.\.\. on|field set' \
  v2/pkg/engine/plan --glob '*_test.go' | head -500

Repository: wundergraph/graphql-go-tools

Length of output: 50384


🌐 Web query:

Apollo Federation FieldSet @key @requires inline fragment on union type condition allowed

💡 Result:

In Apollo Federation, the use of inline fragments within a FieldSet—specifically for @key and @requires directives—is supported [1]. A FieldSet is a string-serialized representation of a selection set [2]. When referencing fields on a union or interface type within a FieldSet, you can use the GraphQL inline fragment syntax (... on Type { fields }) to specify the fields to be included or required [1]. For example, when defining an entity with @key, you can use an inline fragment if the key is associated with specific types in a union: directive @key(fields: "... on Person { name address }") on OBJECT | INTERFACE This syntax is valid and allows for precise selection of fields within a federated schema [1]. It is important to note, however, that an entity's @key cannot include fields that return a union or interface type itself, nor can they include fields that require arguments [3]. While inline fragments are valid within a FieldSet for directives like @key and @requires [1], ensure that your subgraph implementation and Apollo Federation version support the specific schema patterns you are employing, as complex usage of unions and interfaces in federated schemas may have implications for composition and entity resolution [4][5].

Citations:


🌐 Web query:

site:apollographql.com/docs federation @keyfields union interface cannot include fields returning union interface@requires FieldSet

💡 Result:

In Apollo Federation, there is a fundamental restriction regarding the fields used within an entity's @key directive: they cannot include fields that return a union or interface type [1]. This limitation exists because the @key must provide a reliable, unique identifier for an entity, and resolving unions or interfaces in a key definition would create ambiguity or complexity that the federation gateway cannot reliably manage [1]. Specifically, the following constraints apply to the fields argument of the @key directive: - It cannot include fields that return a union or interface type [1]. - It cannot include fields that take arguments [1]. While you mentioned @requires in your query, it is important to distinguish its behavior from @key. The @requires directive, used to annotate fields that depend on other fields, is more flexible than @key. For instance, you can use selection set syntax (e.g., "... on Person { name address }") with @requires when dealing with fields that return a union or interface [2][3][4]. Additionally, recent versions of Apollo Federation have significantly expanded the capabilities of @requires, allowing it to support fields that themselves take arguments (since v2.1.2) [5]. If you are encountering a composition error related to these directives, ensure that your @key selection set consists only of scalar or enum-like fields that unambiguously identify the entity [1]. Fields that return unions or interfaces must be handled through other means, such as by exposing specific scalar fields that serve as the unique identifier.

Citations:


Return a planning error for union type conditions instead of panicking. @requires field sets can use inline fragments on union fields, and this walker reaches EnterField for those selections. Replace the panic with StopWithInternalErr(...), or handle union members as plan.Visitor.resolveOnTypeNames does.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@v2/pkg/engine/plan/representationvariable/representation_variable.go` around
lines 356 - 417, The resolveOnTypeNames method must not panic when the inline
fragment type condition resolves to a union. Replace the union branch’s panic
with the established StopWithInternalErr(...) planning-error path, or reuse the
union-member handling from plan.Visitor.resolveOnTypeNames, while preserving the
existing interface and concrete-type behavior.

devsergiy and others added 2 commits August 11, 2026 13:50
…hape key direction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/caching/6-open-issues.md`:
- Around line 41-44: Update the caching documentation sentence to distinguish
shared selection-digest variants from argument values: aliases, field order, and
fragment variants may share the selection digest, but rendered argument values
must remain in the full cache key and produce separate entries.
🪄 Autofix

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: 03a1aa43-ea0c-4949-8c66-8d6ce6b2b92a

📥 Commits

Reviewing files that changed from the base of the PR and between 279c752 and bfded9e.

📒 Files selected for processing (2)
  • docs/caching/5-testing-on-js-subgraphs.md
  • docs/caching/6-open-issues.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/caching/5-testing-on-js-subgraphs.md

Comment on lines +41 to +44
Because the digest is over normalized schema names,
alias/order/fragment variants and argument-value variants still share entries
(better than Apollo's raw query-shape hash),
and the entity tag still covers all shape variants, so one purge clears them all.

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep rendered argument values in the full cache key.

Line 42 says that argument-value variants share entries. That is unsafe. v2/pkg/engine/cache/cache_key_args_test.go includes an args hash in the key preimage, and v2/pkg/engine/cache/controller_rootfield_test.go requires different variable values to produce different keys.

State that aliases, field order, and fragment variants share the selection digest. Different rendered argument values must use different entries.

Proposed wording
-Because the digest is over normalized schema names,
-alias/order/fragment variants and argument-value variants still share entries
-(better than Apollo's raw query-shape hash),
+Because the digest is over normalized schema names,
+alias/order/fragment variants share the same selection digest, while rendered
+argument values remain in the full cache key and use different entries
+(better than Apollo's raw query-shape hash),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/caching/6-open-issues.md` around lines 41 - 44, Update the caching
documentation sentence to distinguish shared selection-digest variants from
argument values: aliases, field order, and fragment variants may share the
selection digest, but rendered argument values must remain in the full cache key
and produce separate entries.

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.

1 participant