Skip to content

feat: l1/l2 entity and root-field caching on the defer engine - #1560

Open
jensneuse wants to merge 61 commits into
masterfrom
caching-on-defer-v2
Open

feat: l1/l2 entity and root-field caching on the defer engine#1560
jensneuse wants to merge 61 commits into
masterfrom
caching-on-defer-v2

Conversation

@jensneuse

@jensneuse jensneuse commented Jul 2, 2026

Copy link
Copy Markdown
Member

What this PR adds

A clean re-implementation of L1/L2 entity and root-field caching on top of the defer engine, executed as 20 reviewable task commits.
The old implementation (reference: caching-base) tangled caching into loader.go (~461 call sites); this port keeps the loader surface to a handful of explained seams and concentrates all caching semantics in a dedicated v2/pkg/engine/cache package.

Architecture (one paragraph)

The planner side derives per-fetch FetchCacheConfig via additive passes (cacheProvidesDataVisitor, cacheKeyBuilder, fetchCacheConfigurator, optimizeL1Cache) wired through a single postprocess.ConfigureCaching facade; the runtime side is a CacheController/RequestCache pair the loader talks to through two hooks (PrepareFetch before the network, OnFetchSkipped/OnFetchResult after the merge) with an opaque FetchCacheHandle in between — a nil controller or nil config is a zero-cost no-op, and all arena work rides one CacheTransaction (one DataBuffer.Lock acquisition per hook).

Feature checklist

  • L2 entity caching: multi-@key best-effort candidates, freshest-first selection, union merge, write-back/backfill, coverage validation against ProvidesData
  • Store-time normalization (schema names + argument-hash suffixes) with denormalize-at-splice — alias-independent reuse
  • Batch entity caching (per-unique-representation buckets) and partial fetching (reduced representations + realign via BatchIndex)
  • Negative caching (top-level null sentinel; cached and uncached responses stay byte-identical)
  • Shadow mode (read-never-serve staleness probes; root-field asymmetry: force-refetch without compare)
  • Root-field L2 with per-root-field fetch isolation (RFC-3, in core) and by-key root fields reusing entity entries (EntityKeyMappings)
  • Request-lifetime L1 store (*astjson.Value, zero marshaling, keys shared with L2) with cross-defer-group serving
  • ART observability: additive cache section on DataSourceLoadTrace, assembled by cache.TraceObserver with zero observer calls outside the controller

Review aids

  • docs/caching/PROGRESS.md — the task board with per-task commit hashes and the decision log
  • docs/caching/reviews/01-20-*.md — one reviewer document per task commit: decisions, implementation summary, what to look into, verification evidence
  • docs/caching/PLAN.md, docs/caching/specs/ — the RFCs and the phased plan this executes

Verification

  • Unit + plan-driven e2e tests throughout (v2/pkg/engine/cache, execution/cachingtesting — real wgc-composed fixtures, real planner, in-process fakes; testing/synctest for all TTL rows, gate channels for ordering, no latency-based tests)
  • Both modules test-clean and -race-clean; golangci-lint 0 issues in both modules at every task commit

🤖 Generated with Claude Code

Closes #1259

@jensneuse
jensneuse requested a review from a team as a code owner July 2, 2026 06:09
@jensneuse

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough
📝 Walkthrough
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: re-implementing entity and root-field caching on the defer engine.
Description check ✅ Passed The description is directly aligned with the documented caching re-implementation and its supporting docs, tests, and architecture.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch caching-on-defer-v2

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

@jensneuse jensneuse changed the title feat: L1/L2 entity & root-field caching on the defer engine (clean re-implementation) feat: l1/l2 entity and root-field caching on the defer engine Jul 2, 2026

@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: 10

Caution

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

⚠️ Outside diff range comments (1)
v2/pkg/engine/resolve/node_object.go (1)

141-158: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve ParentOnTypeNames in Field.Copy()
Field.Copy() drops ParentOnTypeNames, so copied object trees lose the parent type-condition metadata used by resolvable.go to decide whether a field should be skipped. Add it to the returned struct to keep copies semantically equivalent.

🤖 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/node_object.go` around lines 141 - 158, Field.Copy() is
dropping ParentOnTypeNames, so copied fields lose type-condition metadata used
later by resolvable.go. Update the Field.Copy() return value in node_object.go
to preserve ParentOnTypeNames alongside OnTypeNames and the other copied
properties, ensuring copied object trees remain semantically equivalent.
🧹 Nitpick comments (7)
execution/cachingtesting/normalization_e2e_test.go (1)

43-50: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Tautological key assertion — doesn't actually verify the stored key.

Key: ops[1].Key compares the operation's key against itself, so this assertion can never fail on a wrong/mismatched key — only Kind/Value/TTL are effectively checked.

♻️ Suggested fix — assert fields independently instead of round-tripping the key
 ops := store.Ops()
 require.Len(t, ops, 2)
-assert.Equal(t, cachetesting.StoreOp{
-    Kind:  "Set",
-    Key:   ops[1].Key,
-    Value: `{"stock":5,"__typename":"Product"}`,
-    TTL:   time.Minute,
-}, ops[1])
+assert.Equal(t, "Set", ops[1].Kind)
+assert.NotEmpty(t, ops[1].Key)
+assert.Equal(t, `{"stock":5,"__typename":"Product"}`, ops[1].Value)
+assert.Equal(t, time.Minute, ops[1].TTL)
🤖 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/normalization_e2e_test.go` around lines 43 - 50, The
StoreOp assertion in normalization_e2e_test is tautological because
cachetesting.StoreOp.Key is compared to ops[1].Key itself, so it never validates
the stored key. Update the assertion around store.Ops() to compare the Set
operation’s Key against an independently constructed expected value or a
separately captured key from the tested code path, and keep the existing checks
on Kind, Value, and TTL in the same assertion.
v2/pkg/engine/cache/observer.go (1)

40-72: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Non-deterministic ShadowCompares ordering from map iteration.

h.ShadowStash is a map[int]ShadowCacheEntry; ranging over it directly means compares (and the resulting CacheTrace.ShadowCompares) gets a randomized item order on every call, since Go intentionally randomizes map iteration order.

♻️ Suggested fix — iterate ShadowStash in deterministic (index) order
+	indices := make([]int, 0, len(h.ShadowStash))
+	for itemIndex := range h.ShadowStash {
+		indices = append(indices, itemIndex)
+	}
+	sort.Ints(indices)
 	compares := make([]resolve.CacheShadowCompareTrace, 0, len(h.ShadowStash))
-	for itemIndex, entry := range h.ShadowStash {
+	for _, itemIndex := range indices {
+		entry := h.ShadowStash[itemIndex]
 		freshValue := fresh
🤖 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 40 - 72, The
TraceObserver.CompareShadow method is building ShadowCompares by ranging
directly over h.ShadowStash, so the order is randomized each call. Update
CompareShadow to iterate ShadowStash deterministically by sorting the stash keys
(or otherwise walking indexes in order) before appending CacheShadowCompareTrace
entries. Keep the existing freshValue lookup logic tied to h.Items and batch
indices, but make the compare slice order stable before storing it in
o.compares.
docs/caching/reviews/20-art-observability.md (1)

36-39: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Optional: consider a size cap on TraceObserver.compares.

The doc itself flags this cross-request hygiene gap and invites a decision. Given the leak only manifests on a process-killing panic (deferred EndRequest otherwise always drains the entry), a defensive size cap is optional rather than required.

🤖 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/reviews/20-art-observability.md` around lines 36 - 39, Add a
defensive size cap to TraceObserver.compares and enforce it in the
drain-on-observe path inside TraceObserver/observer.go, even though EndRequest
normally clears entries from controller.go. Keep the existing handle-based
controller↔observer contract unchanged, and make the cap logic safe for
cross-request hygiene so stale entries cannot grow unbounded if a
process-killing panic bypasses deferred EndRequest.
v2/pkg/engine/resolve/loader.go (1)

440-455: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Verify BeginRequest never legitimately returns nil.

cacheRequest() keys its lazy-once init purely on l.ctx.requestCache == nil. If CacheController.BeginRequest can legitimately return nil (e.g. a per-request opt-out), this branch re-invokes BeginRequest on every subsequent cache-configured fetch of the same request instead of once, violating the documented "called once per request" contract on CacheController.BeginRequest. cache_noop_test.go's countingCacheController returns nil but never exercises this path (its test has no cache-configured fetch, so cacheRequest() is never reached).

If nil is a valid "opt out for this request" signal, consider a separate started-flag instead of overloading the nil check.

🔍 Possible fix using a dedicated started flag
 func (l *Loader) cacheRequest() RequestCache {
 	if l.ctx.cacheController == nil {
 		return nil
 	}
 	l.dataBuffer.Lock()
 	defer l.dataBuffer.Unlock()
-	if l.ctx.requestCache == nil {
+	if !l.ctx.requestCacheStarted {
 		l.ctx.requestCache = l.ctx.cacheController.BeginRequest(l.ctx)
+		l.ctx.requestCacheStarted = true
 	}
 	return l.ctx.requestCache
 }
🤖 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/loader.go` around lines 440 - 455, cacheRequest()
currently uses l.ctx.requestCache == nil as the only “not started” check, so if
CacheController.BeginRequest can legitimately return nil it will be called
repeatedly for the same request. Update the lazy init in Loader.cacheRequest to
track whether BeginRequest has already been attempted with a separate
started/initialized flag on the request context instead of relying on
requestCache being non-nil, and keep the BeginRequest call itself in that path
so the once-per-request contract is preserved.
docs/caching/specs/2026-06-30-rfc-02-caching-planner.md (1)

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

Add a language hint to the fenced blocks for markdownlint (MD040) compliance.

Flagged by static analysis at Line 142 and Line 398.

📝 Proposed fix
-```
+```text

(apply to both fenced blocks at lines 142 and 398)

Also applies to: 398-398

🤖 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/specs/2026-06-30-rfc-02-caching-planner.md` at line 142, The
fenced markdown blocks in this document are missing a language hint, which
triggers MD040. Update both fenced sections in the caching planner spec to use a
tagged fence (for example, the markdown-safe text hint) so the blocks are
compliant, and apply the same fix to both occurrences identified in the review.

Source: Linters/SAST tools

v2/pkg/engine/plan/cache_provides_data_visitor.go (1)

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

Redundant FieldNameBytes call — reuse the existing fieldName local.

fieldName is already computed at line 103; line 147 recomputes the identical value instead of reusing it.

♻️ Proposed fix
 	if fetchResponseKey != string(fieldName) {
-		field.OriginalName = v.operation.FieldNameBytes(fieldRef)
+		field.OriginalName = fieldName
 	}
🤖 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/cache_provides_data_visitor.go` around lines 141 - 148,
The redundant name conversion in cache_provides_data_visitor.go should be
removed by reusing the existing fieldName local instead of calling
v.operation.FieldNameBytes(fieldRef) again inside the resolve.Field
construction. Update the logic around the field assignment in the
cacheProvidesDataVisitor path so OriginalName is derived from the
already-computed fieldName value when fetchResponseKey differs, keeping the
behavior unchanged while avoiding duplicate work.
v2/pkg/engine/cache/controller.go (1)

349-410: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Redundant per-target lookup in root-field entity reuse.

prepareItemState is called once per in.Items element with the identical lookupItem/templates, repeating the L2 read/parse (and L1 populate) for every merge target of the same fetch instead of computing the shared entity state once and fanning it out. This also causes ServedFromLayer to differ across targets of the same fetch (first: "l2", rest: "l1") purely from that iteration's cache-warming side effect, which will surface as a confusing/incorrect ART trace value for otherwise-identical merge targets.

♻️ Suggested direction
 	lookupItem := entityLookupItem(r.ctx, cfg.KeySpec.EntityKeyMappings)
 
 	tx := in.Arena.Begin()
 	defer tx.Commit()
 
 	// The coverage/selection walks run against the FIELD subtree — the cached
 	// value is the entity, not the whole response.
 	reuseCfg := *cfg
 	reuseCfg.ProvidesData = subtree
 
+	sharedState, missed, itemMustWriteBack := r.prepareItemState(tx, &reuseCfg, templates, lookupItem)
 	items := make([]resolve.ItemCacheState, 0, len(in.Items))
 	missedByItem := make([][]string, 0, len(in.Items))
-	allCovered := true
-	mustWriteBack := false
+	allCovered := true
+	mustWriteBack := itemMustWriteBack
 	for _, item := range in.Items {
-		state, missed, itemMustWriteBack := r.prepareItemState(tx, &reuseCfg, templates, lookupItem)
+		state := sharedState
 		state.Item = item
🤖 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 349 - 410, The root-field
entity reuse path in requestCache.prepareRootFieldEntityReuse is redundantly
recomputing the same shared entity lookup for every in.Items entry via
prepareItemState, which also makes ServedFromLayer vary across identical merge
targets due to cache warming side effects. Compute the shared lookup/state once
for the fetch using the common lookupItem and templates, then fan out copies of
that result to each item while preserving item-specific fields like Item and
EntityMergePath. Make sure the cached decision and ART trace values are derived
from the shared read rather than per-iteration side effects, and keep ShadowMode
behavior applied per item after cloning the shared state.
🤖 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 `@execution/cachingtesting/cachingtesting.go`:
- Around line 49-53: PlanResult.Inputs and PlanResult.Gate currently hide
unknown subgraph names by passing an empty ID through to Fakes, which can make
typos look like successful setup; update both methods to resolve names the same
way as PlanResult.LoadCount by falling back to the literal name when nameToID
misses, or otherwise fail fast using PlanResult’s existing validation path. Keep
the behavior consistent across Inputs, Gate, and LoadCount so unrecognized
subgraph names cannot silently register unusable gates or fetch paths.

In `@execution/cachingtesting/defer_l1_e2e_test.go`:
- Around line 212-225: The concurrency test in defer_l1_e2e_test.go can hang
indefinitely on channel receives if the resolver regresses, so add short
timeout-based selects around the waits on arrived, writer.Flushed, and done in
the relevant test block. Update the defer-l1 e2e test flow near the current
channel ordering checks to fail fast with a clear assertion on timeout instead
of blocking forever, keeping the same expectations around Frames() and the gated
fetch behavior.

In `@v2/pkg/engine/cache/cache_key_template.go`:
- Around line 83-118: The numeric normalization in renderRepresentationValue
still uses the original JSON literal, so equivalent numbers can produce
different cache keys. Update the default scalar handling in
renderRepresentationValue to canonicalize astjson.TypeNumber values into a
normalized numeric representation before returning, instead of re-serializing
with value.MarshalTo(nil), while keeping the object recursion and __typename
skipping behavior unchanged.

In `@v2/pkg/engine/cache/controller.go`:
- Around line 939-975: The writeFetchedValue path in requestCache is labeling
pure backfill item traces as refresh. Update the final item.WriteReason
assignment so it reflects CacheWriteReasonBackfill when all written keys come
from PendingCandidates (for example, when backfillFrom is 0 and no RenderedKeys
exist), and only use CacheWriteReasonRefresh when there was an original rendered
key. Keep the per-key deferSet reason logic unchanged.
- Around line 135-148: The fetchLoadTrace helper currently violates the
fetch-type switch rule by branching on concrete resolve.Fetch implementations.
Update fetchLoadTrace to use the Fetch interface’s tracing accessors instead of
type-switching on *resolve.SingleFetch, *resolve.EntityFetch, and
*resolve.BatchEntityFetch, and keep the nil behavior for tracing-disabled
requests. Also scan nearby cache/controller code for similar concrete fetch
switches and replace them with interface-based access where found.
- Around line 606-674: The multi-key freshness loop in controller.go incorrectly
lets any null cached value set NegativeHit because it only checks
state.FromCache == nil, which is never set for positive hits in this loop, so a
later stale negative sentinel can override a fresher positive candidate. Fix the
hit-selection logic so only the freshest candidate can determine negative-hit
status, or otherwise defer negative-sentinel promotion until after the freshest
candidate is chosen via selectMultiCandidateCacheValue. Use the lookupHit
processing, state.FromCache, NegativeHit, and selectMultiCandidateCacheValue
flow in controller.go to guide the change, and add a regression case in
TestMultiKeyFreshnessRows for a fresh positive plus stale negative sentinel
across sibling `@key` candidates.

In `@v2/pkg/engine/cache/coverage.go`:
- Around line 18-32: The covers function currently treats every field in
resolve.Object.Fields as required, which causes type-conditional fields to fail
coverage for polymorphic results. Update covers to detect and skip fields
guarded by OnTypeNames unless the cached __typename matches, and keep the
recursive checks in coversNode for only the applicable fields. Use the existing
normalizedFieldName, covers, and coversNode logic to locate where to gate the
field lookup before recursing.

In `@v2/pkg/engine/cache/optimize_l1_cache.go`:
- Around line 344-355: The union contribution gate in objectSharesAnyField is
too shallow because it only checks top-level field overlap. Update this helper
to recurse through nested object fields so a provider only counts as
contributing when it shares a field path the consumer actually needs, using
objectSharesAnyField and the existing field lookup helpers to walk nested
selections instead of stopping at the first top-level match.

In `@v2/pkg/engine/cache/partial.go`:
- Around line 99-100: The partial fetch handling in reduced batch processing is
silently skipping short `_entities` responses instead of surfacing a contract
violation. Update the logic around the batch consumption check in partial.go so
the code in the partial reduction path fails with an error when the consumed
entity count does not match the expected fetched count, rather than continuing;
use the relevant fetch/reduction flow and the `_entities` handling branches as
the place to enforce this guard.

In `@v2/pkg/engine/cache/transform.go`:
- Around line 162-168: The response shaping in denormalizeToSelection is
incorrectly reusing the cached object visit/write-back path, which can append
cache-only extras into client-facing values. Update the logic around obj.Visit
and out.Set so that only fields present in the requested selection are
materialized into the response, while any write-back preservation for cached
supersets is handled separately from response construction. Use
denormalizeToSelection and the surrounding Visit/out.Set flow to keep
response-shaped values free of unselected normalized fields.

---

Outside diff comments:
In `@v2/pkg/engine/resolve/node_object.go`:
- Around line 141-158: Field.Copy() is dropping ParentOnTypeNames, so copied
fields lose type-condition metadata used later by resolvable.go. Update the
Field.Copy() return value in node_object.go to preserve ParentOnTypeNames
alongside OnTypeNames and the other copied properties, ensuring copied object
trees remain semantically equivalent.

---

Nitpick comments:
In `@docs/caching/reviews/20-art-observability.md`:
- Around line 36-39: Add a defensive size cap to TraceObserver.compares and
enforce it in the drain-on-observe path inside TraceObserver/observer.go, even
though EndRequest normally clears entries from controller.go. Keep the existing
handle-based controller↔observer contract unchanged, and make the cap logic safe
for cross-request hygiene so stale entries cannot grow unbounded if a
process-killing panic bypasses deferred EndRequest.

In `@docs/caching/specs/2026-06-30-rfc-02-caching-planner.md`:
- Line 142: The fenced markdown blocks in this document are missing a language
hint, which triggers MD040. Update both fenced sections in the caching planner
spec to use a tagged fence (for example, the markdown-safe text hint) so the
blocks are compliant, and apply the same fix to both occurrences identified in
the review.

In `@execution/cachingtesting/normalization_e2e_test.go`:
- Around line 43-50: The StoreOp assertion in normalization_e2e_test is
tautological because cachetesting.StoreOp.Key is compared to ops[1].Key itself,
so it never validates the stored key. Update the assertion around store.Ops() to
compare the Set operation’s Key against an independently constructed expected
value or a separately captured key from the tested code path, and keep the
existing checks on Kind, Value, and TTL in the same assertion.

In `@v2/pkg/engine/cache/controller.go`:
- Around line 349-410: The root-field entity reuse path in
requestCache.prepareRootFieldEntityReuse is redundantly recomputing the same
shared entity lookup for every in.Items entry via prepareItemState, which also
makes ServedFromLayer vary across identical merge targets due to cache warming
side effects. Compute the shared lookup/state once for the fetch using the
common lookupItem and templates, then fan out copies of that result to each item
while preserving item-specific fields like Item and EntityMergePath. Make sure
the cached decision and ART trace values are derived from the shared read rather
than per-iteration side effects, and keep ShadowMode behavior applied per item
after cloning the shared state.

In `@v2/pkg/engine/cache/observer.go`:
- Around line 40-72: The TraceObserver.CompareShadow method is building
ShadowCompares by ranging directly over h.ShadowStash, so the order is
randomized each call. Update CompareShadow to iterate ShadowStash
deterministically by sorting the stash keys (or otherwise walking indexes in
order) before appending CacheShadowCompareTrace entries. Keep the existing
freshValue lookup logic tied to h.Items and batch indices, but make the compare
slice order stable before storing it in o.compares.

In `@v2/pkg/engine/plan/cache_provides_data_visitor.go`:
- Around line 141-148: The redundant name conversion in
cache_provides_data_visitor.go should be removed by reusing the existing
fieldName local instead of calling v.operation.FieldNameBytes(fieldRef) again
inside the resolve.Field construction. Update the logic around the field
assignment in the cacheProvidesDataVisitor path so OriginalName is derived from
the already-computed fieldName value when fetchResponseKey differs, keeping the
behavior unchanged while avoiding duplicate work.

In `@v2/pkg/engine/resolve/loader.go`:
- Around line 440-455: cacheRequest() currently uses l.ctx.requestCache == nil
as the only “not started” check, so if CacheController.BeginRequest can
legitimately return nil it will be called repeatedly for the same request.
Update the lazy init in Loader.cacheRequest to track whether BeginRequest has
already been attempted with a separate started/initialized flag on the request
context instead of relying on requestCache being non-nil, and keep the
BeginRequest call itself in that path so the once-per-request contract is
preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ba065fe5-dc2d-4729-afae-df2deab6fded

📥 Commits

Reviewing files that changed from the base of the PR and between 14011d2 and c729d36.

📒 Files selected for processing (135)
  • docs/caching/CODING_GUIDELINES.md
  • docs/caching/PLAN.md
  • docs/caching/PROGRESS.md
  • docs/caching/reviews/01-representation-variable-extraction.md
  • docs/caching/reviews/02-runtime-contract-and-loader-seam.md
  • docs/caching/reviews/03-planner-wiring-and-engine-config.md
  • docs/caching/reviews/04-test-infrastructure.md
  • docs/caching/reviews/05-provides-data-visitor.md
  • docs/caching/reviews/06-entity-cache-configuration.md
  • docs/caching/reviews/07-entity-l2-controller-core.md
  • docs/caching/reviews/08-multi-key-freshness-reorder.md
  • docs/caching/reviews/09-store-normalization.md
  • docs/caching/reviews/10-batch-entity-caching.md
  • docs/caching/reviews/11-negative-caching.md
  • docs/caching/reviews/12-shadow-mode.md
  • docs/caching/reviews/13-root-field-l2.md
  • docs/caching/reviews/14-root-field-isolation.md
  • docs/caching/reviews/15-entity-cache-reuse.md
  • docs/caching/reviews/16-optimize-l1-pass.md
  • docs/caching/reviews/17-l1-runtime-store.md
  • docs/caching/reviews/18-defer-concurrency-coverage.md
  • docs/caching/reviews/19-partial-fetching.md
  • docs/caching/reviews/20-art-observability.md
  • docs/caching/specs/2026-06-30-rfc-01-appendix-testing-strategy.md
  • docs/caching/specs/2026-06-30-rfc-01-loader-cache-abstraction.md
  • docs/caching/specs/2026-06-30-rfc-02-caching-planner.md
  • docs/caching/specs/2026-06-30-rfc-03-per-root-field-cache-isolation.md
  • docs/caching/tasks/01-representation-variable-extraction.md
  • docs/caching/tasks/02-runtime-contract-and-loader-seam.md
  • docs/caching/tasks/03-planner-wiring-and-engine-config.md
  • docs/caching/tasks/04-test-infrastructure.md
  • docs/caching/tasks/05-provides-data-visitor.md
  • docs/caching/tasks/06-entity-cache-configuration.md
  • docs/caching/tasks/07-entity-l2-controller-core.md
  • docs/caching/tasks/08-multi-key-freshness-reorder.md
  • docs/caching/tasks/09-store-normalization.md
  • docs/caching/tasks/10-batch-entity-caching.md
  • docs/caching/tasks/11-negative-caching.md
  • docs/caching/tasks/12-shadow-mode.md
  • docs/caching/tasks/13-root-field-l2.md
  • docs/caching/tasks/14-root-field-isolation.md
  • docs/caching/tasks/15-entity-cache-reuse.md
  • docs/caching/tasks/16-optimize-l1-pass.md
  • docs/caching/tasks/17-l1-runtime-store.md
  • docs/caching/tasks/18-defer-concurrency-coverage.md
  • docs/caching/tasks/19-partial-fetching.md
  • docs/caching/tasks/20-art-observability.md
  • execution/cachingtesting/art_e2e_test.go
  • execution/cachingtesting/batch_e2e_test.go
  • execution/cachingtesting/cachingtesting.go
  • execution/cachingtesting/cachingtesting_test.go
  • execution/cachingtesting/compose.sh
  • execution/cachingtesting/config.json
  • execution/cachingtesting/defer_l1_e2e_test.go
  • execution/cachingtesting/entity_config_test.go
  • execution/cachingtesting/entity_l2_test.go
  • execution/cachingtesting/entity_reuse_e2e_test.go
  • execution/cachingtesting/graph.yaml
  • execution/cachingtesting/isolation_e2e_test.go
  • execution/cachingtesting/l1_e2e_test.go
  • execution/cachingtesting/multikey_e2e_test.go
  • execution/cachingtesting/negative_e2e_test.go
  • execution/cachingtesting/normalization_e2e_test.go
  • execution/cachingtesting/partial_e2e_test.go
  • execution/cachingtesting/provides_data_test.go
  • execution/cachingtesting/rootfield_e2e_test.go
  • execution/cachingtesting/shadow_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/engine/engine_caching_config_test.go
  • execution/engine/engine_config.go
  • execution/engine/execution_engine.go
  • v2/pkg/engine/cache/cache_key_builder.go
  • v2/pkg/engine/cache/cache_key_builder_test.go
  • v2/pkg/engine/cache/cache_key_template.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_l1_test.go
  • v2/pkg/engine/cache/controller_negative_test.go
  • v2/pkg/engine/cache/controller_rootfield_test.go
  • v2/pkg/engine/cache/controller_shadow_test.go
  • v2/pkg/engine/cache/controller_test.go
  • v2/pkg/engine/cache/coverage.go
  • v2/pkg/engine/cache/entity_reuse_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/multikey.go
  • v2/pkg/engine/cache/multikey_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/cacheconfig.go
  • v2/pkg/engine/plan/cacheconfig/cacheconfig_test.go
  • v2/pkg/engine/plan/configuration.go
  • v2/pkg/engine/plan/datasource_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/postprocess/postprocess.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_transaction.go
  • v2/pkg/engine/resolve/context.go
  • v2/pkg/engine/resolve/fetch.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 thread execution/cachingtesting/cachingtesting.go
Comment thread execution/cachingtesting/defer_l1_e2e_test.go Outdated
Comment thread v2/pkg/engine/cache/cache_key_template.go Outdated
Comment thread v2/pkg/engine/cache/controller.go Outdated
Comment thread v2/pkg/engine/cache/controller.go
Comment thread v2/pkg/engine/cache/controller.go
Comment thread v2/pkg/engine/cache/coverage.go
Comment thread v2/pkg/engine/cache/optimize_l1_cache.go
Comment thread v2/pkg/engine/cache/partial.go Outdated
Comment thread v2/pkg/engine/cache/transform.go
@jensneuse

Copy link
Copy Markdown
Member Author

Re the outside-diff comment and the nitpicks from the review body (addressed in 0e9e572):

  • Field.Copy() drops ParentOnTypeNames — fixed; the copy now carries it alongside OnTypeNames.
  • Tautological key assertion in normalization_e2e_test.go — fixed; the Set op's key is now asserted against the miss-lookup's key (ops[0].Key), which turns the assertion into a real read-key == write-key check.
  • Non-deterministic ShadowCompares ordering — fixed; the observer iterates the shadow stash in sorted item order.
  • Redundant FieldNameBytes call in the provides-data visitor — fixed; reuses the existing local.
  • MD040 fences in RFC-02 — fixed; the two bare fences are tagged text.
  • Size cap on TraceObserver.compares — won't fix for now: entries are drained per handle at EndRequest, which is deferred at every resolve entry, so a leak needs a process-killing panic (in which case the process is gone anyway). Noted as an option in the task-20 reviewer doc if operators want belt-and-braces.
  • BeginRequest returning nil — intentional and supported: a controller may return nil to opt a request out entirely; the loader's lazy cacheRequest treats nil as "no caching for this request" and every hook is gated on the handle. No change.
  • Redundant per-target lookup in root-field entity reuse — acknowledged, deliberately left: a root-field fetch has exactly one merge target today, so the duplicate work is theoretical; hoisting the state across items would need careful write-dedup for no measurable win. We'll revisit if multi-target root fetches appear.

@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.

🧹 Nitpick comments (1)
execution/cachingtesting/loader_bench_test.go (1)

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

Drop the redundant b.ResetTimer() calls. b.Loop() already handles benchmark timing, so these lines in both helpers can be removed.

🤖 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/loader_bench_test.go` around lines 58 - 72, Remove
the redundant benchmark timing reset from benchResolve, since b.Loop() already
manages timing; update the benchResolve helper to drop b.ResetTimer() while
keeping the resolve.New, b.ReportAllocs(), and ResolveGraphQLResponse loop
unchanged. If there is a matching helper in the same benchmark file, apply the
same cleanup there so both benchmark helpers are consistent.
🤖 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.

Nitpick comments:
In `@execution/cachingtesting/loader_bench_test.go`:
- Around line 58-72: Remove the redundant benchmark timing reset from
benchResolve, since b.Loop() already manages timing; update the benchResolve
helper to drop b.ResetTimer() while keeping the resolve.New, b.ReportAllocs(),
and ResolveGraphQLResponse loop unchanged. If there is a matching helper in the
same benchmark file, apply the same cleanup there so both benchmark helpers are
consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae7c090a-e7f8-4d0c-ac43-8211f8df774d

📥 Commits

Reviewing files that changed from the base of the PR and between ab4e81a and cdc8689.

📒 Files selected for processing (13)
  • execution/cachingtesting/art_e2e_test.go
  • execution/cachingtesting/cachingtesting.go
  • execution/cachingtesting/defer_l1_e2e_test.go
  • execution/cachingtesting/entity_config_test.go
  • execution/cachingtesting/isolation_e2e_test.go
  • execution/cachingtesting/loader_bench_test.go
  • v2/pkg/engine/cache/cache_key_template.go
  • v2/pkg/engine/cache/cachetesting/fakes.go
  • v2/pkg/engine/cache/controller.go
  • v2/pkg/engine/cache/partial.go
  • v2/pkg/engine/plan/root_field_isolation_test.go
  • v2/pkg/engine/resolve/fetchtree.go
  • v2/pkg/engine/resolve/loader.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • v2/pkg/engine/cache/partial.go
  • execution/cachingtesting/art_e2e_test.go
  • execution/cachingtesting/cachingtesting.go
  • execution/cachingtesting/defer_l1_e2e_test.go
  • v2/pkg/engine/cache/cache_key_template.go
  • v2/pkg/engine/cache/cachetesting/fakes.go
  • v2/pkg/engine/cache/controller.go

@jensneuse

Copy link
Copy Markdown
Member Author

Re the b.ResetTimer() nitpick: fixed — both redundant calls before b.Loop() removed from the benchmark helpers. The same push also carries a multi-agent review round (four agents, each owning a disjoint package group, with Codex as an independent reviewer and re-reviewer): three runtime bug fixes (null batch elements no longer cached as unconditional negative sentinels; shadow reads no longer populate the shared L1 pre-stash; freshness-ladder TTL reporting realigned), two plan-side fixes (inert policies no longer split planners; mixed-entity-type fetches decline caching), an L1-only write-back panic guard, and harness hardening (typo'd subgraph names now fail tests; all remaining substring asserts converted to full-value pins). Details in the commit message.

@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.

🧹 Nitpick comments (1)
v2/pkg/engine/cache/controller_batch_test.go (1)

168-195: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Reuse a fresh RequestCache for the post-EndRequest lookup.

Sibling subtests (I1-I3) end a request via primeBatch/rc.EndRequest() and then create a brand-new rc := newRC(store) for the follow-up assertions. This subtest instead reuses the same rc after calling EndRequest() on it (Line 180) to check the "next lookup" behavior at Line 192. EndRequest is documented in controller.go as running "once, single-threaded, after the root tree and every defer group have resolved," so calling PrepareFetch on the same instance afterward relies on unspecified behavior rather than proving the entity was never persisted to L2.

Proposed fix
-		decisionB, handleB := prepare(t, rc, cfg, productItem(t, "1"))
+		rcB := NewController(store, nil).BeginRequest(nil)
+		decisionB, handleB := prepare(t, rcB, cfg, productItem(t, "1"))
 		assert.Equal(t, resolve.DecisionFetch, decisionB)
 		assert.False(t, handleB.Items[0].NegativeHit)
🤖 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_batch_test.go` around lines 168 - 195, The
post-`EndRequest` assertion is reusing the same `RequestCache` instance, which
depends on unspecified behavior after request finalization. In
`controller_batch_test.go`, update the `t.Run("[I6] a null batch element writes
nothing...")` flow to create a fresh `rc := newRC(store)` for the follow-up
lookup, matching the sibling subtests and using `PrepareFetch` on the new cache
instance before checking `NegativeHit`. This keeps the test focused on verifying
persistence behavior through `store` rather than reusing finalized state from
the original `RequestCache`.
🤖 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.

Nitpick comments:
In `@v2/pkg/engine/cache/controller_batch_test.go`:
- Around line 168-195: The post-`EndRequest` assertion is reusing the same
`RequestCache` instance, which depends on unspecified behavior after request
finalization. In `controller_batch_test.go`, update the `t.Run("[I6] a null
batch element writes nothing...")` flow to create a fresh `rc := newRC(store)`
for the follow-up lookup, matching the sibling subtests and using `PrepareFetch`
on the new cache instance before checking `NegativeHit`. This keeps the test
focused on verifying persistence behavior through `store` rather than reusing
finalized state from the original `RequestCache`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2e9ae5d-ad53-45ee-b0e2-c3b18ae4b379

📥 Commits

Reviewing files that changed from the base of the PR and between cdc8689 and 50ad32c.

📒 Files selected for processing (21)
  • execution/cachingtesting/cachingtesting.go
  • execution/cachingtesting/defer_l1_e2e_test.go
  • execution/cachingtesting/loader_bench_test.go
  • execution/cachingtesting/partial_e2e_test.go
  • v2/pkg/engine/cache/cachetesting/fakes.go
  • v2/pkg/engine/cache/cachetesting/fakes_test.go
  • v2/pkg/engine/cache/controller.go
  • v2/pkg/engine/cache/controller_batch_test.go
  • v2/pkg/engine/cache/controller_l1_test.go
  • v2/pkg/engine/cache/controller_shadow_test.go
  • v2/pkg/engine/cache/fetch_cache_configurator.go
  • v2/pkg/engine/cache/fetch_cache_configurator_test.go
  • v2/pkg/engine/cache/multikey_test.go
  • v2/pkg/engine/cache/optimize_l1_cache.go
  • v2/pkg/engine/cache/optimize_l1_cache_test.go
  • v2/pkg/engine/plan/cacheconfig/cacheconfig.go
  • v2/pkg/engine/plan/root_field_isolation.go
  • v2/pkg/engine/plan/root_field_isolation_test.go
  • v2/pkg/engine/resolve/cache_config.go
  • v2/pkg/engine/resolve/cache_controller.go
  • v2/pkg/engine/resolve/resolve.go
🚧 Files skipped from review as they are similar to previous changes (13)
  • v2/pkg/engine/cache/optimize_l1_cache_test.go
  • v2/pkg/engine/cache/fetch_cache_configurator_test.go
  • v2/pkg/engine/cache/multikey_test.go
  • execution/cachingtesting/partial_e2e_test.go
  • v2/pkg/engine/cache/cachetesting/fakes_test.go
  • execution/cachingtesting/loader_bench_test.go
  • v2/pkg/engine/cache/optimize_l1_cache.go
  • v2/pkg/engine/cache/controller_l1_test.go
  • v2/pkg/engine/cache/fetch_cache_configurator.go
  • execution/cachingtesting/cachingtesting.go
  • v2/pkg/engine/cache/controller.go
  • execution/cachingtesting/defer_l1_e2e_test.go
  • v2/pkg/engine/cache/cachetesting/fakes.go

@jensneuse

Copy link
Copy Markdown
Member Author

Re the I6 nitpick: fixed — the post-EndRequest lookup now runs on a fresh RequestCache (empty L1, so the assertion proves nothing was persisted to L2), matching the sibling I-rows.

@jensneuse

Copy link
Copy Markdown
Member Author

@coderabbitai full review

1 similar comment
@jensneuse

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@jensneuse

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

Base automatically changed from feat/eng-7770-add-defer-support-part-4 to master July 2, 2026 16:10
jensneuse and others added 13 commits July 7, 2026 14:41
Unified PLAN.md with the maintainer-feedback revision (R2) folded in,
one work-order task file per subtask (tasks/01-20), regenerated
CODING_GUIDELINES.md, PROGRESS.md as the durable execution state,
and the four RFC specs copied as background reference.

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

Problem: the @key -> representation-node builder was unexported in graphql_datasource;
cache-key construction (task 06) needs it, and copying it would create two divergent
@key walkers — the silent key-skew hazard the key model exists to prevent.
Task file: docs/caching/tasks/01-representation-variable-extraction.md (PLAN.md phase 0).

- Pure move of representation_variable.go to v2/pkg/engine/plan/representationvariable
  (new exported package); buildRepresentationVariableNode -> BuildRepresentationVariableNode,
  mergeRepresentationVariableNodes -> MergeRepresentationVariableNodes; visitor and merge
  helpers stay unexported; logic verbatim.
- graphql_datasource refactored in place: both call sites (build + merge in
  buildRepresentationsVariable) now use the exported names; no copy remains.
- Tests moved with the code and extended with one "with entity interface" case
  (entityInterface __typename OnTypeNames remap was previously uncovered).
- No import cycle: representationvariable imports plan; plan does not import it.

Verification: representationvariable + graphql_datasource + plan suites pass;
full v2 suite (40 pkgs) and execution module (5 pkgs) pass; golangci-lint
(v2.5.0, config minus modernize which the local version lacks) reports 0 issues;
gci/gofmt clean.

Reviewer guidance: diff should read as relocation + rename + doc comments, not rewrite;
MergeRepresentationVariableNodes is exported for the datasource only — the task 06
cacheKeyBuilder will NOT call it (multi-key keeps candidates separate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: the loader had no cache abstraction; the controller package and the
planner passes need the runtime OUTPUT contract to exist and be wired as a
strict no-op first, so every later task plugs into stable seams.
Task file: docs/caching/tasks/02-runtime-contract-and-loader-seam.md (PLAN.md phase 0).
Reviewer notes: docs/caching/reviews/02-runtime-contract-and-loader-seam.md
(includes the backfilled reviews/01-*.md per the new per-commit reviewer-doc convention).

- resolve/cache_controller.go: CacheController, RequestCache, Decision,
  PrepareFetchInput, MergeInput (incl. MergePath per D4 and all five write-gate
  signals), CacheObserver, FetchCacheHandle/ItemCacheState/CacheCandidate/
  ShadowCacheEntry.
- resolve/cache_transaction.go: TransactionBeginner + concrete CacheTransaction
  (D2; Begin takes DataBuffer.Lock once, Commit releases; arena ops only as
  methods on a held transaction).
- resolve/cache_config.go: FetchCacheConfig (nil-safe Equals/String),
  CacheKeySpec, CacheKeyCandidate, CacheScope, CacheWriteReason,
  EntityKeyMapping/EntityFieldMapping.
- fetch.go: Cache field on FetchConfiguration/EntityFetch/BatchEntityFetch;
  Fetch interface gains CacheConfig/SetCacheConfig/IsEntityFetch/
  IsBatchEntityFetch (D8 — no switch over concrete fetch types anywhere in the
  new code); nil-safe cache clause in FetchConfiguration.Equals.
- loader.go: cachePrepare/cacheMerge call sites in resolveSingle OUTSIDE the
  phase locks; lazy once-per-request cacheRequest under DataBuffer.Lock;
  full-hit sets skipLoad + fetchSkipped; result carriers response/responseData/
  responseHasErrors assigned where mergeResult already computes them.
- context.go / resolve.go: SetCacheController (mirrors SetAuthorizer),
  idempotent endCacheRequest deferred at all four entry functions, clone resets
  requestCache, Free tears down defensively.
- Tests (dedicated files, full-value assert.Equal): Equals mutation table (every
  field) + P1-P5 dedup rows, exact String renders, fetch polymorphism table,
  observable no-op gates (real loader run; BeginRequest never called), lifecycle
  idempotency + clone isolation.

Verification: full v2 suite (40 pkgs) ok; execution module (5 pkgs) ok;
go test -race ./pkg/engine/resolve ok; golangci-lint (config minus modernize,
absent in local v2.5.0) 0 issues; gci/gofmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: the planner had no caching producer, no policy model, and no
ProvidesData carrier; these must exist as additive wiring that produces NO
config until caching configuration is supplied, with the engine Configuration
as the only public entry point.
Task file: docs/caching/tasks/03-planner-wiring-and-engine-config.md (PLAN.md phase 0).
Reviewer notes: docs/caching/reviews/03-planner-wiring-and-engine-config.md.

- NEW plan/cacheconfig (leaf; imports only time): CachingConfiguration +
  policy structs (no L1/L2 bools, D3) + CacheConfigProvider; the configuration
  itself implements the provider. The first-pass KeySpecs external key input is
  dropped per D10 (keys derive structurally in cacheKeyBuilder, task 06).
- NEW engine/cache (D5/D6): Configurator.ConfigureCaching facade holding the
  single no-op gate; inert skeletons cacheKeyBuilder / fetchCacheConfigurator /
  optimizeL1Cache (bodies land in tasks 06/13/16).
- postprocess: EnableCaching option + three thin ConfigureCaching calls (sync,
  defer incl. every defer-group tree via deferTrees, subscription), each after
  createConcreteSingleFetchTypes and before organizeFetchTree/buildDeferTree.
- plan: Configuration.CacheConfigProviders (keyed by datasource ID);
  dataSourceConfiguration.Caching() accessor seam; cacheProvidesDataVisitor
  skeleton + the gated SECOND filter-free walk in Planner.Plan (D9; never
  re-runs the planning visitor, never rebuilds the plan).
- resolve: Object.HasAliases, Field.OriginalName/CacheArgs (+CacheFieldArg)
  carried through Copy (CacheArgs cloned); GraphQLResponse cacheProvidesData
  side-table + accessors (off the response tree, unreachable by defer Copy).
- execution/engine: Configuration.SetCaching(map[dsID]CachingConfiguration);
  NewExecutionEngine validates IDs (unknown id errors), force-enables FetchInfo
  (DisableIncludeInfo=false), builds providers+federation maps, threads
  postprocess.EnableCaching into every per-execution processor.

Tests: facade no-op gate rows (full-tree equality, providers off/on-but-inert);
provider lookup hit/miss rows for all four policy kinds; P1 gating + second-walk
determinism (plan with inert caching == plan without, pretty-printed); Copy
carrier + clone semantics; engine wiring on/off/unknown-id rows.

Verification: full v2 suite (42 pkgs) ok; execution module (5 pkgs) ok;
golangci-lint (config minus modernize) 0 issues in both modules; gci/gofmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: caching needs provably-composable test subgraphs, drift-proof
real-planner inputs, and a shared fake set, so the loader glue and the
controller reach high coverage without a network — built on the execution
module and the federationtesting approach, with no golden snapshots (D7).
Task file: docs/caching/tasks/04-test-infrastructure.md (PLAN.md phase 0).
Reviewer notes: docs/caching/reviews/04-test-infrastructure.md.

- NEW execution/cachingtesting fixtures: products (multi-key Product upc+sku,
  by-key root fields, sibling root fields, User.favoriteProduct), inventory
  (Product.stock/warehouse — mixed-TTL siblings vs products), reviews (batch
  entities), users (me/user(id)); composed with real wgc (config.json
  committed), cross-validated with rover. The defer fixture gap is closed and
  PINNED: TestDeferSupersetShape proves a defer group whose inventory entity
  fetch selects a strict subset of the initial fetch's selection.
- NEW harness (execution/cachingtesting): Plan(tb, query, caching, responses)
  loads the committed config, wires caching like NewExecutionEngine (via the
  new exported Configuration.PlannerConfig() accessor), runs the REAL planner +
  postprocess (query plans always included for inline plan-shape asserts), and
  swaps transports for fakes; ResolveResponse drives the public sync entry.
  Caching/response keys use subgraph NAMES (translated to ID-named datasources).
- NEW v2/pkg/engine/cache/cachetesting fakes: FakeCacheController,
  FakeRequestCache (normalized Call log incl. MergePath, scripted decisions,
  handle identity), RecordingController, FakeStore (absolute ExpiresAt, ordered
  StoreOp log), GatedDataSource (gate channels), RecordingObserver, FakeRegistry
  + SwapDataSources. No custom clock: TTL/time via testing/synctest only.
- Fetch.SetDataSource added to the Fetch interface (all concrete types): the
  first-pass swap util switched over concrete fetch types — the D8 pattern this
  port removes.
- NOT ported from the first pass: RealishCache/Mode/CacheStage/storeAdapter
  (they need the task-07 controller; would be dead code today) and all goldens.

Tests: no-op e2e baseline (byte-identical with a recording controller set and
caching unconfigured; zero BeginRequests), six fixture smoke rows with complete
response bodies, defer-superset plan pin, fake self-tests (TTL expiry in a
synctest bubble, gate ordering via synctest.Wait, full Call-log round-trip,
registry fallback + load counts).

Verification: wgc + rover composition clean; full v2 suite (43 pkgs) ok;
execution module (6 pkgs) ok; golangci-lint (config minus modernize) 0 issues
in both modules; gci/gofmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: the runtime coverage walk needs, per fetch, the exact field tree the
fetch returns — alias-aware and argument-aware; deriving it from the merged
response tree is lossy (per-fetch attribution lost, arg-blind).
Task file: docs/caching/tasks/05-provides-data-visitor.md (PLAN.md phase A).
Reviewer notes: docs/caching/reviews/05-provides-data-visitor.md.

- Full port of the ProvidesData builder into cacheProvidesDataVisitor (from the
  first-pass port of the OLD builder): entity-boundary reset, __typename dedup,
  inline-fragment OnTypeNames (union/interface expansion with grandparent
  narrowing, interfaceObject remap), alias->OriginalName, sorted CacheArgs
  (variable-bound only; root-operation-field args excluded).
- D9 seam completed in planner.go: the gated SECOND filter-free walk receives
  operation/definition/config/planners/fieldPlanners (complete, because the
  main walk populated fieldPlanners in LeaveField) and attaches the
  map[*FetchInfo]*Object side-table via SetCacheProvidesData, planner IDs
  sorted for determinism. Deviation from the first pass: P1 is NOT additionally
  registered on the main walk (wasted work; reset() discarded it).
- ComputeHasAliases deliberately deferred to task 06 (its first caller).

Tests: fidelity rows ported 1:1 + a NEW inline-fragment row; adversarial rows
beyond the OLD set (irrelevant provider no-leak; partial overlap with per-fetch
trees; empty selections: boundary-only planner => EMPTY tree i.e. zero
coverage, unrouted planner absent); determinism (same op twice, identical
side-tables); execution-module fidelity over REAL plans (full side-table for a
root+batch-entity plan; the DEFERRED inventory fetch owns its {stock} entry).

Verification: full v2 suite (43 pkgs) ok; execution module (6 pkgs) ok;
golangci-lint (config minus modernize) 0 issues; gci/gofmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntity arm)

Problem: entity fetches carried no cache config — no keys, no coverage tree,
no policy; federation @key data must cross into runtime config exactly once,
by value, in one reviewable unit.
Task file: docs/caching/tasks/06-entity-cache-configuration.md (PLAN.md phase A).
Reviewer notes: docs/caching/reviews/06-entity-cache-configuration.md.

- cacheKeyBuilder.buildEntitySpec (the SOLE federation reader): one candidate
  per resolvable @key set via the shared representationvariable builder,
  deterministically ordered by selection-set string, copied out by value.
  HARDENING vs the first pass: candidates whose representation carries no field
  beyond __typename are rejected as malformed (unknown-field @keys silently
  degrade to __typename-only nodes — a cross-entity key-collision hazard);
  zero usable keys => not cached.
- fetchCacheConfigurator entity arm: walks the finished flat trees, and for
  entity/batch-entity fetches (D8 predicates, no type switch — the first-pass
  fetchIsEntity switch was not ported) assembles FetchCacheConfig: L1=true
  (eligibility; task 16 narrows), L2 = TTL>0 || NegativeCacheTTL>0 (D3), scalar
  policy fields, KeySpec, ProvidesData from the task-05 side-table (the tree
  itself, not a copy) with ComputeHasAliases folded in; SetCacheConfig via the
  Fetch interface; nil on no provider / no policy / no key / nil info /
  all-flags-false (the last unreachable for entities, serves task 13).
- resolve.ComputeHasAliases lands with its first caller (deferred from task 05).

Tests: full CacheKeySpec literals (single, composite+nested, MULTIPLE sets
sorted), malformed-candidate skip + all-broken bail, the ALIASING GATE (mutate
source federation after building), builder-node == datasource representation
node; full FetchCacheConfig on entity + batch fetches, zero-TTL L1-only row,
five nil rows; plan-level rows over REAL sync AND defer plans (deferred-group
inventory fetch configured too) + determinism.

Verification: full v2 suite (43 pkgs) ok; execution module (6 pkgs) ok;
golangci-lint (config minus modernize) 0 issues in both modules; gci/gofmt
clean; no freeze/stamp vocabulary in the cache package (D6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jensneuse and others added 26 commits July 7, 2026 14:41
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: the defer x caching interaction is where the first pass could not
complete its proofs — cross-defer-group L1 SERVING was never proven end to
end. The task-04 fixtures carry the required shapes; this lands the proofs
(N1-N4, M3, M5) plus the one runtime fix they flushed out.
Task file: docs/caching/tasks/18-defer-concurrency-coverage.md (PLAN.md phase D).
Reviewer notes: docs/caching/reviews/18-defer-concurrency-coverage.md.

- FLUSHED-OUT FIX: the narrowing pass learns defer-group ANCESTRY as an
  ordering source — ConfigureCaching takes treeParents (postprocess derives
  them from DeferDescriptors.ParentID, mirroring deferTrees) and treeEncloses
  generalizes root-before-defers: ancestor-tree fetches execute before every
  descendant-tree fetch (the resolver resolves a parent group fully before
  its children); SIBLING groups stay unordered. Without it, N2's nested
  consumer (no dependency edge to the deferred provider) was narrowed off.
- Harness: ResolveDeferResponse over a frame-capturing writer (optional
  Flushed signal channel for gate-based frame ordering) + PlanResult.Gate.
- N1+M3: initial-fetch entity serves the DEFERRED group — plan inspected (the
  group really carries a configured same-entity fetch), both frames pinned as
  complete strings, deferred subgraph never hit (tampered canned response),
  zero store ops, ONE BeginRequest across groups. The first-pass carry-forward
  gap is CLOSED.
- N2: a deferred fetch populates L1 served to the NESTED later group —
  ancestry-only ordering (subset-nested @defer is normalized away, so the
  fixture routes through the reviews hop back to the same Product).
- N3: exactly one EndRequest; the single flush carries the initial AND the
  group's L2 writes (all Gets before all Sets, values pinned).
- N4: a SkipFullHit sibling flushes while the other sibling is gated mid-Load
  — pure channel synchronization (synctest bubbles deadlock on engine-lifetime
  goroutines; documented), zero latency dependence.
- M5: a cache-hook error lands in THAT group's completed entry as a frame
  error; the sibling's frame is complete and unaffected. N5/M4 are covered by
  -race across the rows plus the task-02 loader seam tests.

Verification: full execution harness -race clean; v2 cache/postprocess -race
clean; full v2 + execution suites ok; golangci-lint (config minus modernize)
0 issues in both modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: a fetch was all-or-nothing — one uncovered batch item refetched
everything. Partial fetching serves what the cache has, sends the subgraph
ONLY the missing representations, and realigns the reduced response to the
original positions.
Task file: docs/caching/tasks/19-partial-fetching.md (PLAN.md phase E).
Reviewer notes: docs/caching/reviews/19-partial-fetching.md.

LOADER TOUCHES (the sanctioned beyond-seam changes, each explained):
(1) cachePrepare swaps prepared.input for handle.PartialInput on FetchPartial
    (single-flight dedups on the reduced input) and sets res.cachePartial;
(2) the res.cachePartial bool;
(3) mergeResult returns after response/error processing for partial fetches —
    the reduced response is positionally misaligned with batchStats, so the
    cache hook owns the data merge (error rendering/parsing unchanged);
(4) cacheMerge dispatches FetchPartial to OnFetchResult (the task-02 seam sent
    it to OnFetchSkipped, which could splice but never write fetched values).

Cache side (all partial semantics in the separable cache/partial.go):
- filterBatchInput: best-effort representations filtering in bucket order;
  any unexpected shape falls back to a FULL fetch — never a wrong request.
- onPartialBatchResult: ONE hook, one transaction — splice covered buckets
  (identical duties to OnFetchSkipped via the extracted spliceCachedItem,
  incl. the negative splice-nothing rule), realign the reduced _entities in
  fetched-bucket order, merge into every target, write via the extracted
  writeFetchedValue. Dispatches BEFORE the failure gate: a failed partial
  fetch keeps the covered splice and skips only the fetched subset.
- Gates: EnablePartialCacheLoad (entity policies) or PartialBatchLoad
  (root-field policies); shadow wins; all-hit/all-miss degenerate to
  SkipFullHit/Fetch; knob off = byte-for-byte all-or-nothing.
- Per-field partial expiry delivered as mixed-TTL semantics ACROSS fetches
  (per-request query rewriting is out of scope — interpretation documented).
- cachetesting: exact-input recording (RecordInput/Inputs) for the
  "exactly the missing representations" acceptance criterion.

Tests: filterBatchInput byte-exact + fallbacks; partial split with exact
reduced input; realign with full merged targets and fetched-only writes; the
adversarial set (duplicated representations, all-hit, all-miss, single
element); failure-in-fetched-subset; config gate; shadow-wins; e2e batch
partial (recorded subgraph input asserted; canned response only matches a
reduced request) and mixed-TTL expiry (only the expired subgraph refetched);
-race clean; every earlier task's row passes unchanged.

Verification: full v2 + execution suites ok; golangci-lint (config minus
modernize) 0 issues in both modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem: operators need to SEE caching behave — the playground (via ART) must
surface every caching aspect per fetch without the observability concern
leaking back onto the lookup/write surface (the coupling that sank the OLD
implementation at ~461 call sites).
Task file: docs/caching/tasks/20-art-observability.md (PLAN.md phase E, final task).
Reviewer notes: docs/caching/reviews/20-art-observability.md.

- Production cache.TraceObserver: assembles a per-fetch CacheTrace from the
  opaque FetchCacheHandle at EndRequest (single-threaded, no lock, no arena;
  endCacheRequest defers before resolve returns, so the router's trace
  serialization always sees it) and attaches it to the fetch's existing ART
  trace — DataSourceLoadTrace gains the additive "cache" JSON section:
  decision, per-item served_from (l1|l2), rendered keys (hashed under
  HashAnalyticsKeys — full-key xxhash64), exact remaining TTLs, write reasons
  (refresh/backfill), negative hits, pending candidates, shadow compares with
  CacheAge.
- ZERO observer calls outside the controller: registerHandle (centralizing
  four duplicated registration sites) stamps the handle's Trace destination +
  HashAnalyticsKeys; EndRequest calls OnFetchObserved per handle. Accumulation
  rides existing state: ItemCacheState.ServedFromLayer stamped at the serve
  points (cleared by the shadow stash — nothing was served) and WriteReason
  stamped at the write sites; the per-item helpers now take pointers so the
  stamps persist.
- CompareShadow computes compares EAGERLY (nothing arena-owned survives the
  transaction) keyed per handle under the observer's own mutex (one instance,
  many requests); OnFetchObserved drains entries even with tracing off.
- Scope guard: OnEntity/OnFieldValue stay no-ops; export pipelines follow up.

Tests: 8 observer unit rows with COMPLETE CacheTrace asserts (synctest pins
the 40s remaining TTL and 15s CacheAge exactly; HashAnalyticsKeys both ways;
tracing-off drains); ART e2e over real plans (L2 miss->hit, L1 chain hit,
shadow compare, partial batch — complete cache JSON sections pinned with
real-clock TTLs normalized) plus the tracing-off regression row; -race clean;
the untouched full suites are the byte-identical no-op proof.

Verification: full v2 + execution suites ok; golangci-lint (config minus
modernize) 0 issues in both modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… += in a loop

CI's modernize analyzer (stringsbuilder) flags string concatenation in loops;
the local lint config lacks that analyzer, so this surfaced only on CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Negative sentinel authority (CRITICAL finding): only the FRESHEST @key
  candidate decides a negative hit — a staler sentinel on a sibling key no
  longer overrides a fresher positive value, and sentinels never enter the
  positive selection ladder; regression rows added both ways.
- Fetch type-switch removed: resolve.Fetch grew LoadTrace() (implemented by
  the three concrete fetch types + test stubs); the controller and the ART
  e2e helper use it, per CODING_GUIDELINES.
- covers() now gates type-conditioned fields on the cached __typename: a
  concrete-type value no longer misses coverage over a SIBLING type's fields
  (absent __typename stays conservative); unit rows added.
- optimizeL1Cache contribution gate is recursive: nested NAME-only overlap no
  longer counts as contribution; flaw-pin row added.
- Partial batch: a reduced _entities count mismatch is now a surfaced
  contract violation (short and over-long rows added), mirroring the loader's
  own invalidBatchItemCount check.
- writeFetchedValue labels pure-backfill writes as "backfill" in the item
  trace.
- Field.Copy() preserves ParentOnTypeNames (outside-diff finding).
- Harness: Inputs/Gate fall back to the literal subgraph name like LoadCount;
  N4 channel waits carry failsafe timeouts (regression-only, never ordering);
  normalization e2e key assert de-tautologized (write key == read key).
- TraceObserver emits ShadowCompares in deterministic item order.
- Docs/comments: renderRepresentationValue comment now states the ACTUAL
  behavior (number/string unification; 1 vs 1.0 split conservatively — full
  float canonicalization deliberately avoided); RFC-02 bare fences tagged.

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

The key hashes are deterministic (xxhash64 over canonical preimages), so the
whole cache trace sections pin as full-value assert.Equal literals instead of
substring checks — consistent with the other ART rows and the repo's
full-value assertion rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Package-level query/expected consts and response helper funcs (batchQuery,
entityL2Query/Expected/Responses, l1ChainQuery/Expected/Responses,
batchResponses) forced readers to jump around; every test now defines its
query, responses, and expected body immediately before the Plan call and
reuses the locals for repeat requests. Duplication across tests is accepted
by design — each test reads top to bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ops in isolation

Multi-request tests accumulated the fake store's op log, so later assertions
re-listed earlier requests' ops and readers had to diff mentally. FakeStore
gained ResetOps() (clears the LOG, keeps the data); the v2 unit tests use the
existing store.ops = nil idiom. Every cross-request op assertion now states
exactly what THAT request did (e.g. an L2 hit is ONE Get, a full-batch hit is
one Get per entity), and the L1 mode-matrix second request gained a previously
missing exact op assert (sku-hit Get, upc L2 Get, pending-candidate backfill
Set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The isolation, entity-config, and defer-L1 plan tests asserted hand-rolled
per-fetch summary strings (renderFetchIsolation/renderFetchCacheConfigs),
which hid the plan structure. The engine's query-plan printer now emits an
additive 'Cache: {...}' line per fetch when a cache config exists (byte-
identical output when caching is off; the config reads through the Fetch
interface), the harness gained PrettyPlan (initial tree + every defer group,
one string), and every plan assertion pins the COMPLETE pretty-printed plan —
structure, queries, representations, and cache configs in one readable blob.
The two custom renderers are gone. As a side effect the router's query-plan
output (JSON field "cache" + pretty line) now surfaces cache configs to the
playground.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The predicate was covered only indirectly through the plan-level isolation
tests in execution/cachingtesting; these rows pin each gate condition next to
the file: cached query root isolates; no-providers never isolates (the
provable no-op); mutation/subscription/nested parents decline; missing
provider and missing coordinate policy decline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BenchmarkLoader measures every fetch type over real plans with a logging-free
in-memory store (benchStore): entity/batch/root-field/L1-chain, each as
no-cache baseline, steady-state hit, miss+write, and batch-partial — one
resolver and controller across iterations (the router's production mode),
fresh Context per request, b.Loop().

pprof (alloc_space, memprofilerate 512-1024) attributed ~28% of the chain hit
path's allocations to PrepareFetch. Fixes:

- cacheKeyTemplate.render writes the canonical key JSON DIRECTLY to a byte
  buffer instead of building intermediate astjson objects per candidate; the
  preimage bytes are identical to the former marshal form (the pinned key
  hashes in the e2e suites prove parity), render's share dropped from ~11%
  cum to ~4%.
- The controller's four per-handle side maps (configs/prefixes/missedKeys/
  reuseProvides) collapsed into ONE lazily allocated states map with a
  handleState struct — BeginRequest allocates no maps at all now.
- cachetesting.FakeRegistry caps input recording at 16 per datasource (the
  recorder copies only under the cap), removing test-double noise from every
  measurement.

Results (Apple M4 Max, 20000x): cache-attributable overhead on the entity L2
hit path fell from +33 to +18 allocs/op vs the no-cache baseline, the
L1+L2 chain from +139 to +86 (353 -> 292 total allocs/op, -3.2KB/op).
Remaining cache-side costs are inherent (parsing the hit bytes, one map
insert + one state struct per cached fetch); the residual profile is
dominated by loader input rendering and response walking, which predate
caching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every ART subtest now pins the COMPLETE DataSourceLoadTrace of every fetch —
raw input, rendered input, output, single-flight flags, load stats, and the
cache section — as one indented, path-labeled JSON blob per request
(real-clock durations and TTL/age nanos normalized; exact values pinned by
the synctest unit rows). This surfaced a trace gap: a cache full hit skipped
the load without reporting it — cachePrepare now sets Trace.LoadSkipped like
every other skip path, so ART shows load_skipped=true alongside the cache
section's SkipFullHit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four parallel agents each owned a disjoint package group, ran Codex
(gpt codex-cli) as an independent reviewer, fixed valid findings with
fail-first test rows, and had Codex re-review their diffs to a CLEAN verdict.

Runtime core (engine/cache controller/partial/multikey/observer + doubles):
- A null _entities element in a full-batch response was cached as a
  positive-TTL negative sentinel, bypassing the NegativeCacheTTL knob —
  writeFetchedValue now guards TypeNull (aligned with the partial path); I6 row.
- A shadow L2 hit populated the shared L1 BEFORE the stash cleared it, so an
  L1-only sibling policy could serve a never-served probe value — populateL1
  is now gated on !ShadowMode; row pins both reads probing L2.
- Sentinel filtering misaligned the parsed list with FromCacheCandidates,
  making the older-single ladder rung report the wrong SelectedRemainingTTL —
  nil placeholders keep positional alignment; 3-candidate synctest row.
- spliceCachedItem could enqueue L2 writes under L1-only configs (nil-store
  panic at EndRequest) and marshaled unconditionally on pure hits — useL2
  gate + no-duties early return; zero-store-ops row.
- FakeRegistry.LoadCount returns -1 for never-swapped name/path pairs so
  typo'd assertions fail loudly (counters created eagerly at swap).

Plan side (visitor/isolation/configurator/optimize/cacheconfig):
- An INERT root-field policy (no TTL, no shadow) still split planners while
  the configurator dropped its config — plans changed with zero caching
  enabled. Gate now requires cacheconfig.RootFieldCachePolicy.EnablesCaching()
  (TTL > 0 || ShadowMode, the configurator's exact effective-enablement).
- Abstract-path entity fetches can carry MIXED entity types; policy and key
  spec derived from RootFields[0] would mis-key the other types — the entity
  arm now declines caching on mixed TypeNames (mirror of the root-field
  all-or-nothing rule).
- treeEncloses looped forever on a malformed treeParents cycle — walk bounded
  by tree count.

Resolve surfaces: comment/contract fixes only — EndRequest's post-arena-
release heap-data-only contract is now documented at the interface, the
ItemCacheState arena-ownership warning added, guard comments at the three
arena entry defers, the OnFetchSkipped/OnFetchResult partial-dispatch docs
corrected, and the FetchCacheConfig.Equals scope reasoning recorded (dedup
runs before ConfigureCaching; Input identity pins alias/arg structure).

Execution harness: PlanResult fails tests on unknown subgraph names (no more
vacuous LoadCount/Inputs/Gate); translateResponseKeys rejects key collisions;
TestDeferHookErrorIsolation rewritten with gate-ordered, fully pinned frames;
remaining assert.Contains converted to full-value pins; redundant
b.ResetTimer() before b.Loop() removed (CodeRabbit nitpick).

Refuted with reasons (in agent transcripts): failed-partial write-backs of
covered buckets (served-value bytes, per the task-19 contract); partial
count-mismatch "bypass" (validated cache-side both directions); Equals field
coverage (no live trigger); interface-object type-name mirror; CacheArgs
inline-literal precondition; entity-boundary reset (under-reports only).

All fixes verified: both modules test- and race-clean, golangci-lint (config
minus modernize) 0 issues, all 10 loader benchmarks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reusing the finalized instance kept its L1 map alive and relied on
unspecified post-EndRequest behavior; a fresh request proves the missing
entity was never PERSISTED (its empty L1 forces the lookup through L2),
matching the sibling I-rows' structure.

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

ConfigureCaching in the defer arm now runs AFTER organizeFetchTree and
buildDeferTree: the group trees and their ancestry come from the
AUTHORITATIVE DeferTree the resolver executes (collectDeferCachingTrees walks
Single/Sequence/Parallel nodes — a Sequence's first child is the parent
group, Parallel children share their enclosing parent, fetchless groups
attach children to the nearest fetch-bearing ancestor, and the flat Defers
list is the fallback when the tree is not built). This deletes the parallel
deferTreeParents derivation from DeferDescriptors.ParentID — one source of
truth instead of two that could drift.

NEW postprocess-level caching suite (the pipeline had no caching/defer
coverage at this level): the FULL Processor over synthetic plans pins
- the sync arm configuring a converted EntityFetch (full config string),
- the no-op gate (EnableCaching with empty providers produces a plan EQUAL
  to a plain processor's),
- the defer-ancestry semantics end to end through the pipeline: the same
  L1-only provider/consumer pair KEEPS L1 when the consumer's group is
  nested under the provider's and is narrowed off (configs re-nil'd) when
  the groups are unordered siblings,
- collectDeferCachingTrees directly: single root, siblings, nested chains,
  the not-built fallback, and fetchless-parent reattachment.

Test-authoring note baked into the suite: DisableResolveInputTemplates also
disables createConcreteSingleFetchTypes, so the entity fixtures carry a real
ResolvableObjectVariable and run the untrimmed pipeline.

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

The partial-batch path rendered the FULL input, then parsed it back, filtered
the representations array, and re-marshaled (cache/partial.go
filterBatchInput). The filtering now happens while the data is still astjson:

- prepareBatchEntityFetch renders header/separator/footer into their own
  buffers and keeps each UNIQUE representation's rendered SEGMENT (bucket
  order) instead of building the final input inline;
- the cache decision runs on the buckets, and the controller marks the
  still-missing ones on the new opaque handle.BatchFetchKeep (PartialInput is
  gone, and with it the input-shape dependence and its fallback);
- assembleBatchInput then renders the FINAL input exactly once — header +
  kept segments + footer — and runs the undefined-variables post-processing
  and pre-fetch validation on the final bytes. A full fetch assembles ALL
  segments, byte-identical to the old inline build (the batch e2e suite and
  the partial e2e's exact recorded-subgraph-input pin prove both paths).

BenchmarkLoader/batch/partial-hit: 253 -> 208 allocs/op, -2.5KB/op, ~-8% time;
the full-batch paths are unchanged within noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The caching ProvidesData walk reused the finished planningWalker via
ResetVisitors + SetVisitorFilter(nil) and seven field-by-field assignments in
planner.go. The visitor now owns a walk() entry that creates its OWN walker
(the planning walker keeps its visitors and filter untouched), takes all the
state in one call, and runs after every planning walk — fieldPlanners is
complete at that point. planner.go shrinks to a single gated call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The response-level e2e tests drove a hand-rolled plan+resolve pipeline; they
now run the REAL engine end to end (the federation_integration_static_test.go
pattern): a new engine.WithCacheController execution option attaches the
runtime controller per request (the counterpart of Configuration.SetCaching,
which was already wired but had no runtime hook), and the harness gained an
engine half (enginetesting.go) — httptest subgraph doubles with body-routed
canned responses, per-double request counters and exact recorded bodies, over
the committed federation config with datasource URLs rewritten to the doubles.

Converted (14 tests across 10 files): batch, negative, normalization,
multikey, rootfield, entity-reuse, shadow, partial, the entity-L2 and
isolation serving tests, and the three L1 rows. Notable semantics:
- the engine renames user variables during normalization, so canned routing
  matches rendered bodies ("variables":{"a":1}) and per-fetch-path LoadCounts
  became per-rule request counts (same strictness, real transport);
- the partial e2e now pins the COMPLETE reduced subgraph request body
  recorded by the reviews double — the representations-filtering proof over
  real HTTP;
- one engine per caching configuration, reused across requests (production
  plan-cache behavior); TAMPERED responses keep their must-not-serve roles;
- TestEntityL2EndToEnd dropped its LoaderHooks assertions (no engine hook
  option; the C7 contract stays pinned by the resolve-level suites).

Plan()/ResolveResponse remain ONLY for what the engine encapsulates: plan
pins (pretty-printed plans), ART trace internals, defer frames, gated
in-process ordering, and the loader benchmarks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Load stats are HTTP connection-timing noise that only bloats the trace
pins and golden files — the tests never assert on them. ExcludeLoadStats
also drops the redundant per-fetch duration fields at the source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
batchInputAssembly moves to its own file with a single entry point —
assemble(arena, keep) returns the final input bytes — and drops the
info/fetchItem fields the loader already carries via prepared.item.
The loader keeps only orchestration (tracing-skip branch, pre-fetch
validation). Unit tests pin the exact final bytes for every keep shape,
including separator placement and the undefined-variables
post-processing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ART trace extension serializes DURING Resolve, while EndRequest —
where the observer used to assemble each fetch's CacheTrace — runs after
the response has been written. A trace rendered with the response
(extensions.trace) therefore never carried the cache sections.

resolve gains the optional CacheTraceFlusher extension; the request
cache implements FlushTraces() idempotently (per-handle observed flag,
EndRequest reuses the same pass), and both sync resolve paths flush
right before rendering when the trace ships in the response extensions.
Each handle is observed exactly once either way, pinned by
TestFlushTracesBeforeEndRequest.

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

# Conflicts:
#	v2/pkg/engine/resolve/resolve.go
Every executing caching test now runs the federation-integration
pattern: a real ExecutionEngine over httptest subgraph doubles, queries
executed like a client, and full-response assertions. ART tests pin the
entire body including the complete extensions.trace cache sections
(ExecuteTraced), defer tests pin complete frame slices with ordering
driven by gated subgraph responses (ExecuteDefer + SubgraphRule.Gate),
and plan-shape tests pin the queryPlan response extension carrying the
canonical one-line cache configs (ExecutePlanned via the new
WithIncludeQueryPlanInResponse option).

Kept on the plan harness, each with a documented rationale: the
defer-plan pins and ProvidesData tests (deferred-group plans and
plan-internal objects are not client-visible) and the loader benchmarks
(HTTP would drown the hot-path measurements). The orphaned resolve-side
helpers (ResolveDeferResponse, deferFrameWriter, resolveWithContext) are
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ysmolski
ysmolski force-pushed the caching-on-defer-v2 branch from 79d06ea to 10be522 Compare July 7, 2026 11:45
@ysmolski

ysmolski commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

I have rebased it on top of master (with defer)

@ysmolski
ysmolski self-requested a review July 13, 2026 07:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants