From 8425ea0d10bddef47ce4d375c7cffc437bee4466 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Mon, 15 Jun 2026 21:33:01 +0200 Subject: [PATCH 1/4] chore(deps): pin astjson to two-pass-parser primitives Pin github.com/wundergraph/astjson to commit 0d295948 (branch feat/two-pass-parser) in both the v2 and execution modules. This commit provides the StructuralCopy / StructuralCopyWithTransform / Transform / two-pass-parser primitives the entity-caching foundation depends on, and drops the 'changed bool' return from MergeValues / MergeValuesWithPath. Adapt all 9 MergeValues / MergeValuesWithPath call sites to the new (*Value, error) signature. No logic or behavior change. Real packages build (go build ./pkg/...) and vet clean; the pre-existing v2/doc.go package-main quirk is unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) --- execution/go.mod | 2 +- execution/go.sum | 4 ++-- go.work.sum | 5 +--- v2/go.mod | 2 +- v2/go.sum | 24 +++++++++++++++++-- .../grpc_datasource/json_builder.go | 4 ++-- v2/pkg/engine/resolve/loader.go | 8 +++---- v2/pkg/engine/resolve/resolvable.go | 6 ++--- 8 files changed, 36 insertions(+), 19 deletions(-) diff --git a/execution/go.mod b/execution/go.mod index 5d4926d1fb..ad28719968 100644 --- a/execution/go.mod +++ b/execution/go.mod @@ -14,7 +14,7 @@ require ( github.com/sebdah/goldie/v2 v2.7.1 github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.30 - github.com/wundergraph/astjson v1.1.0 + github.com/wundergraph/astjson v1.1.1-0.20260612092327-0d295948520f github.com/wundergraph/cosmo/router v0.0.0-20260611115430-e8a965a40952 github.com/wundergraph/graphql-go-tools/v2 v2.4.4 go.uber.org/atomic v1.11.0 diff --git a/execution/go.sum b/execution/go.sum index c257716c37..49fc5a6197 100644 --- a/execution/go.sum +++ b/execution/go.sum @@ -150,8 +150,8 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLVL040= -github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= +github.com/wundergraph/astjson v1.1.1-0.20260612092327-0d295948520f h1:YICwvdQMAfeqB65Q9aheSgQs9r1jP6pOp9EMg16cy2I= +github.com/wundergraph/astjson v1.1.1-0.20260612092327-0d295948520f/go.mod h1:uHSJv7uowLN/nIPvkTFqUDt1sXk4qQU0KNwHfwfDcQE= github.com/wundergraph/cosmo/router v0.0.0-20260611115430-e8a965a40952 h1:pmrzfmyfaLO+CxIMbFpJGFedijeispdohl17WT7Kxj8= github.com/wundergraph/cosmo/router v0.0.0-20260611115430-e8a965a40952/go.mod h1:GK9C7mTNNpwQLt6zM0joXlNHL/1c5GXB0jIzShojmUg= github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= diff --git a/go.work.sum b/go.work.sum index 20390f9ce0..883474fc5a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -406,8 +406,6 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= @@ -479,8 +477,7 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= github.com/wundergraph/go-arena v0.0.0-20251008210416-55cb97e6f68f h1:5snewyMaIpajTu4wj22L/DgrGimICqXtUVjkZInBH3Y= github.com/wundergraph/go-arena v0.0.0-20251008210416-55cb97e6f68f/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= -github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= +github.com/wundergraph/go-arena v1.2.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.231/go.mod h1:ErOQH1ki2+SZB8JjpTyGVnoBpg5picIyjvuWQJP4abg= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= diff --git a/v2/go.mod b/v2/go.mod index 85f68755a9..0b25415feb 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -28,7 +28,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/vektah/gqlparser/v2 v2.5.30 - github.com/wundergraph/astjson v1.1.0 + github.com/wundergraph/astjson v1.1.1-0.20260612092327-0d295948520f github.com/wundergraph/go-arena v1.3.0 go.uber.org/goleak v1.3.0 golang.org/x/sync v0.20.0 diff --git a/v2/go.sum b/v2/go.sum index c35711724c..7aeb814ffe 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -12,6 +12,7 @@ github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8 github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -22,6 +23,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= @@ -65,6 +67,7 @@ github.com/jensneuse/byte-template v0.0.0-20231025215717-69252eb3ed56/go.mod h1: github.com/jensneuse/diffview v1.0.0 h1:4b6FQJ7y3295JUHU3tRko6euyEboL825ZsXeZZM47Z4= github.com/jensneuse/diffview v1.0.0/go.mod h1:i6IacuD8LnEaPuiyzMHA+Wfz5mAuycMOf3R/orUY9y4= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/kingledion/go-tools v0.6.0 h1:y8C/4mWoHgLkO45dB+Y/j0o4Y4WUB5lDTAcMPMtFpTg= github.com/kingledion/go-tools v0.6.0/go.mod h1:qcDJQxBui/H/hterGb90GMlLs9Yi7QrwaJL8OGdbsms= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -97,11 +100,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/r3labs/sse/v2 v2.8.1 h1:lZH+W4XOLIq88U5MIHOsLec7+R62uhz3bIi2yn0Sg8o= github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -141,18 +146,25 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE= github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLVL040= -github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= +github.com/wundergraph/astjson v1.1.1-0.20260612092327-0d295948520f h1:YICwvdQMAfeqB65Q9aheSgQs9r1jP6pOp9EMg16cy2I= +github.com/wundergraph/astjson v1.1.1-0.20260612092327-0d295948520f/go.mod h1:uHSJv7uowLN/nIPvkTFqUDt1sXk4qQU0KNwHfwfDcQE= github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= +github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -170,15 +182,18 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -193,11 +208,14 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -206,12 +224,14 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/v2/pkg/engine/datasource/grpc_datasource/json_builder.go b/v2/pkg/engine/datasource/grpc_datasource/json_builder.go index 556371d511..531439817c 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/json_builder.go +++ b/v2/pkg/engine/datasource/grpc_datasource/json_builder.go @@ -54,7 +54,7 @@ func (j *jsonBuilder) mergeValues(left *astjson.Value, right resultData) (*astjs if right.kind != CallKindEntity { // No federation index map available - use simple merge // This path is taken for non-federated queries - root, _, err := astjson.MergeValues(j.jsonArena, left, right.response) + root, err := astjson.MergeValues(j.jsonArena, left, right.response) if err != nil { return nil, err } @@ -331,7 +331,7 @@ func (j *jsonBuilder) marshalResponseJSON(message *RPCMessage, data protoref.Mes if field.JSONPath == "" { // Field should be merged into parent object (flattened) - root, _, err = astjson.MergeValues(j.jsonArena, root, value) + root, err = astjson.MergeValues(j.jsonArena, root, value) if err != nil { return nil, err } diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index 6e6c82ef0d..67455cf5bd 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -586,7 +586,7 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson return nil } if len(items) == 1 && res.batchStats == nil { - items[0], _, err = astjson.MergeValuesWithPath(l.jsonArena, items[0], responseData, res.postProcessing.MergePath...) + items[0], err = astjson.MergeValuesWithPath(l.jsonArena, items[0], responseData, res.postProcessing.MergePath...) if err != nil { return errors.WithStack(ErrMergeResult{ Subgraph: res.ds.Name, @@ -612,7 +612,7 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson for batchIndex, targets := range res.batchStats { src := batch[batchIndex] for _, target := range targets { - _, _, mErr := astjson.MergeValuesWithPath(l.jsonArena, target, src, res.postProcessing.MergePath...) + _, mErr := astjson.MergeValuesWithPath(l.jsonArena, target, src, res.postProcessing.MergePath...) if mErr != nil { return errors.WithStack(ErrMergeResult{ Subgraph: res.ds.Name, @@ -633,7 +633,7 @@ func (l *Loader) mergeResult(fetchItem *FetchItem, res *result, items []*astjson } for i := range items { - items[i], _, err = astjson.MergeValuesWithPath(l.jsonArena, items[i], batch[i], res.postProcessing.MergePath...) + items[i], err = astjson.MergeValuesWithPath(l.jsonArena, items[i], batch[i], res.postProcessing.MergePath...) if err != nil { return errors.WithStack(ErrMergeResult{ Subgraph: res.ds.Name, @@ -1218,7 +1218,7 @@ func (l *Loader) renderRateLimitRejectedErrors(fetchItem *FetchItem, res *result if err != nil { return err } - errorObject, _, err = astjson.MergeValuesWithPath(l.jsonArena, errorObject, extension, "extensions") + errorObject, err = astjson.MergeValuesWithPath(l.jsonArena, errorObject, extension, "extensions") if err != nil { return err } diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 13695e1e4b..4c57094939 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -159,7 +159,7 @@ func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.Op if err != nil { return err } - r.data, _, err = astjson.MergeValues(r.astjsonArena, r.data, initialValue) + r.data, err = astjson.MergeValues(r.astjsonArena, r.data, initialValue) if err != nil { return err } @@ -179,14 +179,14 @@ func (r *Resolvable) InitSubscription(ctx *Context, initialData []byte, postProc return err } if postProcessing.SelectResponseDataPath == nil { - r.data, _, err = astjson.MergeValuesWithPath(r.astjsonArena, r.data, initialValue, postProcessing.MergePath...) + r.data, err = astjson.MergeValuesWithPath(r.astjsonArena, r.data, initialValue, postProcessing.MergePath...) if err != nil { return err } } else { selectedInitialValue := initialValue.Get(postProcessing.SelectResponseDataPath...) if selectedInitialValue != nil { - r.data, _, err = astjson.MergeValuesWithPath(r.astjsonArena, r.data, selectedInitialValue, postProcessing.MergePath...) + r.data, err = astjson.MergeValuesWithPath(r.astjsonArena, r.data, selectedInitialValue, postProcessing.MergePath...) if err != nil { return err } From 35aae324cdd04fbc97ef8edd4c7050ccceae60fe Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Mon, 15 Jun 2026 21:38:26 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(resolve):=20entity-caching=20foundatio?= =?UTF-8?q?n=20=E2=80=94=20seam=20interfaces=20+=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the minimal, declarations-only foundation for entity caching: the router-facing LoaderCache/CacheEntry L2 backend contract, the engine-internal CacheKeyTemplate/CacheKey seam, EntityCacheInvalidationConfig, and the per-fetch FetchCacheConfiguration skeleton (resolve/cache.go). Nothing implements or calls these yet; loader and resolvable are untouched. Also lands the full re-implementation spec package under docs/entity-caching/: the architecture spec and integration seam, the directive inventory, one spec + ADR per directive, the stacked-PR plans for graphql-go-tools and the router, the astjson dependency spec, and the test/benchmark plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/entity-caching/00-OVERVIEW.md | 100 +++ docs/entity-caching/01-ARCHITECTURE-SPEC.md | 451 ++++++++++ docs/entity-caching/02-DIRECTIVE-INVENTORY.md | 186 ++++ .../03-PR-PLAN-graphql-go-tools.md | 825 ++++++++++++++++++ docs/entity-caching/04-PR-PLAN-router.md | 676 ++++++++++++++ docs/entity-caching/05-ASTJSON-PRIMITIVES.md | 338 +++++++ docs/entity-caching/06-TEST-AND-BENCH-PLAN.md | 823 +++++++++++++++++ docs/entity-caching/07-UNRELATED-FINDINGS.md | 440 ++++++++++ docs/entity-caching/08-EXECUTION-RUNBOOK.md | 492 +++++++++++ docs/entity-caching/adr/0001-foundation.md | 253 ++++++ docs/entity-caching/adr/0002-key.md | 197 +++++ docs/entity-caching/adr/0003-requires.md | 164 ++++ docs/entity-caching/adr/0004-provides.md | 208 +++++ .../entity-caching/adr/0005-request-scoped.md | 180 ++++ .../adr/0006-entity-cache-config.md | 204 +++++ .../adr/0007-root-field-cache-config.md | 227 +++++ .../adr/0008-mutation-cache-config.md | 164 ++++ .../adr/0009-subscription-cache-config.md | 191 ++++ .../directives/entity-cache-config.md | 308 +++++++ docs/entity-caching/directives/key.md | 408 +++++++++ .../directives/mutation-cache-config.md | 429 +++++++++ docs/entity-caching/directives/provides.md | 290 ++++++ .../directives/request-scoped.md | 305 +++++++ docs/entity-caching/directives/requires.md | 299 +++++++ .../directives/root-field-cache-config.md | 335 +++++++ .../directives/subscription-cache-config.md | 348 ++++++++ v2/pkg/engine/resolve/cache.go | 102 +++ 27 files changed, 8943 insertions(+) create mode 100644 docs/entity-caching/00-OVERVIEW.md create mode 100644 docs/entity-caching/01-ARCHITECTURE-SPEC.md create mode 100644 docs/entity-caching/02-DIRECTIVE-INVENTORY.md create mode 100644 docs/entity-caching/03-PR-PLAN-graphql-go-tools.md create mode 100644 docs/entity-caching/04-PR-PLAN-router.md create mode 100644 docs/entity-caching/05-ASTJSON-PRIMITIVES.md create mode 100644 docs/entity-caching/06-TEST-AND-BENCH-PLAN.md create mode 100644 docs/entity-caching/07-UNRELATED-FINDINGS.md create mode 100644 docs/entity-caching/08-EXECUTION-RUNBOOK.md create mode 100644 docs/entity-caching/adr/0001-foundation.md create mode 100644 docs/entity-caching/adr/0002-key.md create mode 100644 docs/entity-caching/adr/0003-requires.md create mode 100644 docs/entity-caching/adr/0004-provides.md create mode 100644 docs/entity-caching/adr/0005-request-scoped.md create mode 100644 docs/entity-caching/adr/0006-entity-cache-config.md create mode 100644 docs/entity-caching/adr/0007-root-field-cache-config.md create mode 100644 docs/entity-caching/adr/0008-mutation-cache-config.md create mode 100644 docs/entity-caching/adr/0009-subscription-cache-config.md create mode 100644 docs/entity-caching/directives/entity-cache-config.md create mode 100644 docs/entity-caching/directives/key.md create mode 100644 docs/entity-caching/directives/mutation-cache-config.md create mode 100644 docs/entity-caching/directives/provides.md create mode 100644 docs/entity-caching/directives/request-scoped.md create mode 100644 docs/entity-caching/directives/requires.md create mode 100644 docs/entity-caching/directives/root-field-cache-config.md create mode 100644 docs/entity-caching/directives/subscription-cache-config.md create mode 100644 v2/pkg/engine/resolve/cache.go diff --git a/docs/entity-caching/00-OVERVIEW.md b/docs/entity-caching/00-OVERVIEW.md new file mode 100644 index 0000000000..c19bca99c8 --- /dev/null +++ b/docs/entity-caching/00-OVERVIEW.md @@ -0,0 +1,100 @@ +# 00 — Overview & Navigation + +> The entry point for this document set. +> Read this first. +> It assumes you have never seen the entity-caching feature and explains why this folder exists, what is in it, and the order to read it in. + +--- + +## 1. The problem + +Entity caching for the GraphQL router was built across **three pull requests** that grew too large to review. +The diffs touch the most sensitive files in the engine — the resolver's `loader` and `resolvable` — and they mix many concerns in one change: +new astjson primitives, +planner passes, +seven distinct caching behaviors, +analytics events, +and router wiring. +A reviewer cannot hold all of that in their head at once, +cannot tell which line traces to which feature, +and cannot approve any single piece in isolation. + +The result is that the work is effectively unmergeable in its current shape, +not because it is wrong, +but because it is unreviewable. + +## 2. The goal + +Re-implement the same feature cleanly as **two fresh feature branches** — one in `graphql-go-tools`, one in the router (cosmo) — each landed as a **stack of small, additive PRs**. + +The guiding principles: + +- **One concern per PR.** + Each directive or caching behavior lands on its own, + against contracts that earlier PRs already made stable. +- **The hot path stays untouched.** + Caching attaches through a small **integration seam** (a handful of interfaces and hook points), + so the existing `loader` and `resolvable` code is left essentially as-is. + This is a hard requirement, not a preference. +- **Foundation first.** + The architecture and the seams land before any directive, + so every later PR is a small leaf change rather than a rewrite. +- **A hard external prerequisite is made explicit.** + The caching layer depends on astjson APIs that exist only on an **unreleased** astjson branch. + Landing and releasing those primitives is the first step of the whole plan, + not an afterthought. + +## 3. The deliverables in this folder + +Read in this order. +Each entry says what the document gives you and when you need it. + +| # | Document | What it gives you | +|---|----------|-------------------| +| 00 | **00-OVERVIEW.md** (this file) | Executive summary and the map of everything else. | +| 01 | [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) | The clean architecture: where caching attaches, the two-level model, the memory invariants, and the **integration seam** (the interfaces and hook points that keep `loader`/`resolvable` untouched). | +| 02 | [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md) | A table of every directive and caching-config concept the feature reads or introduces — what each means, who consumes it, and which PR rebuilds it. | +| — | [directives/<name>.md](./directives/) | One detailed contract spec per directive (linked from the inventory table). | +| — | [adr/0001-foundation.md](./adr/0001-foundation.md) | The foundation decision record: architecture and seams, decided once so later PRs are additive. Plus one ADR per directive at `adr/00NN-.md`. | +| 03 | [03-PR-PLAN-graphql-go-tools.md](./03-PR-PLAN-graphql-go-tools.md) | The stacked-PR plan for the engine repo (`graphql-go-tools`). | +| 04 | [04-PR-PLAN-router.md](./04-PR-PLAN-router.md) | The stacked-PR plan for the router repo (cosmo). | +| 05 | [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) | The astjson dependency contract — the unreleased primitives the foundation needs, and which are required versus ship-along. | +| 06 | [06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md) | The test and benchmark plan that gates each PR. | +| 07 | [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md) | Out-of-scope issues found while reading the original code — documented, deliberately **not** fixed here. | +| 08 | [08-EXECUTION-RUNBOOK.md](./08-EXECUTION-RUNBOOK.md) | The Codex-driven implementation loop that turns the plan into landed PRs. | + +## 4. The architecture in five sentences + +Entity caching is a **two-level cache**: an L1 per-request `map[string]*astjson.Value` on the resolver's Loader that deduplicates identical entity lookups inside one request, plus an external L2 (for example Redis) behind a narrow interface that deduplicates across requests. +Caching attaches at exactly two stages of the `parse → normalize → validate → plan → resolve → response` pipeline — the **planner** annotates each fetch with a cache-key template, a provides-shape, and per-fetch flags, and the **resolver** acts on those annotations to read and write the caches — while everything else stays unaware. +The whole layer plugs into the engine through a small **integration seam**: extracted `LoaderCache` and cache-key interfaces plus a thin `entityCache` collaborator and a single `cacheSkipFetch`/`cacheMustBeUpdated` flag, so the hot `loader` and `resolvable` files are left essentially untouched. +Correctness rests on one memory primitive, astjson's **`StructuralCopy`**, which clones container nodes onto the per-request arena while aliasing scalar leaves — cheap, safe only within the same request, and the basis for keeping cache entries and the live response tree from corrupting one another. +The one true new schema directive is `@requestScoped`, a symmetric per-request coordinate L1 where any field with a shared key can populate the entry and later fields skip their fetch; everything else (`@key`, `@requires`, `@provides`) is existing federation metadata the cache merely *reads*. + +## 5. How to read this package + +If you are reviewing the plan, follow this path: + +1. **Start here (00),** then read [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) end to end. + The architecture spec is load-bearing — every other document assumes its vocabulary (L1, L2, the seam, `StructuralCopy`, `ProvidesData`). +2. **Read [adr/0001-foundation.md](./adr/0001-foundation.md)** to understand *why* the architecture is shaped this way and why the foundation must land before any directive. +3. **Skim [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md)** for the lay of the land, + then dip into individual [directives/<name>.md](./directives/) specs and their matching ADRs only when a given behavior matters to you. +4. **Read [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md)** before either PR plan — the astjson release is the literal first step, and the plans depend on it. +5. **Read the two PR plans** ([03](./03-PR-PLAN-graphql-go-tools.md) for the engine, [04](./04-PR-PLAN-router.md) for the router) to see the concrete stack ordering. +6. **Use [06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md)** as the acceptance bar for each PR, and **[08-EXECUTION-RUNBOOK.md](./08-EXECUTION-RUNBOOK.md)** as the operating procedure for actually doing the work. +7. **Treat [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md)** as a parking lot — issues to be aware of, but explicitly out of scope for this re-implementation. + +A few terms that recur everywhere, defined once so the rest reads smoothly: + +- **L1** — per-request in-memory cache on the Loader, main-thread only, entity fetches only. +- **L2** — external cross-request cache behind the `LoaderCache` interface, root-field and entity fetches. +- **The seam** — the small set of interfaces and hook points through which caching attaches without rewriting the resolver. +- **`StructuralCopy`** — astjson primitive that clones containers on the arena while aliasing leaves; the safety basis for the cache. +- **`ProvidesData`** — the alias-aware field shape a fetch yields, used for cache projection and the field-widening check. +- **`@requestScoped`** — the one new schema directive; a symmetric per-request coordinate L1. + +--- + +> Two repos, two stacks of small PRs, one astjson release that must land first. +> The architecture spec (01) and the foundation ADR (0001) are the two documents to read closely before anything else. diff --git a/docs/entity-caching/01-ARCHITECTURE-SPEC.md b/docs/entity-caching/01-ARCHITECTURE-SPEC.md new file mode 100644 index 0000000000..46212ecde9 --- /dev/null +++ b/docs/entity-caching/01-ARCHITECTURE-SPEC.md @@ -0,0 +1,451 @@ +# Architecture Specification: Entity Caching (Clean Re-implementation) + +> Part of the entity-caching re-implementation document set. +> See [00-OVERVIEW.md](./00-OVERVIEW.md) for the executive summary and navigation, +> [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md) for the directive table, +> [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) for the astjson dependency contract, +> and [adr/0001-foundation.md](./adr/0001-foundation.md) for the foundation decision record. + +## 0. Who this document is for + +This is the load-bearing architecture spec for a from-scratch re-implementation of entity caching in `graphql-go-tools`. +It assumes you have never seen this feature before. +It explains where caching attaches in the engine, +the two-level cache model, +the memory invariants that keep it correct, +and — most importantly — the **Integration Seam**: +the small set of interfaces and hook points that let us add caching while leaving the existing `loader` and `resolvable` code essentially untouched. + +Leaving the existing resolution engine untouched is a **hard requirement**, not a preference. +The central section (§7) specifies exactly what that seam looks like. + +--- + +## 1. The data flow, and where caching attaches + +A GraphQL request moves through six stages: + +```text +parse → normalize → validate → plan → resolve → response +``` + +Caching attaches at exactly **two** of these stages, and nowhere else. + +- **plan** (compile-time, once per operation): the planner decides *what could be cached* and attaches a small, declarative configuration to each fetch. + It does not touch any cache. + It produces a cache-key *template* (how to compute a key), a *provides-shape* (what fields a fetch yields), and per-fetch flags (L2 on/off, TTL, cache name). + This is covered by [03-PR-PLAN-graphql-go-tools.md](./03-PR-PLAN-graphql-go-tools.md) and the per-directive specs under [directives/](./directives/). + +- **resolve** (run-time, once per request): the resolver executes the fetch tree. + This is where caches are actually read and written. + L1 (per-request) is consulted on the main thread before a fetch is dispatched. + L2 (external) is consulted in a single bulk read per cache instance. + After a fetch returns, both caches are populated. + +`parse`, `normalize`, `validate`, and `response` rendering are caching-agnostic and stay untouched. +The cleanest mental model: **the planner annotates, the resolver acts, everything else is unaware.** + +A small, important nuance: between `plan` and `resolve` there is a **post-processing** pass that walks the concrete fetch tree. +One post-process step (the L1 optimizer) is the *only* place that decides whether L1 is actually turned on for a given fetch. +This is described in §6 and in [03-PR-PLAN-graphql-go-tools.md](./03-PR-PLAN-graphql-go-tools.md). + +--- + +## 2. The two-level model (L1 + L2) + +Entity caching has two tiers with different lifetimes, scopes, and storage. + +| Tier | Storage | Lifetime | Scope | Thread model | +|------|---------|----------|-------|--------------| +| **L1** | Plain `map[string]*astjson.Value` on the Loader | One request | Within a single resolution | Main thread only, no locking | +| **L2** | External backend behind the `LoaderCache` interface (Redis, in-memory, etc.) | Cross-request | Shared by all requests | Backend must be concurrency-safe; engine reads in one bulk call on the main thread | + +**Why two tiers.** +A single federated query can ask for the same entity several times along different fetch paths. +L1 deduplicates *within* one request: the first fetch that produces `User:1234` populates L1, +and a later fetch for the same entity reads it back and skips its subgraph call. +L2 deduplicates *across* requests: an entity fetched on request A is served from the external cache on request B without any subgraph call at all. + +**Key principle, shared by both tiers.** +Both L1 and L2 key entities on their `@key` fields only — never on `@requires` fields and never on arbitrary selected fields. +This keeps entity identity stable regardless of what a given query happens to select. +Field arguments are handled separately via a hash suffix (see §4), not by widening the key. + +**What is cached at each tier.** +L1 applies only to **entity fetches** (a nested `_entities` fetch has prior entity data to key on; a root field does not). +L2 applies to both **entity fetches and root-field fetches**. + +**Operation-type behavior** (the resolver relies on this; directive specs in [directives/](./directives/) pin it per directive): + +- **Queries**: L1 → L2 → subgraph, then populate L1 + L2. + A complete L1 hit skips L2 and the goroutine entirely. +- **Mutations**: always skip L2 *reads* (fetch fresh); skip L2 *writes* unless explicitly enabled; optionally delete impacted entity keys. +- **Subscriptions**: per event, either populate L2 with entity data or invalidate (delete) when only `@key` fields are present. + +There is a third, narrow coordinate-L1 mechanism for `@requestScoped` fields. +It rides on the same L1 enable flag and the same copy primitives, +but is logically its own concern; see [directives/requestScoped.md](./directives/requestScoped.md). +This spec treats it as part of the L1 layer, not a separate tier. + +--- + +## 3. Arena allocation and the StructuralCopy invariant + +This is the single most important correctness concept in the whole feature. +Get this wrong and you corrupt cached data silently. + +### 3.1 Arena, in one paragraph + +The engine allocates all `*astjson.Value` nodes on a per-request **arena** — a bump-pointer region freed wholesale at the end of the request, with no GC tracing into it. +Within one request, every value (response data, parsed subgraph bytes, L1 cache entries) shares the same arena lifetime. +The arena is **not** thread-safe, so only the main thread allocates on it. + +### 3.2 The three copy primitives + +The foundation depends on three astjson primitives (full contract in [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md)): + +- **`StructuralCopy(arena, v)`** — clones *container* nodes (objects, arrays) onto the arena while *aliasing* leaf nodes (strings, numbers, bools, nulls) and object keys from the source. + Cheap: one tree walk, no re-parse, no byte round-trip. + Safe **only** when source and destination share the same arena lifetime (same request). + This is the workhorse for every L1 read and write and every cache-into-response merge. + +- **`StructuralCopyWithTransform(arena, v, t)`** — same container-clone/leaf-alias semantics, plus per-field rename / project / passthrough driven by a `Transform`. + This is how alias normalization and argument-aware keys are applied during a copy. + +- **`DeepCopy(arena, v)`** — clones *everything*, including scalar payloads, so the result shares no memory with the source. + Needed only when crossing a heap↔arena boundary. + The foundation uses it in exactly **one** place: isolating per-request `Variables` on the heap. + +### 3.3 The invariant, stated plainly + +`MergeValues(dst, src)` **aliases** nested containers from `src` into `dst`. +So if you merge a cache entry directly into the response tree and a later fetch mutates that part of the response tree, you have just mutated the cache entry. + +The rule that prevents this: + +> Every cache **write** StructuralCopies *into* the cache. +> Every cache **read** StructuralCopies *out* of the cache before merging into the response tree. +> Every **merge-into-existing-L1-entry** uses *working-copy-and-swap*: copy the live entry, merge into the copy, then store the copy (or the fresh value on merge failure) — never mutate the live entry in place, because `MergeValues` is non-atomic on failure and a partial mutation corrupts every sibling key pointing at that entry. + +These copy counts are not negotiable padding; they are the minimum for isolation. +They are pinned by a **Copy Budget** table and four adversarial mutation tests (see [06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md)). +Any change to copy counts must update the table, the invariant tests, and the matching benchmarks together. + +### 3.4 L1 vs L2 projection: the Passthrough switch + +The `Transform` has exactly one switch that distinguishes L1 from L2 copies: `Passthrough`. + +- **L1 (`Passthrough = true`)** — rename aliases to schema names, but **keep all source fields**, including `@key` fields not in the provides-shape and fields accumulated by sibling fetches. + L1 entries grow as more fetches contribute to the same entity within a request. + +- **L2 (`Passthrough = false`)** — rename **and project**: keep only the fields the fetch provides, drop everything else. + L2 entries are minimal and self-contained so they round-trip cleanly across requests. + +L2 writes serialize the normalized value to heap bytes (`MarshalTo`) before handing them to the backend — this is the heap boundary that makes external storage safe. + +--- + +## 4. The cache-key model + +A cache key is a deterministic, byte-identical JSON string. +Byte-identity matters: two requests that should hit the same entry must produce exactly the same bytes. + +Two key shapes exist, produced by two template implementations: + +- **Entity key** — `{"__typename":"User","key":{"id":"123"}}`. + Built from `@key` fields only. + Numbers in keys are coerced to strings so an integer `id` and a string `id` collide on the same entry. + +- **Root-field key** — `{"__typename":"Query","field":"topProducts","args":{...}}`. + Args are sorted alphabetically for determinism. + Optionally, `EntityKeyMappings` rewrite a root-field key into *entity* key shape so a root field and an `_entities` fetch share the same cache entry. + +**Templates are alias-independent.** +The key is computed from `@key` fields, never from response aliases — so the same entity produces the same key regardless of how a query aliases its fields. + +**Field arguments produce a hash suffix, not a wider key.** +A field with arguments gets an xxhash suffix derived from the per-request argument values. +The argument metadata is captured at plan time; the suffix is computed at resolve time because it depends on per-request variables. + +**Key transform pipeline (applied identically on read, write, and delete):** + +```text +GlobalCacheKeyPrefix → subgraph header-hash prefix → L2CacheKeyInterceptor +``` + +Anyone performing manual invalidation must reproduce this exact pipeline, or they target the wrong key. + +The key template is an interface (`CacheKeyTemplate`) but it couples to the arena and astjson — it is an **engine-internal** seam, not a router-facing one. +The router configures keys *declaratively* via `EntityKeyMappings`; it never implements the template. +This is an explicit boundary decision recorded in [adr/0001-foundation.md](./adr/0001-foundation.md). + +--- + +## 5. The four-phase parallel resolution touchpoints + +The resolver executes a fetch tree of `Sequence` / `Parallel` / `Single` nodes. +The parallel path is where caching has the most touchpoints, and where the design discipline matters most. + +**The governing rule: the main thread parses, merges, and runs all cache logic; goroutines do subgraph HTTP only.** +No goroutine ever touches the arena, the L1 map, or a cache backend. + +Phases of `resolveParallel` (existing structure; caching hooks called between/within phases): + +- **Phase 1 — prepare + L1 check (main thread).** + Generate L1 and L2 keys for each fetch. + Check L1; on a complete entity hit, mark the fetch skipped and copy the stored value out via a denormalizing StructuralCopy. + +- **Phase 1.5 — `@requestScoped` injection (main thread).** + Inject coordinate-L1 data when present; skip the fetch if satisfied. + See [directives/requestScoped.md](./directives/requestScoped.md). + +- **Phase 2-L2 — bulk L2 lookup (main thread).** + Group L2-eligible fetches by cache instance, issue **one** `Get` per instance, parse the returned bytes verbatim onto the Loader arena, distribute values back to each fetch, then decide per-fetch whether the L2 hits cover all items. + Failure semantics: a single bulk `Get` error fails *all* fetches in that batch back to the subgraph (acceptable because production backends rarely fail partially; the win is removing a goroutine and an arena per fetch). + +- **Phase 2-HTTP — parallel HTTP (goroutines).** + Only fetches not already satisfied by L1, `@requestScoped`, or L2 run here. + Goroutines return a `[]byte` body; they do not parse or allocate on the arena. + +- **Phase 3 / 3.5 — merge analytics + retry `@requestScoped` (main thread).** + +- **Phase 4 — merge results + populate caches (main thread).** + Parse bodies, merge into the response tree, then `populateL1Cache` / `updateL2Cache` / `exportRequestScopedFields`. + +The single sequential path (`resolveSingle`) collapses the same steps without the bulk batching. + +The crucial design property: **all of this is invoked through a handful of hook points** (§7), so the phase machinery itself does not need rewriting. + +--- + +## 6. The L1 enable decision (post-process) + +The planner attaches a key template to every cacheable fetch but **leaves L1 off** (`UseL1Cache = false`). +A dedicated post-process pass (the L1 optimizer) is the *single source of truth* that flips it on. + +It runs **last** in the fetch-tree processor chain, after concrete `EntityFetch` / `BatchEntityFetch` types are created, because it dispatches on those concrete types. +For each entity fetch it asks two questions: + +- **Can it read?** Does a prior (dependency-ordered) fetch — or the *union* of prior providers of the same entity type — provide all the fields this fetch needs? +- **Can it write?** Is there a later fetch this fetch could populate L1 for? + +`UseL1Cache` becomes `read || write`. +A fetch that can neither read nor write L1 skips key generation, lookup, and populate entirely — pure CPU/memory savings. + +This pass is **self-contained** (it touches only public resolve fetch types) and is **safe to disable** (a no-op leaves L1 off everywhere). +That property makes it an ideal independent PR. +Note the coupling for re-implementers: if you skip this pass, L1 is effectively off even though templates are present. +Decide the default deliberately and document it (see open question in [adr/0001-foundation.md](./adr/0001-foundation.md)). + +--- + +## 7. THE INTEGRATION SEAM (central section) + +This section is the heart of the spec. +It defines the minimal, clean surface that lets us add caching while leaving the existing `loader` and `resolvable` mostly untouched. + +### 7.1 Design goal, restated as a constraint + +- The existing fetch-tree walker, the four-phase parallel machinery, two-pass rendering, and error/null-bubbling logic **must not be rewritten**. +- Caching enters through **named hook functions** invoked at existing seams, plus **a few state fields** on the Loader and per-fetch result. +- The set of cross-boundary **interfaces** is tiny: one cache backend interface, one key-template interface, one analytics sink. Everything else is plain data. + +### 7.2 The control-flow seam: hooks, not rewrites + +The Loader's resolution flow gains caching by calling these hook functions at points that already exist. +Each is a method on `*Loader`; none changes the *shape* of the existing flow: + +- **`prepareCacheKeys(...)`** — once per fetch, before dispatch: render L1/L2 keys from the fetch's template + input items. +- **`tryL1CacheLoad(...)`** — main-thread L1 read; returns "skip this fetch" on a complete hit. +- **`bulkL2Lookup(...)`** (parallel) / **`tryL2CacheLoad(...)`** (single) — L2 read; returns "skip this fetch" per fetch when hits cover all items. +- **`mergeResult(...)`** — *unchanged in shape*: it already merges fetched data into the response tree; the only addition is that it honors `cacheSkipFetch` (merge the cached value instead of a subgraph body) and records negative-cache hits. +- **`populateL1Cache(...)` / `updateL2Cache(...)`** — after merge: write both caches using the copy primitives of §3. +- **`tryRequestScopedInjection(...)` / `exportRequestScopedFields(...)`** — coordinate-L1 read/write for `@requestScoped`. + +The funnel point is `mergeResult`. +A single boolean pair carries cache state into it: + +- **`cacheSkipFetch`** — this fetch was fully satisfied by L1 or L2; merge the cached value instead of dispatching. +- **`cacheMustBeUpdated`** — this fetch must (re)write L2 after merge (a miss, a partial hit, a backfill, or a forced refresh). + +These two booleans live on the per-fetch `result` and are the *entire* contract between the cache hooks and the existing merge path. +That is what keeps the seam small. + +### 7.3 The state seam: additive fields + +Caching adds state without restructuring existing types: + +- On the **Loader**: the named L2 cache registry (`map[string]LoaderCache`), the L1 map (`map[string]*astjson.Value` on the request arena), a reusable `astjson.Parser`, reusable `Transform` slabs, and the per-subgraph invalidation config map. +- On the per-fetch **result**: `cacheSkipFetch`, `cacheMustBeUpdated`, the rendered L1/L2 keys, and accumulated analytics/trace attachments. +- On the **Context** (per request): `ExecutionOptions.Caching` (the toggles of §9). + +No existing field changes meaning. +The `resolvable` two-pass walk gains only *read-only* analytics hooks during the print pass (entity source, field hashing) — it is otherwise untouched. + +### 7.4 The cache backend interface (router-facing) + +This is the one interface the router actually implements (Redis, in-memory, circuit-breaker decorator). +Contract level — tiny signatures only: + +```go +type LoaderCache interface { + Get(ctx context.Context, keys []string) ([]*CacheEntry, error) // result aligns 1:1 with keys; nil = miss + Set(ctx context.Context, entries []*CacheEntry) error // TTL is per-entry, not a Set argument + Delete(ctx context.Context, keys []string) error +} + +type CacheEntry struct { + Key string + Value []byte // opaque JSON payload + TTL time.Duration // write expiration: 0 = backend default, negative = indefinite + RemainingTTL time.Duration // set by backend on read (0 = unknown) + WriteReason CacheWriteReason // engine-set: refresh | backfill | derived | "" +} +``` + +Notes that the re-implementation must honor (these correct stale documentation): + +- `Set` takes **per-entry TTL**, not a `ttl` argument. This lets one `Set` mix regular and negative-cache TTLs. +- `Get` must return a slice the **same length as `keys`**, with `nil` for misses. +- The backend should be **concurrency-safe** as a forward-compatible contract, even though the engine currently issues bulk reads on the main thread. + +A second, minimal config struct lives next to it so invalidation does not pull a `plan` dependency into `resolve`: + +```go +type EntityCacheInvalidationConfig struct { + CacheName string + IncludeSubgraphHeaderPrefix bool +} +``` + +### 7.5 The cache-key template interface (engine-internal) + +The router never implements this; it couples to the arena and astjson. +Contract level: + +```go +type CacheKeyTemplate interface { + RenderCacheKeys(a arena.Arena, ctx *Context, items []*astjson.Value, prefix string) ([]*CacheKey, error) + IsEntityFetch() bool + BatchEntityKeyArgumentPath() []string // nil if no batch support + EntityMergePath(pp PostProcessingConfiguration) []string // nil if it stores full payloads +} +``` + +Two implementations: `EntityQueryCacheKeyTemplate` (entity shape) and `RootQueryCacheKeyTemplate` (root-field shape, optionally entity-mapped). +`RenderCacheKeys` returns `[]*CacheKey`, where each `CacheKey` carries its input `*astjson.Value`, the rendered key strings, an optional `FromCache` value populated on hit, and batch/merge-path bookkeeping. + +[05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) specifies the astjson side; the open question of whether this interface should be exported at all is recorded in [adr/0001-foundation.md](./adr/0001-foundation.md). + +### 7.6 The analytics sink (observability seam) + +Analytics is an opt-in, zero-overhead-when-off collector. +The router reads results through exactly one sanctioned method: + +```go +func (c *Context) GetCacheStats() CacheAnalyticsSnapshot // call once after resolve; releases the pooled collector +``` + +The snapshot is a plain struct of event slices (`L1Reads`, `L2Reads`, `L1Writes`, `L2Writes`, `FetchTimings`, `ShadowComparisons`, `MutationEvents`, `EntityTypes`, `FieldHashes`, `HeaderImpactEvents`, `CacheOpErrors`) plus convenience derivations (hit rates, bytes served). +When analytics is disabled, the snapshot is empty and the collector imposes no cost. +The re-implementation should treat `GetCacheStats()` as the only contract and keep the per-event `Record*` methods internal unless a concrete external consumer is confirmed (see open questions in [adr/0001-foundation.md](./adr/0001-foundation.md)). + +### 7.7 The trace seam (per-fetch diagnostics) + +Cache tracing is gated on `Context.TracingOptions.Enable && !ExcludeCacheStats`. +When on, each fetch attaches a `CacheTrace` (L1/L2 enabled, hit/miss counts, durations, per-entity source and byte size, raw keys when not excluded) into the response trace extensions. +This is purely a read-out; it adds no control-flow coupling beyond setting fields on the per-fetch result. + +### 7.8 Why this seam keeps loader/resolvable untouched + +- The walker dispatch (`single` / `sequence` / `parallel`) is unchanged; cache hooks slot into existing phase boundaries. +- `mergeResult` keeps its signature and purpose; it only learns to honor two booleans. +- `resolvable` gains read-only analytics hooks during rendering; its validation and null-bubbling are untouched. +- All cross-boundary contracts are either tiny interfaces (cache, key template, analytics read-out) or plain additive structs. + +--- + +## 8. Trace and analytics: relationship to the seam + +Trace and analytics are siblings, not the same thing: + +- **Analytics** is a per-request aggregate read once via `GetCacheStats()`, for metrics export. +- **Trace** is a per-fetch diagnostic embedded in the response, for the playground / studio. + +Both are **off by default** and gated independently. +Both write only to fields on per-fetch results and a pooled collector — neither participates in control flow. +This separation is why they can ship as their own stacked PRs after the core, with no risk to the resolution path. + +--- + +## 9. Per-request toggles + +All run-time behavior is controlled per request on `Context.ExecutionOptions.Caching`: + +```go +type CachingOptions struct { + EnableL1Cache bool // per-request L1; also gates @requestScoped coordinate L1 + EnableL2Cache bool // external L2 + EnableCacheAnalytics bool // detailed events; off = empty snapshot, zero cost + L2CacheKeyInterceptor L2CacheKeyInterceptor // custom key transform (L2 only) + GlobalCacheKeyPrefix string // prepended to all L2 keys (schema versioning) +} +``` + +The router maps dev/debug headers (`X-WG-Disable-Entity-Cache[-L1|-L2]`) onto these flags, gated on trace/dev mode to prevent production abuse. +Disabling L1 via these flags also disables `@requestScoped` coordinate L1 because it shares `EnableL1Cache`. + +--- + +## 10. What stays untouched + +The re-implementation **must not** rewrite any of the following. +If a change is needed here, it is a red flag that the seam is wrong. + +- The fetch-tree walker and its `single` / `sequence` / `parallel` dispatch. +- The four-phase parallel execution structure and its main-thread/goroutine split. +- `mergeResult`'s signature and core responsibility (parse + merge into the response tree) — it gains only "honor `cacheSkipFetch` / `cacheMustBeUpdated`". +- The `resolvable` two-pass validate-then-render walk, including null bubbling and field authorization (it gains only read-only analytics hooks during the print pass). +- The arena pooling and early-release pattern in `ResolveGraphQLResponse`. +- The `DataSource` and `LoaderHooks` interfaces. +- `parse`, `normalize`, `validate`, and JSON response rendering. + +## 11. What the foundation introduces + +The foundation is the minimal, additive surface that everything else stacks on. + +**Interfaces (the seam):** + +- `LoaderCache` + `CacheEntry` + `EntityCacheInvalidationConfig` — the router-facing L2 backend contract (§7.4). +- `CacheKeyTemplate` + `CacheKey` — the engine-internal key seam (§7.5). +- The analytics read-out: `Context.GetCacheStats()` returning `CacheAnalyticsSnapshot` (§7.6). + +**Additive data shapes:** + +- `FetchCacheConfiguration` on each fetch (L2 flags, TTL, cache name, key template, provides-shape references, request-scoped fields, `UseL1Cache`). +- `FetchInfo.ProvidesData` — the per-fetch field-shape `*Object` that drives normalization, widening, and L1 optimization. +- `KeyField`, `CacheFieldArg`, `ObjectCacheAnalytics`, `MutationEntityImpactConfig` — analytics/key support shapes. +- `CachingOptions` on `Context.ExecutionOptions` (§9) and the caching fields on `ResolverOptions` (`Caches`, `EntityCacheConfigs`, subscription callbacks). + +**Run-time mechanism:** + +- The L1 map + L2 bulk-lookup path on the Loader, the two-boolean merge contract, and the StructuralCopy-based isolation discipline of §3. +- The L1-enable post-process pass of §6. + +**Hard external prerequisite:** + +- The astjson copy/transform/two-pass primitives the foundation leans on (`StructuralCopy`, `StructuralCopyWithTransform`, `Transform` with `Passthrough`, package-level `DeepCopy`, the two-return `MergeValues`) are **unreleased** at the time of writing. + Cutting an astjson release that contains them is **PR #0** of the whole stack. + See [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) and [adr/0001-foundation.md](./adr/0001-foundation.md). + +--- + +## 12. Cross-references + +- Directive table: [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md); per-directive specs: [directives/](./directives/). +- Stacked PR plans: gqtools [03-PR-PLAN-graphql-go-tools.md](./03-PR-PLAN-graphql-go-tools.md), router [04-PR-PLAN-router.md](./04-PR-PLAN-router.md). +- astjson dependency: [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md). +- Test and benchmark plan (incl. Copy Budget): [06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md). +- Out-of-scope findings (onError, service_datasource, planner correctness, harness rewrite, stale-base artifacts): [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md). +- Implementation loop: [08-EXECUTION-RUNBOOK.md](./08-EXECUTION-RUNBOOK.md). +- Foundation decision record: [adr/0001-foundation.md](./adr/0001-foundation.md). diff --git a/docs/entity-caching/02-DIRECTIVE-INVENTORY.md b/docs/entity-caching/02-DIRECTIVE-INVENTORY.md new file mode 100644 index 0000000000..84423541a6 --- /dev/null +++ b/docs/entity-caching/02-DIRECTIVE-INVENTORY.md @@ -0,0 +1,186 @@ +# 02 — Directive & Caching-Concept Inventory + +Reference catalogue of every GraphQL directive and caching configuration concept that the entity-caching feature depends on, reads, or introduces. +Read this document to learn *what each directive means*, *who consumes it*, and *in which re-implementation PR it is rebuilt*. +No prior knowledge of the feature is assumed. + +Sibling documents: +- Overview and navigation: [00-OVERVIEW.md](00-OVERVIEW.md) +- Architecture and the integration seam: [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md) +- Foundation decision record: [adr/0001-foundation.md](adr/0001-foundation.md) +- graphql-go-tools PR plan: [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md) +- Router PR plan: [04-PR-PLAN-router.md](04-PR-PLAN-router.md) +- astjson primitives: [05-ASTJSON-PRIMITIVES.md](05-ASTJSON-PRIMITIVES.md) +- Test and bench plan: [06-TEST-AND-BENCH-PLAN.md](06-TEST-AND-BENCH-PLAN.md) + +--- + +## How to read this inventory + +The entity-caching feature interacts with two families of concept. + +1. **Federation directives.** +These already exist in the schema and the planner. +The feature does not redefine them, but it *reads* them to know how to build cache keys, project cached shapes, and validate field widening. +They are listed here because re-implementing the caching layer means re-consuming them correctly. + +2. **Caching configuration concepts.** +These are *not* schema directives in the wire sense. +They arrive as Go configuration structs (for example `EntityCacheConfiguration`), +typically synthesised by the router from composition output and per-subgraph config. +The one true *new directive* in this family is `@requestScoped`, which is declared in subgraph SDL and validated at composition time. + +Each row links to a per-directive spec under `directives/.md` and a decision record under `adr/00NN-.md`. +The specs describe the contract in detail. +The ADRs record why the contract is shaped the way it is. + +--- + +## 1. Summary table + +| Name | Applies To | Responsibility | Composition Rules (short) | Consumed By | Re-impl PR | +|---|---|---|---|---|---| +| [`@key`](directives/key.md) · [ADR](adr/0002-key.md) | `OBJECT`, `INTERFACE` | Declares the field set that uniquely identifies an entity. Source of the cache-key identity for entity L1/L2. | Repeatable. `fields` is a mandatory `_FieldSet`. Must be resolvable to participate in entity fetches. | Planner key-fields visitor; cache-key builder; entity L1 passthrough (keeps `@key` fields even when not in `ProvidesData`). | PR-CACHE-KEYS | +| [`@requires`](directives/requires.md) · [ADR](adr/0003-requires.md) | `FIELD_DEFINITION` | Declares external fields a resolver needs as input. Excluded from cache projection because it is request-derived, not entity-owned. | `fields` is a mandatory `_FieldSet` referencing `@external` fields on the same type. | Planner required-fields visitor; L1 population logic that explicitly *excludes* `@requires` from the cached shape. | PR-CACHE-PROJECTION | +| [`@provides`](directives/provides.md) · [ADR](adr/0004-provides.md) | `FIELD_DEFINITION` | Declares extra entity fields a subgraph can return inline. Defines the `ProvidesData` shape used for cache projection and field-widening checks. | `fields` is a mandatory `_FieldSet` over the returned entity type. | Planner provides-fields visitor; `ProvidesData *Object` on fetches; L2 projection (`structuralCopyNormalized`); widening validation. | PR-CACHE-PROJECTION | +| [`@requestScoped`](directives/request-scoped.md) · [ADR](adr/0005-request-scoped.md) | `FIELD_DEFINITION` | NEW. Marks fields whose value is identical for the whole request inside one subgraph. Enables a per-request coordinate L1 that lets the first resolved field populate and later fields skip their fetch. | `key: String!` is mandatory. Composition warns when a key appears on only one field in a subgraph (a lone reader is meaningless). | Datasource `ConfigureFetch` (emits one `RequestScopedField` per annotated field); planner `configureFetchCaching`; resolver `requestScopedL1`. | PR-REQUEST-SCOPED | +| [Entity caching config](directives/entity-cache-config.md) · [ADR](adr/0006-entity-cache-config.md) | Entity type (`TypeName`) | Configuration concept, not a wire directive. Binds an entity type to a named cache, a TTL, optional negative-cache TTL, and a serve-stale window. | No schema syntax. Supplied via `SubgraphCachingConfig.EntityCaching`; one config per (subgraph, type). | Planner caching state; resolver entity L1/L2 read/write paths; cache-key construction. | PR-CACHE-CONFIG | +| [Root-field caching config](directives/root-field-cache-config.md) · [ADR](adr/0007-root-field-cache-config.md) | Root field (`Query.field`) | Configuration concept. Caches a whole root-field response and optionally maps it onto entity cache keys via `EntityKeyMapping` so root queries and entity fetches share L2 entries. | No schema syntax. Supplied via `SubgraphCachingConfig.RootFieldCaching`; `EntityKeyMapping` lists the `@key` field ↔ argument-path bindings. | Planner caching state; resolver root-field cache path; batch and partial-fetch optimisation. | PR-CACHE-CONFIG | +| [Mutation cache config](directives/mutation-cache-config.md) · [ADR](adr/0008-mutation-cache-config.md) | Mutation root field | Configuration concept. Per-mutation control: opt-in L2 population for entity fetches triggered by the mutation, plus entity/root-field invalidation on success. | No schema syntax. Supplied via `SubgraphCachingConfig.MutationFieldCaching` and `MutationCacheInvalidation`. Mutations always skip L2 *reads*. | Resolver mutation path; L2 write gate; post-mutation invalidation. | PR-CACHE-INVALIDATION | +| [Subscription population config](directives/subscription-cache-config.md) · [ADR](adr/0009-subscription-cache-config.md) | Subscription root field | Configuration concept. On each event, either populates L2 (event carries fields beyond `@key`) or invalidates the L2 entry (event carries only `@key`). | No schema syntax. Requires BOTH `TypeName` AND `FieldName` set, or the lookup silently no-ops. | Resolver subscription path; L2 populate/invalidate on event. | PR-CACHE-SUBSCRIPTION | + +Re-impl PR identifiers above are logical names. +Their concrete stack ordering lives in [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md) and [04-PR-PLAN-router.md](04-PR-PLAN-router.md). + +--- + +## 2. Per-directive notes + +### `@key` — entity identity +`@key(fields: _FieldSet!)` is the federation primitive that names which fields uniquely identify an entity, for example `@key(fields: "id")` on `type User`. +For caching it is load-bearing twice over. +First, the key field set is the seed for every entity cache key, so two requests asking for the same `User` by the same `id` collide on the same cache entry. +Second, the L1 passthrough projection must *retain* `@key` fields even when the current query did not ask for them, because a later, wider entity fetch needs the key present to merge correctly. +This is why entity L1 uses passthrough rather than a strict projection. +The directive is consumed, never redefined, by the caching layer. +Full contract: [directives/key.md](directives/key.md). +Rationale: [adr/0002-key.md](adr/0002-key.md). + +### `@requires` — request-derived inputs, excluded from cache +`@requires(fields: _FieldSet!)` declares fields a resolver needs as *input* before it can resolve its own field, where those inputs come from another subgraph. +The critical caching rule is exclusionary. +`@requires` data is supplied per request and is not owned by the entity, +so it must never be written into the cached entity shape. +If it leaked into the cache, a later request with different required inputs would read stale, mismatched data. +The re-implementation must reproduce this exclusion when it builds the L1/L2 projected shape. +Full contract: [directives/requires.md](directives/requires.md). +Rationale: [adr/0003-requires.md](adr/0003-requires.md). + +### `@provides` — the projected cache shape +`@provides(fields: _FieldSet!)` lets a subgraph declare that it can return additional entity fields inline on a given field. +For caching, `@provides` is the source of the `ProvidesData *Object` that travels on a fetch. +`ProvidesData` is the alias-aware description of the exact shape the query expects at a fetch location, and it drives three things. +It scopes the L2 projection, which keeps only the provided/listed fields via `structuralCopyNormalized`. +It defines the field-widening check, so a narrow query cannot poison the cache for a wider one. +And it carries the response-side aliases needed to denormalise cached values back into the active response tree. +Full contract: [directives/provides.md](directives/provides.md). +Rationale: [adr/0004-provides.md](adr/0004-provides.md). + +### `@requestScoped` — the one new directive +`directive @requestScoped(key: String!) on FIELD_DEFINITION` is the only directive this feature *introduces*. +Its model is purely symmetric. +Every field annotated with `@requestScoped(key: "X")` in the same subgraph shares one per-request L1 entry keyed `{subgraphName}.X`. +There is no provider/receiver distinction: whichever annotated field resolves first writes the value, and every later field with the same key reads it and may skip its own fetch. +A tiny on-the-wire surface — one struct, `RequestScopedField{FieldName, FieldPath, ProvidesData}` — carries it from planner to resolver. +The resolver keeps a main-thread-only `requestScopedL1 map[string]*astjson.Value`, gated behind the per-request `EnableL1Cache` flag, and injects only after a full field-widening check via `validateItemHasRequiredData`. +Composition makes `key` mandatory and warns when a key is declared on just one field, because a lone reader can never coordinate with a second. +Full contract: [directives/request-scoped.md](directives/request-scoped.md). +Rationale: [adr/0005-request-scoped.md](adr/0005-request-scoped.md). + +### Entity caching config — bind a type to a cache and a TTL +Not a wire directive, this is the `EntityCacheConfiguration` struct: `{TypeName, CacheName, TTL, NegativeCacheTTL, ...}`. +It tells the resolver which named cache backs a given entity type, how long entries live, how long a null result (entity not found) stays negatively cached, and whether stale data may be served within bounds. +Multiple entity types can share a backing cache by reusing the same `CacheName`. +It is supplied per subgraph through `SubgraphCachingConfig.EntityCaching`. +Full contract: [directives/entity-cache-config.md](directives/entity-cache-config.md). +Rationale: [adr/0006-entity-cache-config.md](adr/0006-entity-cache-config.md). + +### Root-field caching config — cache whole root responses, share with entities +`RootFieldCacheConfiguration` caches the response of a root field such as `Query.user`. +Its distinctive piece is `EntityKeyMapping`, which maps the entity's `@key` fields onto the root field's arguments (`FieldMapping{EntityKeyField, ArgumentPath, ArgumentIsEntityKey}`). +With that mapping, a root query `user(id: "1")` and an entity fetch for `User{id:"1"}` resolve to the *same* L2 cache key, so they share data. +When the key argument is a list and marked `ArgumentIsEntityKey`, the engine can build one cache key per element, short-circuit empty lists, and fetch only the missing entities in partial-fetch mode. +Full contract: [directives/root-field-cache-config.md](directives/root-field-cache-config.md). +Rationale: [adr/0007-root-field-cache-config.md](adr/0007-root-field-cache-config.md). + +### Mutation cache config — write-on-mutation and invalidation +Two related structs cover mutations. +`MutationFieldCacheConfiguration{FieldName, EnableEntityL2CachePopulation, TTL}` decides whether entity fetches *triggered by* a mutation are allowed to write to L2; by default they are not, and mutations always skip L2 reads regardless. +`MutationCacheInvalidationConfiguration` describes what to evict from L2 after a mutation succeeds, so downstream queries see fresh data. +The TTL on the mutation config overrides the entity default for those mutation-triggered writes. +Full contract: [directives/mutation-cache-config.md](directives/mutation-cache-config.md). +Rationale: [adr/0008-mutation-cache-config.md](adr/0008-mutation-cache-config.md). + +### Subscription population config — populate or invalidate per event +`SubscriptionEntityPopulationConfiguration` runs on every subscription event. +If the event carries entity fields beyond `@key`, it writes them to L2 so later queries hit cache. +If the event carries only `@key` fields and `EnableInvalidationOnKeyOnly` is set, it deletes the L2 entry instead, evicting stale data. +The mandatory-pair invariant matters: the lookup `FindByTypeAndFieldName` matches on BOTH `TypeName` and `FieldName`, so if `FieldName` is empty the config silently never fires. +The router integration must set `FieldName` on both populate and invalidate paths. +Full contract: [directives/subscription-cache-config.md](directives/subscription-cache-config.md). +Rationale: [adr/0009-subscription-cache-config.md](adr/0009-subscription-cache-config.md). + +--- + +## 3. Dependency ordering + +The specs build on each other. +The chain below is the order in which the directive contracts can be re-implemented and reviewed, and it mirrors the stacked-PR ordering. +Each level assumes everything above it is already correct. + +```text +adr/0001-foundation.md (astjson StructuralCopy + Transform; arena lifetime; L1/L2 layering) + │ + ├─ @key ──────────────► cache-key identity + L1 passthrough (keeps key fields) + │ │ + │ ▼ + ├─ @provides ──────────────► ProvidesData shape + projection + widening check + │ │ + │ ▼ + ├─ @requires ──────────────► exclusion rule (request-derived, never cached) + │ + ▼ +Entity cache config ──────────────► bind type → cache + TTL (needs @key for keys) + │ + ▼ +Root-field cache config ──────────────► whole-response cache + EntityKeyMapping + (needs @key + entity cache config to share entries) + │ + ├─► Mutation cache config (write-on-mutation + invalidation; needs entity + root config) + │ + └─► Subscription population config (per-event populate/invalidate; needs entity cache config) + +@requestScoped ──────────────► coordinate L1 + (needs @provides ProvidesData + foundation StructuralCopy; + independent of L2 / mutation / subscription specs) +``` + +Reading guide for the ordering: + +- **Foundation first.** +Everything depends on the StructuralCopy / Transform primitives and the arena lifetime rules in [adr/0001-foundation.md](adr/0001-foundation.md). +Do not start any directive spec until the foundation contract is settled. + +- **`@key` before everything caching-related.** +Cache keys are derived from key fields, and L1 passthrough must preserve them, so `@key` is the first directive every other spec leans on. + +- **`@provides` before `@requires`.** +`@provides` defines the `ProvidesData` shape; `@requires` is then expressed as the exclusion *from* that shape, so it is easier to specify second. + +- **Config concepts after the projection directives.** +Entity, root-field, mutation, and subscription configs all assume the projected shape and cache-key rules are nailed down. +Entity config comes before root-field config (root-field reuses entity cache keys), and both come before mutation/subscription (which read and invalidate what the other two populate). + +- **`@requestScoped` is a side-branch.** +It needs the foundation and the `@provides` `ProvidesData` machinery for its widening check, but it does not depend on L2, mutation, or subscription specs. +It can be implemented in parallel with the config-concept branch once `@provides` lands. diff --git a/docs/entity-caching/03-PR-PLAN-graphql-go-tools.md b/docs/entity-caching/03-PR-PLAN-graphql-go-tools.md new file mode 100644 index 0000000000..b94856b6b9 --- /dev/null +++ b/docs/entity-caching/03-PR-PLAN-graphql-go-tools.md @@ -0,0 +1,825 @@ +# 03 — Stacked PR Plan (graphql-go-tools) + +> Part of the entity-caching re-implementation plan. +> See [00-OVERVIEW.md](./00-OVERVIEW.md) for navigation, +> [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) for the clean architecture and integration seam, +> [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md) for the directive table, +> [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) for the astjson dependency spec, +> [06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md) for the full test and benchmark plan, +> [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md) for out-of-scope topics, +> [08-EXECUTION-RUNBOOK.md](./08-EXECUTION-RUNBOOK.md) for the implementation loop. +> The router-side stack is in [04-PR-PLAN-router.md](./04-PR-PLAN-router.md). + +This document defines the **graphql-go-tools** (gqtools) PR stack only. +Each PR is sized to be reviewable in **under ~30 minutes**. + +--- + +## 0. Reading guide for someone with no prior context + +Entity caching adds a two-level cache to the federation router. +**L1** is a per-request, in-memory map that deduplicates entity fetches within a single GraphQL operation. +**L2** is an external, cross-request cache (Redis or in-memory) that the router plugs in. +The cache stores JSON fragments of resolved entities and root-field results, +keyed by the entity's `@key` fields (for L1/L2 entity entries) +or by the root-field name plus arguments (for L2 root-field entries). + +The feature lives in three layers of gqtools: + +1. **resolve** (`v2/pkg/engine/resolve`) — the runtime. + It owns the loader that fetches data, the `LoaderCache` backend interface the router implements, + the cache-key templates, the L1 map, the merge logic, the analytics collector, and the cache trace. +2. **plan** (`v2/pkg/engine/plan`) — the planner. + It reads declarative per-subgraph cache config from `FederationMetaData` + and attaches a per-fetch `resolve.FetchCacheConfiguration` plus a `ProvidesData` field-shape tree to every fetch. +3. **datasource + postprocess** — the GraphQL datasource builds the raw cache-key templates; + the `optimizeL1Cache` postprocess pass decides which fetches actually turn L1 on. + +The router-facing entry point is `execution/engine`, +which exposes `WithSubgraphEntityCachingConfigs(...)`. + +There is one **hard external prerequisite**: the foundation depends on astjson primitives +(`StructuralCopy`, `StructuralCopyWithTransform`, `Transform`, package-level `DeepCopy`, +and a breaking 2-return `MergeValues`) that exist **only on the open astjson PR #16 branch**. +They are not in any released astjson tag. +So PR 0 of this stack is "cut + pin a real astjson release". Nothing compiles without it. +Full detail in [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md). + +--- + +## 1. Branching model and how each PR stays mergeable + +### 1.1 The feature branch + +Create one long-lived feature branch off `master`: + +``` +git checkout master +git pull +git checkout -b feat/entity-caching +git push -u origin feat/entity-caching +``` + +> Diff hygiene note: in this worktree the **local** `master` ref is frozen at 2026-02-16 +> and is NOT the same as `origin/master` (2026-06-12). +> Always branch from and diff against `origin/master`. +> See [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md) for why the stale-ref diff overcounts by ~181 files. + +Every PR below targets `feat/entity-caching` as its base (except PR 0, whose first half targets the astjson repo). +The feature branch is merged into `master` only once at the very end, after the full stack lands. + +### 1.2 The stacking discipline + +- PRs stack linearly: PR N branches off PR N-1's branch, not off the feature branch tip. +- Each PR's "Dependencies" field names the single PR it stacks on. +- A PR is merged into `feat/entity-caching` (squash) only after the PR(s) it depends on have merged. + When a dependency merges, rebase the child branch onto the new feature-branch tip before merging it. +- Tools like `gh pr create --base ` keep the review diff scoped to just that PR's changes. + +### 1.3 Why every PR is independently mergeable into the feature branch + +The recurring technique is **additive-then-wired**. +Almost every PR adds new types, new files, or new struct fields that are **inert** until a later PR reads them. +A PR that only adds an unused struct field, an unused interface, or a default-off pass +compiles, passes the existing test suite unchanged, and changes zero runtime behavior. +This is what keeps each PR small AND safe to merge on its own. + +Three concrete levers make this work: + +1. **Default-off flags.** The two behavior-bearing seams default to off: + - per-request `CachingOptions{EnableL1Cache, EnableL2Cache, EnableCacheAnalytics}` all default `false`, + so the loader's caching branches are dead until a request opts in. + - `FetchCacheConfiguration.UseL1Cache` defaults `false`, + set true only by the `optimizeL1Cache` postprocess pass. +2. **Opt-in config.** L2 is per-type/per-field opt-in. + A nil lookup in `FederationMetaData` means "L2 disabled for this coordinate", + so adding the config types and lookups changes nothing until a router populates them. +3. **No-op-safe passes.** The `optimizeL1Cache` pass is gated behind `disableOptimizeL1Cache` + and, until wired, is a no-op that leaves `UseL1Cache` false everywhere. + +The result: you can merge PR 1 through PR N into the feature branch one at a time, +run the full pre-existing test suite after each, and never see a regression, +because the new code paths are not yet reachable at runtime. + +### 1.4 The two "wire it on" PRs + +Two PRs flip behavior on and therefore deserve extra review scrutiny: + +- **The resolve loader-integration PR** (PR 7) — the first PR where `EnableL1Cache`/`EnableL2Cache` + actually change loader behavior. +- **The visitor-wiring PR** (PR 11) — the first PR where the planner emits non-empty cache config. + +Everything before PR 7 is pure data-shape and interface plumbing. + +--- + +## 2. The stack at a glance + +| PR | Title | Layer | Wires behavior? | Stacks on | +|----|-------|-------|-----------------|-----------| +| 0 | astjson release + pin | dep | n/a | (master) | +| 1 | Foundation: interfaces, seams, ADRs, specs | resolve (decl) | no | 0 | +| 2 | astjson Loader copy helpers (transform wrappers) | resolve | no (helpers only) | 1 | +| 3 | Cache-key templates | resolve | no | 1 | +| 4 | Plan-time config structs + lookups | plan | no | 1 | +| 5 | ProvidesData field-tree types + ComputeHasAliases | resolve | no | 1 | +| 6 | Per-request CachingOptions + Context plumbing | resolve | no (flags off) | 1 | +| 7 | Loader L1/L2 read+write integration | resolve | **yes** | 2, 3, 5, 6 | +| 8 | Batch + partial entity cache load | resolve | yes | 7 | +| 9 | Negative cache | resolve | yes | 7 | +| 10 | Cache analytics collector + snapshot | resolve | yes (gated) | 7 | +| 11 | Datasource key-template + visitor caching wiring | plan + datasource | **yes** | 3, 4, 5 | +| 12 | optimizeL1Cache postprocess pass | postprocess | yes (gated) | 5, 11 | +| 13 | Mutation impact (populate + invalidate) | plan + resolve | yes | 7, 11 | +| 14 | Extension-driven invalidation | resolve | yes | 7 | +| 15 | Subscription entity population | plan + resolve | yes | 7, 11 | +| 16 | Shadow mode | resolve + plan | yes | 7, 10 | +| 17 | @requestScoped composition (metadata + lookups) | plan | no | 4 | +| 18 | @requestScoped datasource emission | datasource | no (data only) | 11, 17 | +| 19 | @requestScoped selection-set widening pass | plan | yes | 11, 17 | +| 20 | @requestScoped coordinate-L1 runtime | resolve | yes | 7, 18, 19 | +| 21 | Cache trace + execution/engine config factory | resolve + execution | yes | 7, 11 | + +Test and benchmark PRs are interleaved per the rules in section 4. + +--- + +## 3. The PRs + +For each PR: **Goal**, **Scope**, **Excludes**, **Dependencies**, **Acceptance criteria**, **Reviewer-guide doc**, **Mergeability**. + +--- + +### PR 0 — astjson release and pin + +**Goal:** ship a real astjson tag containing the copy primitives, then pin gqtools to it. + +**Scope:** +- (astjson repo, separate) Land PR #16 / cut a tagged release containing: + `StructuralCopy` + `StructuralCopyWithTransform` as `*Parser` methods, + `Transform` / `TransformEntry` (`Entries`, `ArrayItem`, `Passthrough` — note: NO `Forced` field), + package-level `DeepCopy`, the 2-return `MergeValues` / `MergeValuesWithPath`, + and the existing value constructors + `MarshalTo`. +- (gqtools) Bump `v2/go.mod` and `execution/go.mod` from the current pseudo-version + to the new real tag; confirm `go.work` and `go-arena v1.2.0` agree. + +**Excludes:** any caching code; the `DeepCopyWithTransform`, `CoerceToString`, +and `MarshalToWithTransform`/`ParseBytesWithTransform` surface +(unused by the foundation — do not pull them in just because they exist on the branch). + +**Dependencies:** none (root of the stack). + +**Acceptance criteria:** +- astjson tag exists and is fetchable. +- `go build ./...` succeeds in both modules against the new tag. +- All call sites use the 2-return `MergeValues` form (grep shows no 3-tuple usage). + +**Reviewer-guide doc:** [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) +(documents which primitives the foundation requires vs the ship-along surface to drop, +the breaking `MergeValues` change, and the plan/fill alignment hazard). + +**Mergeability:** pure dependency bump; no behavior change in gqtools. +Merge to the feature branch first so every later PR builds. + +--- + +### PR 1 — Foundation: interfaces, integration seam, ADRs, specs + +**Goal:** define the minimal stable contracts and the integration seam, with NO implementation. + +**Scope:** +- `resolve` package: declare the backend interface `LoaderCache` + (tiny: `Get(ctx, keys) ([]*CacheEntry, error)`, `Set(ctx, entries) error`, `Delete(ctx, keys) error`) + and the `CacheEntry` struct (`Key`, `Value []byte`, `TTL`, `RemainingTTL`, `WriteReason`). +- Declare `EntityCacheInvalidationConfig` (`CacheName`, `IncludeSubgraphHeaderPrefix`) — + deliberately separate from `plan.EntityCacheConfiguration` to keep `resolve` free of a `plan` import. +- Declare the empty `FetchCacheConfiguration` struct skeleton (field set, no behavior) + and the `CacheKeyTemplate` interface signature only. +- Ship all the planning/spec docs: ADRs and the architecture spec. + +**Excludes:** any method bodies, any loader changes, any flag plumbing, any key rendering. +The interface compiles but nothing implements or calls it inside gqtools. + +**Dependencies:** PR 0. + +**Acceptance criteria:** +- `go build ./...` passes. +- No existing test changes (pure addition). +- `LoaderCache` and `CacheEntry` doc comments match the contracts in + [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md). + +**Reviewer-guide doc:** [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md), +[adr/0001-foundation.md](./adr/0001-foundation.md). +The ADR records the foundational decisions: +two-level model, arena lifetime / StructuralCopy isolation invariant, +opt-in L2, default-off L1, and the resolve-must-not-import-plan boundary. + +**Mergeability:** declarations only; zero runtime reach. + +--- + +### PR 2 — Loader astjson copy helpers + +**Goal:** add the four StructuralCopy wrapper helpers the cache read/write paths will use. + +**Scope:** +- `loader_cache_transform.go`: four `Loader` helpers wrapping `p.StructuralCopy` / `p.StructuralCopyWithTransform`: + `structuralCopyNormalized` (L2 write: rename alias→schema + project, `Passthrough=false`), + `structuralCopyDenormalized` (L2 read: schema→alias, project), + `structuralCopyNormalizedPassthrough` (L1 write: rename, keep all fields, `Passthrough=true`), + `structuralCopyDenormalizedPassthrough` (L1 read: restore alias, keep all). +- The ephemeral `*Transform` builders (`buildNormalizeTransform` / `buildDenormalizeTransform`) + over reusable `l.transformEntries` / `l.transforms` slabs. + +**Excludes:** any caller in the loader (helpers are unused dead code until PR 7); +any cache key logic; any flag checks. + +**Dependencies:** PR 1. + +**Acceptance criteria:** +- Unit tests in `loader_cache_transform_test.go` cover all four helpers: + alias normalize/denormalize round-trip, passthrough-keeps-unlisted, project-drops-unlisted, + arena residency of `OutputKey` strings (GC-safety). +- `go test ./v2/pkg/engine/resolve/...` passes; `-race` clean. + +**Reviewer-guide doc:** [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) section "Loader copy helpers". + +**Mergeability:** new file with new methods + their tests; nothing else calls them. + +--- + +### PR 3 — Cache-key templates + +**Goal:** implement the two `CacheKeyTemplate` implementations and the `CacheKey` value type. + +**Scope:** +- `caching.go`: `EntityQueryCacheKeyTemplate{Keys *ResolvableObjectVariable, TypeName}` + → key JSON `{"__typename":"User","key":{"id":"123"}}`; + `RootQueryCacheKeyTemplate{RootFields, EntityKeyMappings}` + → key JSON `{"__typename":"Query","field":"x","args":{...}}` (args sorted alphabetically). +- `NewRootQueryCacheKeyTemplate(rootFields, entityKeyMappings)` constructor (precomputes batch metadata). +- `RenderCacheKeys(a arena.Arena, ctx *Context, items []*astjson.Value, prefix string) ([]*CacheKey, error)` + plus `IsEntityFetch()`, `BatchEntityKeyArgumentPath()`, `EntityMergePath(...)`. +- `EntityKeyMappingConfig` / `EntityFieldMappingConfig`, and `KeyField` + `ParseKeyFields`. +- Number-to-string coercion for entity keys (so int and string `@key` args produce identical keys). + +**Excludes:** wiring templates into any fetch (the planner attaches them in PR 11); +arg-hash suffix application at resolve time (that consumes `Field.CacheArgs`, added with PR 5/11). + +**Dependencies:** PR 1. + +**Acceptance criteria:** +- Table-driven `cache_key_test.go` asserts the FULL `[]*CacheKey` (including `Item` pointer) + for: single/composite/nested/array keys, prefix, missing/null variable → 0 keys, + number coercion parity, root-field no-args/single/multiple/string/bool/array/object/null, + and `EntityKeyMappings` derivation (simple ID, int→string, nested path, array-index, multiple mappings, + partial-missing skips only that key). +- All assertions use `assert.Equal` on full values per repo convention. + +**Reviewer-guide doc:** [directives/key-entity-caching.md](./directives/key-entity-caching.md) +section "Cache key shapes". + +**Mergeability:** self-contained file + tests; templates are constructed only by tests, never by the planner yet. + +--- + +### PR 4 — Plan-time config structs + lookups + +**Goal:** add the declarative per-subgraph cache config carried in `FederationMetaData`. + +**Scope:** +- `plan/federation_metadata.go`: add (with their JSON tags) and their lookup methods: + `EntityCacheConfiguration` (+ `FindByTypeName`), + `RootFieldCacheConfiguration` + `EntityKeyMapping` + `FieldMapping` (+ `FindByTypeAndField`), + `MutationFieldCacheConfiguration` (+ `FindByFieldName`), + `MutationCacheInvalidationConfiguration` (+ `FindByFieldName`), + `SubscriptionEntityPopulationConfiguration` (+ `FindByTypeAndFieldName` — matches BOTH `TypeName` AND `FieldName`). +- Add the corresponding collections to the `FederationMetaData` / `FederationInfo` lookup surface: + `EntityCacheConfig`, `RootFieldCacheConfig`, `MutationFieldCacheConfig`, + `MutationCacheInvalidationConfig`. + +**Excludes:** `RequestScopedField` (PR 17); any consumer of these lookups (visitor reads them in PR 11); +the `execution/engine` `SubgraphCachingConfig` container (PR 21). + +**Dependencies:** PR 1. + +**Acceptance criteria:** +- Pure additive struct + method additions, zero behavior change. +- Unit tests for each lookup: hit, miss (nil), and the `FindByTypeAndFieldName` + both-fields-required footgun (empty `FieldName` must miss). +- `go build ./...` and existing plan tests pass unchanged. + +**Reviewer-guide doc:** [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md) +and the per-directive specs under `directives/` that these config structs back. + +**Mergeability:** additive structs + lookups; nothing populates or reads them yet. + +--- + +### PR 5 — ProvidesData field-tree types + ComputeHasAliases + +**Goal:** add the `Object`/`Field` shape metadata the cache uses for alias-aware normalization. + +**Scope:** +- `resolve/node_object.go`: the cache-relevant fields on `Object` + (`HasAliases`, `CacheAnalytics *ObjectCacheAnalytics`) + and on `Field` (`OriginalName`, `CacheArgs []CacheFieldArg`). +- `CacheFieldArg{ArgName, VariableName}`, `ObjectCacheAnalytics{KeyFields, HashKeys, ByTypeName}`. +- `ComputeHasAliases(*Object) bool` (fast-path flag: true if any field is aliased or has `CacheArgs`). + +**Excludes:** populating the tree (planner does that in PR 11); +the L1 optimizer that walks it (PR 12); any runtime consumption. + +**Dependencies:** PR 1. + +**Acceptance criteria:** +- `ComputeHasAliases` unit tests: no-alias→false, alias→true, CacheArg→true, nested. +- Existing `node_object` behavior unchanged (new fields default zero). + +**Reviewer-guide doc:** [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) section "ProvidesData". + +**Mergeability:** additive struct fields + one pure function; default-zero fields are inert. + +--- + +### PR 6 — Per-request CachingOptions + Context plumbing + +**Goal:** add the per-request toggles, all defaulting off. + +**Scope:** +- `resolve/context.go`: `CachingOptions{EnableL1Cache, EnableL2Cache, EnableCacheAnalytics, + L2CacheKeyInterceptor, GlobalCacheKeyPrefix}` and the `ExecutionOptions.Caching` field. +- `L2CacheKeyInterceptor` func type + `L2CacheKeyInterceptorInfo{SubgraphName, CacheName}`. +- The single heap-mode `astjson.DeepCopy(nil, c.Variables)` site for per-request variable isolation. + +**Excludes:** the `Resolver`-level registry (`Caches`, `EntityCacheConfigs`, subscription callbacks) — +those land with the loader integration in PR 7 where they are first read; +any loader branch that checks the flags (PR 7). + +**Dependencies:** PR 1. + +**Acceptance criteria:** +- All flags default `false`; a request with zero config behaves identically to today. +- Unit test confirms `GetCacheStats()` returns an empty snapshot when `EnableCacheAnalytics` is false + (zero-overhead guarantee). +- `go build ./...` passes. + +**Reviewer-guide doc:** [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) section "Per-request seam". + +**Mergeability:** new fields on an existing options struct; no branch reads them yet. + +--- + +### PR 7 — Loader L1/L2 read+write integration (the first behavior PR) + +**Goal:** make `EnableL1Cache`/`EnableL2Cache` actually cache single (non-batch) entity and root fetches. + +**Scope:** +- `loader.go` / `loader_cache.go`: + the L1 map (`l1Cache map[string]*astjson.Value`, main-thread, entity-only, field-widening), + the bulk L2 lookup (one `Get` per instance batch, batch-failure-falls-through-to-subgraph), + and the `mergeResult` funnel that merges `FromCache` via `MergeValues` with the load-bearing StructuralCopy. +- The working-copy-and-swap pattern for merge-into-existing L1 entries (never mutate a live entry in place). +- L2 write path: `structuralCopyNormalized` then `value.MarshalTo(nil)` to heap bytes for the backend. +- `ResolverOptions` caching fields: `Caches map[string]LoaderCache`, + `EntityCacheConfigs map[string]map[string]*EntityCacheInvalidationConfig`. +- The key transform pipeline (`GlobalCacheKeyPrefix` → subgraph header hash → `L2CacheKeyInterceptor`), + applied identically on read/write/delete. + +**Excludes:** batch/partial loading (PR 8), negative cache (PR 9), analytics (PR 10), +mutation/subscription/extension paths (PR 13/14/15), shadow (PR 16), trace (PR 21), +@requestScoped coordinate L1 (PR 20). + +**Dependencies:** PR 2 (copy helpers), PR 3 (key templates), PR 5 (ProvidesData), PR 6 (options). + +**Acceptance criteria:** +- `l1_cache_test.go`: dedup (same entity fetched twice → 1 fetch), L1 field-widening guard, + UseL1Cache-disabled gate. +- `l1_l2_cache_e2e_test.go` (in-package, mock DataSource): miss-then-hit for both L1 and L2. +- `loader_cache_copy_invariant_test.go`: the 2 invariant tests for the merge sites this PR introduces + (cache-skip-fetch + the L1 merge path) — mutate a nested container post-merge, + assert the cache entry stays intact. +- `-race` clean; with both flags off, all pre-existing loader tests pass byte-identically. + +**Reviewer-guide doc:** [directives/key-entity-caching.md](./directives/key-entity-caching.md) +and the Copy Budget table in the resolve package's CLAUDE.md +(this PR establishes 2 of the 4 budgeted copies). + +**Mergeability:** every new path is guarded by `EnableL1Cache`/`EnableL2Cache`, +which default off; the existing test suite exercises the off path unchanged. + +--- + +### PR 8 — Batch + partial entity cache load + +**Goal:** extend caching to batch entity fetches with partial-hit support. + +**Scope:** +- `loader.go` batch path: all-miss, all-hit, partial-hit (only missed entities refetched when + `EnablePartialCacheLoad`), multi-candidate projection merge, entity splice into batch arrays. +- The 2 remaining StructuralCopy merge sites (batch cache hit + batch partial response). + +**Excludes:** negative cache, analytics, mutation, the root-field smart-backfill nuance (keep minimal). + +**Dependencies:** PR 7. + +**Acceptance criteria:** +- `batch_entity_cache_test.go`: all-miss→all-hit, partial-hit, multi-candidate merge, L2-disabled. +- The 2 remaining `TestCopyInvariant_*` tests (batch cache hit + batch partial response). +- Copy Budget table updated to 4 copies; benches added in the matching bench PR (section 4). + +**Reviewer-guide doc:** [directives/key-entity-caching.md](./directives/key-entity-caching.md) +section "Batch + partial". + +**Mergeability:** batch path only reachable when flags on and the fetch is a batch entity fetch. + +--- + +### PR 9 — Negative cache + +**Goal:** cache null / not-found entities as sentinels when `NegativeCacheTTL > 0`. + +**Scope:** `loader.go` / `loader_cache.go` null-sentinel store/serve/TTL lifecycle; +`CacheKey.NegativeCacheHit`; `EntityCacheConfiguration.NegativeCacheTTL` consumption. + +**Excludes:** analytics emission for negative hits (folded into PR 10). + +**Dependencies:** PR 7. + +**Acceptance criteria:** `negative_cache_test.go` — store/serve/TTL, mutation interaction, +overwrite-after-expiry, nullable-field regression guards. `-race` clean. + +**Reviewer-guide doc:** [directives/key-entity-caching.md](./directives/key-entity-caching.md) +section "Negative cache". + +**Mergeability:** off unless `NegativeCacheTTL > 0` in config; default zero. + +--- + +### PR 10 — Cache analytics collector + snapshot + +**Goal:** add the analytics collector and `GetCacheStats()` snapshot, gated by `EnableCacheAnalytics`. + +**Scope:** +- `cache_analytics.go`: the pooled collector, `CacheAnalyticsSnapshot` and all event types + (`CacheKeyEvent`, `CacheWriteEvent`, `FetchTimingEvent`, `ShadowComparisonEvent`, + `MutationEvent`, `HeaderImpactEvent`, `CacheOperationError`, etc.) and enums. +- The derived-metric convenience methods (`L1HitRate`, `CachedBytesServed`, `EventsByEntityType`, …). +- `ctx.GetCacheStats()` snapshot-and-release semantics (call exactly once). +- Wire the collector record calls into the loader paths added in PR 7–9. + +**Excludes:** shadow comparison events (PR 16), mutation events (PR 13), subscription (PR 15) — +those PRs add their own record calls; this PR ships the collector and the read paths. + +**Dependencies:** PR 7. + +**Acceptance criteria:** +- `cache_analytics_test.go`: collector record/merge, field hashing, entity counts, + derived metrics, snapshot independence (snapshot dedups one event per `(CacheKey,Kind)`). +- With `EnableCacheAnalytics=false`, snapshot is empty and the collector is never allocated. + +**Reviewer-guide doc:** [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) section "Analytics". + +**Mergeability:** gated by `EnableCacheAnalytics`; default off → zero overhead, zero behavior change. + +--- + +### PR 11 — Datasource key-template + visitor caching wiring (the second behavior PR) + +**Goal:** make the planner emit non-empty `FetchCacheConfiguration` and `ProvidesData` per fetch. + +**Scope:** +- `graphql_datasource.go` `ConfigureFetch`: build `CacheKeyTemplate` + (entity: `EntityQueryCacheKeyTemplate` over `@key`-only fields; root: `NewRootQueryCacheKeyTemplate`), + fill `RootFieldL1EntityCacheKeyTemplates`. +- `plan/caching_planner_state.go`: `cachingPlannerState` (`plannerObjects`, field-stack, response paths, + entity-boundary paths), `trackFieldForPlanner`, `createFieldValueForPlanner`, `captureFieldCacheArgs`, + `isEntityBoundaryField`, and `configureFetchCaching` (the central L2 on/off decision). +- `visitor.go` wiring: `v.caching = newCachingPlannerState(...)`, the walk hooks, and the final + `external.Caching = v.caching.configureFetchCaching(...)` + `ProvidesData` attachment. +- `plan.Configuration` flags `DisableEntityCaching` and `DisableFetchProvidesData`. + +**Excludes:** `UseL1Cache` enablement (still false here; PR 12 sets it), +mutation impact (PR 13), subscription population (PR 15), @requestScoped population (PR 18/19). + +**Dependencies:** PR 3 (templates), PR 4 (config lookups), PR 5 (ProvidesData types). + +**Acceptance criteria:** +- Datasource-package tests assert the full rendered key shape for entity and root fetches. +- `configureFetchCaching` unit tests: entity path (config present → Enabled config; absent → L2 off but KeyFields kept), + root-field path (all root fields must share identical config else L2 off), hard gates. +- Existing planner snapshot tests updated where `Caching`/`ProvidesData` now appear on fetches; + with `DisableEntityCaching`, plans are byte-identical to today. + +**Reviewer-guide doc:** [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) section "Planner wiring", +plus the ADR for the entity-boundary path-derivation risk +([adr/0002-planner-cache-config.md](./adr/0002-planner-cache-config.md)). + +**Mergeability:** behavior is gated by config presence (opt-in) and `DisableEntityCaching`; +no router populates `FederationMetaData` caching collections until PR 21 + the router stack, +so on the feature branch this PR only changes plans for tests that explicitly set config. + +--- + +### PR 12 — optimizeL1Cache postprocess pass + +**Goal:** turn `UseL1Cache` on only where a provider/consumer relationship exists for the same entity type. + +**Scope:** +- `postprocess/optimize_l1_cache.go`: the `FetchTreeProcessor` collecting `entityFetchInfo` / + `rootFieldProviderInfo`, computing `canRead`/`canWrite` via field-tree containment + (`objectProvidesAllFields`), and setting `UseL1Cache`. +- Register it LAST in `postprocess.go`'s `processFetchTree` chain (after `createConcreteSingleFetchTypes`), + gated by `opts.disableOptimizeL1Cache`. + +**Excludes:** anything outside `resolve.FetchTreeNode` + public fetch types (the pass must not import `plan`). + +**Dependencies:** PR 5 (ProvidesData), PR 11 (so concrete fetches carry `Caching`/`ProvidesData`). + +**Acceptance criteria:** +- `optimize_l1_cache_test.go`: provider-then-consumer → UseL1Cache true on both; + no consumer → root field UseL1Cache false; ancestor-union coverage; disable flag → no-op. +- Ordering test: pass runs after concrete-fetch conversion. + +**Reviewer-guide doc:** [adr/0003-l1-optimizer.md](./adr/0003-l1-optimizer.md) +(records the false-then-widen default decision and the must-run-last ordering coupling). + +**Mergeability:** behind `disableOptimizeL1Cache`; when disabled it is a safe no-op +(UseL1Cache stays false, L1 simply off everywhere). + +--- + +### PR 13 — Mutation impact (populate + invalidate) + +**Goal:** support mutation-triggered L2 populate and entity invalidation. + +**Scope:** +- `plan/caching_planner_state.go`: `configureMutationEntityImpact` → + `resolve.MutationEntityImpactConfig`; `MutationFieldCacheConfig` / `MutationCacheInvalidationConfig` reads. +- `resolve` loader: mutations always skip L2 reads; skip L2 writes unless `EnableEntityL2CachePopulation`; + delete the impacted entity key (skip delete if being written same fetch); `MutationEvent` analytics. + +**Excludes:** subscription (PR 15), extension invalidation (PR 14). + +**Dependencies:** PR 7 (loader), PR 11 (planner). + +**Acceptance criteria:** +- `mutation_cache_test.go`: `navigateProvidesDataToField`, `buildEntityKeyValue`, + `buildMutationEntityCacheKey`, `detectMutationEntityImpact`, TTL override. +- E2E mutation-skips-L2-read + mutation-invalidation (Delete log entry) tests. + +**Reviewer-guide doc:** [directives/mutation-invalidation.md](./directives/mutation-invalidation.md). + +**Mergeability:** mutation paths only reached for mutation operations with config present. + +--- + +### PR 14 — Extension-driven invalidation + +**Goal:** delete L2 entries when a subgraph returns `extensions.cacheInvalidation.keys`. + +**Scope:** `loader_cache.go` parse of the extension payload, build matching L2 keys +(full transform pipeline), `LoaderCache.Delete` per key, skip keys being written same fetch. +Requires `ResolverOptions.EntityCacheConfigs` populated + `EnableL2Cache`. + +**Excludes:** mutation/subscription invalidation (their own PRs). + +**Dependencies:** PR 7. + +**Acceptance criteria:** `extensions_cache_invalidation_test.go` — Delete + analytics; +key reconstruction includes `GlobalCacheKeyPrefix`. + +**Reviewer-guide doc:** [directives/extension-invalidation.md](./directives/extension-invalidation.md). + +**Mergeability:** only fires when a subgraph emits the extension AND L2 is enabled AND configs present. + +--- + +### PR 15 — Subscription entity population + +**Goal:** per-event subscription populate / invalidate of L2. + +**Scope:** +- `plan/caching_planner_state.go`: `configureSubscriptionEntityCachePopulation` → + `resolve.SubscriptionEntityCachePopulation` (Populate vs Invalidate; + abstract-type resolution via union/interface; `FindByTypeAndFieldName` needs BOTH fields). +- `resolve`: per-event write (Populate) or delete-when-only-@key (Invalidate); + `ResolverOptions.OnSubscriptionCacheWrite` / `OnSubscriptionCacheInvalidate` callbacks. + +**Excludes:** non-subscription paths. + +**Dependencies:** PR 7, PR 11. + +**Acceptance criteria:** +- `federation_subscription_caching_test.go` via `NewManualFederationSetup` + product subscription `Emit()`: + populate writes across events; invalidate deletes; `t.Cleanup` registered immediately after creation. +- Empty-`FieldName` config is a silent no-op (regression guard test). + +**Reviewer-guide doc:** [directives/subscription-population.md](./directives/subscription-population.md). + +**Mergeability:** subscription-only; config-gated; both fields mandatory. + +--- + +### PR 16 — Shadow mode + +**Goal:** read/write L2 but always serve fresh and compare (staleness measurement). + +**Scope:** `resolve` loader shadow path (`ShadowMode` on entity + root config); +`ShadowComparisonEvent` analytics (cached vs fresh hash/bytes, freshness rate); +never-serve-cached behavior. + +**Excludes:** any change to non-shadow serve logic. + +**Dependencies:** PR 7, PR 10 (analytics). + +**Acceptance criteria:** analytics tests assert `Shadow:true` on reads, +`ShadowFreshnessRate`, and that cached values are never served in shadow mode. + +**Reviewer-guide doc:** [directives/shadow-mode.md](./directives/shadow-mode.md). + +**Mergeability:** off unless `ShadowMode` set in config. + +--- + +### PR 17 — @requestScoped composition (metadata + lookups) + +**Goal:** add the `@requestScoped` plan-time metadata and lookups (composition side, no runtime). + +**Scope:** +- `plan/federation_metadata.go`: `RequestScopedField{FieldName, TypeName, L1Key}` + in `FederationMetaData.RequestScopedFields`; lookups `RequestScopedFieldsForType`, + `RequestScopedExportsForField`; `InterfaceObjects []EntityInterfaceConfiguration` + for concrete→interface mapping; `RequiredFieldsByKey`. +- Composition validation: `key` mandatory; warn when a key appears on only one field in a subgraph. + +**Excludes:** datasource emission (PR 18), widening (PR 19), runtime injection (PR 20). + +**Dependencies:** PR 4. + +**Acceptance criteria:** +- Lookup unit tests (symmetric model: each field is both reader and writer; same `L1Key` → shared entry). +- Composition validation tests for the mandatory-key and single-field-warning rules. + +**Reviewer-guide doc:** [directives/request-scoped.md](./directives/request-scoped.md), +[adr/0004-request-scoped.md](./adr/0004-request-scoped.md). + +**Mergeability:** additive metadata + lookups; no emitter or runtime reads them yet. + +--- + +### PR 18 — @requestScoped datasource emission + +**Goal:** emit a `resolve.RequestScopedField` per `@requestScoped` field during `ConfigureFetch`. + +**Scope:** `graphql_datasource.go`: for root fetches iterate root fields → +`RequestScopedExportsForField`; for entity fetches iterate `RequestScopedFieldsForType` +PLUS interface types via `InterfaceObjects`; dedup by `FieldName\x00L1Key`; +map schema name → response key. `ProvidesData` left nil here (visitor fills it). +Add `resolve.RequestScopedField{FieldName, FieldPath, L1Key, ProvidesData *Object}` +to `FetchCacheConfiguration`. + +**Excludes:** `ProvidesData` population (the visitor step is in PR 19/the visitor-completion step); +runtime injection (PR 20); widening (PR 19). + +**Dependencies:** PR 11 (datasource ConfigureFetch caching path), PR 17 (lookups). + +**Acceptance criteria:** datasource tests assert the emitted `RequestScopedField` slice +(response-key mapping, interface-object dedup, symmetric emission). + +**Reviewer-guide doc:** [directives/request-scoped.md](./directives/request-scoped.md) section "Emission". + +**Mergeability:** emits data only; the runtime ignores `RequestScopedFields` until PR 20. + +--- + +### PR 19 — @requestScoped selection-set widening + ProvidesData population + +**Goal:** widen co-keyed `@requestScoped` selection sets to an identical L1 shape, +and populate `ProvidesData` on each `RequestScopedField`. + +**Scope:** +- `node_selection_visitor_request_scoped.go`: `propagateRequestScopedWidening()` in LeaveDocument — + group by `{l1Key, dsHash}`, build the union selection set for groups of ≥2 same-return-type participants, + mint synthetic aliases on collision, inject missing fragments back into the operation; + share the `requestScopedVisibleResponseKeys` / `requestScopedFetchAliases` maps via `setRequestScopedMaps`. +- `populateRequestScopedFieldsProvidesData` in the visitor: match each field by response key, + drop hints whose field is not an Object, set `ProvidesData` + `ComputeHasAliases`. + +**Excludes:** runtime injection/export (PR 20). + +**Dependencies:** PR 11 (visitor), PR 17 (lookups). + +**Acceptance criteria:** +- `request_scoped_widening_test.go` (datasource): union built, conservative early-returns, + interface-object fallback. +- `request_scoped_provides_data_test.go`: nil plannerObj leaves fields unchanged; + no-match/scalar drops the hint; alias matches by response key; per-field sub-Object pointer identity. + +**Reviewer-guide doc:** [directives/request-scoped.md](./directives/request-scoped.md) section "Widening". + +**Mergeability:** widening rewrites the AST only when ≥2 co-keyed fields exist; +absent the directive it is a no-op. `ProvidesData` is data the runtime ignores until PR 20. + +--- + +### PR 20 — @requestScoped coordinate-L1 runtime + +**Goal:** inject from / export to the per-request coordinate L1 so co-keyed fields share a fetch. + +**Scope:** +- `loader.go`: `requestScopedL1 map[string]*astjson.Value` (main-thread only); + `tryRequestScopedInjection` (Phase 1.5, Phase 3.5, `resolveSingle`) and `exportRequestScopedFields`. +- Field-widening check via `validateItemHasRequiredData` (collect-then-inject, all-or-nothing); + copy-on-inject (`structuralCopyDenormalized`) and copy-on-export (`structuralCopyNormalized`); + `EnableL1Cache` gating; `LoadSkipped` trace + the `cacheTraceRequestScopedHits` counter fold. + +**Excludes:** any change to entity L1/L2 paths. + +**Dependencies:** PR 7 (loader + L1), PR 18 (emitted fields), PR 19 (ProvidesData + widening). + +**Acceptance criteria:** +- `request_scoped_test.go`: injection (no-hints/missing-key/widening-rejects-narrow/all-or-nothing/L1-gating), + export (copy-on-export, independence), round-trip, GC-survival + arena-residency, alias handling, + synthetic alias. +- E2E `federation_caching_request_scoped_test.go` un-skipped with EXACT subgraph call-count assertions + (no fuzzy `if calls == 0` smoke checks). + +**Reviewer-guide doc:** [directives/request-scoped.md](./directives/request-scoped.md) section "Runtime", +[adr/0004-request-scoped.md](./adr/0004-request-scoped.md). + +**Mergeability:** gated on `EnableL1Cache`; no `RequestScopedFields` are emitted unless the directive +is present in composition, so default behavior is unchanged. + +--- + +### PR 21 — Cache trace + execution/engine config factory + +**Goal:** add the per-fetch cache trace and the router-facing config container. + +**Scope:** +- `resolve/trace.go`: `CacheTrace` + `CacheTraceEntity`, gated on + `ctx.TracingOptions.Enable && !ctx.TracingOptions.ExcludeCacheStats`, + emitted under `extensions.trace.fetches[].fetch.trace.cache_trace`. +- `execution/engine/config_factory_federation.go`: `SubgraphCachingConfig` container (5 plan.* slices) + + `SubgraphCachingConfigs.FindBySubgraphName`, + `WithSubgraphEntityCachingConfigs(...)` option, and `dataSourceMetaData()` copying the slices + onto each datasource's `FederationMetaData` with the 3-tier subgraph-name fallback. + +**Excludes:** router-side wiring (that is the [04-PR-PLAN-router.md](./04-PR-PLAN-router.md) stack). + +**Dependencies:** PR 7 (runtime), PR 11 (planner config consumed by the factory). + +**Acceptance criteria:** +- `federation_caching_trace_test.go`: trace populated when tracing on, absent when off, + `Keys` present only when `!ExcludeRawInputData`. +- `config_factory_federation` test: the 5 slices land on `FederationMetaData` for the right subgraph. +- This is the first PR where a full E2E gateway can opt in via `WithSubgraphEntityCachingConfigs`. + +**Reviewer-guide doc:** the public-API integration guide referenced in +[01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) section "execution/engine surface". + +**Mergeability:** the factory copies config only when a caller passes +`WithSubgraphEntityCachingConfigs`; trace only fires under tracing options. +Both default off. + +--- + +## 4. Test and benchmark PRs (interleaving rule) + +Per the repo convention, tests ship **with** the PR that implements the behavior they cover +(every behavior PR above lists its unit/E2E tests in its acceptance criteria). +Two categories are pulled out into their own small PRs to keep each diff under ~30 minutes: + +- **Large E2E analytics suites.** `federation_caching_analytics_test.go` (~120KB) is split per concern + (L1 integration, L2 integration, shadow, mutation events) into separate files, + each landing right after its corresponding behavior PR (10, 13, 16). + Reason: full-snapshot assertions are correct but too large to review bundled with the implementation. + +- **Benchmarks.** Benchmarks land in dedicated PRs immediately after the behavior they measure: + - After PR 7–8: `loader_cache_copy_bench_test.go` + `loader_noncaching_bench_test.go` + (the Copy-Budget pair — must stay 1:1 with the 4 `TestCopyInvariant_*` tests and the Copy Budget table; + update all three together). + - After PR 7: `caching_overhead_bench_test.go` (the Disabled → ConfiguredButDisabled → L1Only → + L1L2_Miss → L1L2_Hit ladder; `ConfiguredButDisabled` catches guard leaks). + - After PR 2: `structural_copy_bench_test.go` (the 8 primitive benches, NoTransform vs WithTransform). + - After PR 10: `cache_analytics` micro-benches (Disabled vs Enabled, field hashing). + +The full taxonomy, conventions, and the acceptance-criteria sync rule are in +[06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md). +Every test PR must update `docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md` +(path + line + test name per AC) — this is a hard requirement. + +--- + +## 5. Explicitly out of scope for this stack + +These topics surfaced during analysis but are NOT entity caching. +They must NOT be bundled into the PRs above — see [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md): + +- the `onError` / `ErrorBehavior` request-extension feature (shares `ExecutionOptions` + `execution_engine.go`); +- the `service_datasource` `__service` capabilities endpoint; +- embedded `@requires`/`@provides` planner correctness changes that alter NON-cached planning; +- the federationtesting gateway/test-harness rewrite (land as its own harness PR BEFORE the E2E test PRs); +- subscription-client transport bug fixes (SSE leak eviction + WS legacy subprotocol); +- dependency DOWNGRADES in `v2/go.mod` (stale-base artifacts — only the astjson + go-arena BUMPS are real). diff --git a/docs/entity-caching/04-PR-PLAN-router.md b/docs/entity-caching/04-PR-PLAN-router.md new file mode 100644 index 0000000000..ebeed28421 --- /dev/null +++ b/docs/entity-caching/04-PR-PLAN-router.md @@ -0,0 +1,676 @@ +# 04 — Stacked PR Plan: Router (Cosmo) + +This document defines the ordered stack of pull requests that integrate entity caching into the **Cosmo router**. + +The router is a separate repository from `graphql-go-tools` (gqtools). +The router consumes gqtools as a Go module dependency, so every router PR that needs a new gqtools API must wait for a gqtools **release** (a published version tag) and then bump its `go.mod`. + +Read this alongside: + +- [00-OVERVIEW.md](00-OVERVIEW.md) — what entity caching is and why we are re-implementing it. +- [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md) — the clean architecture and the integration seam between gqtools and the router. +- [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md) — the **upstream** stack. + Every router PR below depends on one or more gqtools PRs from that document. +- [05-ASTJSON-PRIMITIVES.md](05-ASTJSON-PRIMITIVES.md) — the astjson primitives the engine relies on (router never calls these directly). +- [06-TEST-AND-BENCH-PLAN.md](06-TEST-AND-BENCH-PLAN.md) — the cross-cutting test and benchmark plan. +- [07-UNRELATED-FINDINGS.md](07-UNRELATED-FINDINGS.md) — out-of-scope findings discovered while mapping the existing router PR (#2777). + +--- + +## Background for a first-time reader + +The existing router integration shipped as a single squashed PR (#2777) of roughly 96K lines across 310 files. +That is too large to review and too large to revert safely. +This plan breaks the same surface into a stack of small, independently reviewable PRs. + +**What "entity caching" means for the router.** +The router builds the federation execution plan, runs requests through the gqtools resolve engine, and exposes configuration and observability. +The caching logic itself (cache keys, L1, L2 read/write/merge, shadow comparison, analytics collection) lives entirely in the gqtools `resolve` engine. +The router's job is narrow and falls into four buckets: + +1. **Declare what to cache.** + Translate composition output (per-subgraph cache configuration) into the gqtools `plan` config structs, and pass them through the federation config factory. +2. **Provide the L2 backend.** + Implement the `resolve.LoaderCache` interface (Redis, in-memory) and register named instances in the resolver. +3. **Toggle per request.** + Set `ctx.ExecutionOptions.Caching` (an `resolve.CachingOptions` value) for each incoming request, honoring config and dev/debug headers. +4. **Observe.** + Read `ctx.GetCacheStats()` after resolution and export the analytics snapshot to OTLP/Prometheus; optionally surface per-fetch cache traces in response extensions. + +**The seam is deliberately thin.** +The router never implements cache-key rendering, never touches the arena or astjson, and never implements L1. +It only implements `LoaderCache` (a plain `Get`/`Set`/`Delete` over `[]string` keys and `[]*CacheEntry`) and fills declarative config structs. +Keeping the seam thin is what makes the router stack independent of engine internals — see [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md). + +--- + +## Release and dependency model + +The router depends on **two** gqtools modules, each released and tagged independently: + +- `github.com/wundergraph/graphql-go-tools/v2` — tagged `v2.x.y` (the core engine: `resolve`, `plan`). +- `github.com/wundergraph/graphql-go-tools/execution` — tagged `execution/v1.x.y` (the federation config factory: `engine.SubgraphCachingConfig`, `WithSubgraphEntityCachingConfigs`). + +A router PR that needs a new gqtools API cannot merge until: + +1. the corresponding gqtools PR has merged on `master`, and +2. a release has been cut (a new `v2.x.y` and/or `execution/v1.x.y` tag), and +3. the router PR bumps both modules in `go.mod` to that tag. + +Throughout this document a dependency is written as: + +> **Waits on gqtools release:** RG-x (gqtools PR x → release `v2.A.B` + `execution/v1.C.D`). + +The exact version numbers are filled in at release time. +What matters is the **ordering**: a router PR must not be opened against an unreleased gqtools API. +Work may be *prototyped* against a local `replace` directive, but the `replace` must be removed and a real tag pinned before the router PR is marked ready. + +See [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md) for the upstream PR numbering (`G1`, `G2`, …) referenced below. + +--- + +## Branch strategy + +Create one fresh feature branch off `main` in the router repository: + +``` +feat/entity-caching +``` + +Each PR below targets the previous PR's branch (a true stack), not `main` directly, until the foundation lands. +Once the foundation (R1) merges to `main`, the remaining PRs may target `main` individually if they are independent, or remain stacked where one strictly needs another. +The dependency graph at the end of this document shows which are independent. + +Naming convention for the stack branches: + +``` +feat/entity-caching (R1, foundation) +feat/entity-caching-l1 (R2) +feat/entity-caching-l2-redis (R3) +feat/entity-caching-l2-memory (R4) +feat/entity-caching-breaker (R5) +feat/entity-caching-headers (R6) +feat/entity-caching-shadow (R7) +feat/entity-caching-analytics (R8) +feat/entity-caching-invalidate (R9) +feat/entity-caching-subs (R10) +feat/entity-caching-docs (R11) +``` + +--- + +## PR stack overview + +| PR | Title | Waits on gqtools release | Independent after R1? | +|----|-------|--------------------------|------------------------| +| **R1** | Foundation: config schema + cache wiring (no backend) | RG-1 (foundation: `plan.*` configs, `resolve.LoaderCache`, `CachingOptions`) | — | +| **R2** | L1 per-request cache wiring | none beyond R1 | yes | +| **R3** | L2 Redis backend | none beyond R1 | yes | +| **R4** | L2 in-memory backend | none beyond R1 | yes | +| **R5** | Circuit breaker decorator | none (decorates R3/R4) | needs R3 or R4 | +| **R6** | Per-request cache-control headers | none beyond R1 | yes | +| **R7** | Shadow mode wiring + export | RG-7 (shadow comparison events) | yes | +| **R8** | Analytics export (OTLP / Prometheus) | RG-8 (analytics snapshot) | yes | +| **R9** | Mutation + extension-based invalidation | RG-9 (invalidation config + callbacks) | needs R3 or R4 | +| **R10** | Subscription cache populate/invalidate | RG-10 (subscription callbacks) | needs R3 or R4 | +| **R11** | Documentation + config reference | none | needs all | + +Total: 11 PRs replacing the single 96K-line squash. +Each is independently reviewable in well under a day. + +--- + +## R1 — Foundation: config schema + cache wiring (no backend) + +**Title:** `feat(cache): entity caching foundation — config schema and wiring (no backend)` + +**Goal.** +Stand up the entire configuration surface and all the wiring seams, with **no functional caching**. +After this PR, an operator can write entity-caching YAML and the router parses, validates, and threads it through to the engine — but because no backend is registered and the feature defaults to disabled, behavior is byte-for-byte identical to today. +This is the keystone every other router PR stacks on. + +**Scope.** + +- **YAML config schema.** + Add `EntityCachingConfiguration` to the router config struct and the JSON schema (`config.schema.json`): + - `Enabled` (default `false`). + - `GlobalCacheKeyPrefix` (string, used for schema-versioning of keys). + - `L1{ Enabled, MaxSize (default 100MB) }`. + - `L2{ Enabled, Storage{ ProviderID, KeyPrefix (default cosmo_entity_cache) }, CircuitBreaker{ FailureThreshold (default 5), CooldownPeriod (default 10s) } }`. + - `SubgraphCacheOverrides[]{ Name, StorageProviderID, Entities[]{ Type, StorageProviderID } }`. + - Extend `StorageProviders` with `Redis{ ID, URLs, ClusterEnabled }` and `Memory{ ID, MaxSize }` blocks. +- **Provider-ID resolution seam.** + Add `resolveEntityCacheProviderID` with the documented 3-tier precedence: entity override → subgraph override → `default`. + This one function is shared between instance-build time and plan-config time so the naming can never drift; it is the single source of truth for which named cache a config points at. +- **Instance map build.** + Add `Router.buildEntityCacheInstances()` returning `map[providerID]resolve.LoaderCache`. + In R1 it returns an **empty (or nil-valued) map**: no backend types exist yet. + Establish the contract that the map MUST contain a `"default"` key whenever any plan config references it. +- **Engine wiring.** + In the executor build path, set `resolve.ResolverOptions.Caches` from the instance map and pass it to the single `resolve.New(...)` entry point. + Wire the (empty) `EntityCacheConfigs` map placeholder. +- **Composition → plan translation.** + In `factoryresolver.go`, translate the composition protobuf into `plan.FederationMetaData`: + populate `EntityCacheConfiguration`, `RootFieldCacheConfiguration` (with `EntityKeyMappings`/`FieldMapping`), `MutationFieldCacheConfiguration`, `MutationCacheInvalidationConfiguration`, `SubscriptionEntityPopulationConfiguration`, and `RequestScopedField`. + Build `engine.SubgraphCachingConfigs` and pass them via `engine.WithSubgraphEntityCachingConfigs(...)` to `NewFederationEngineConfigFactory`. +- **Per-request options plumbing (disabled).** + Add the code path that constructs `resolve.CachingOptions` per request from `EntityCachingHandlerOptions`, but with all flags defaulting to `false`. + No headers honored yet. +- **Lifecycle.** + `Router.Shutdown` closes any cache instance that implements `io.Closer` (no-op while the map is empty). + +**Exclusions (explicitly NOT in R1).** + +- No Redis backend (R3). +- No in-memory backend (R4). +- No circuit breaker (R5). +- No header handling (R6). +- No analytics export (R8). +- No invalidation deletes wired to subgraph extensions (R9). +- No subscription callbacks (R10). +- Demo apps, benchmark harnesses, and regenerated protobuf files are split into their own non-blocking chore PRs (see [07-UNRELATED-FINDINGS.md](07-UNRELATED-FINDINGS.md)). + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** RG-1 — the gqtools **foundation** PR (G1 in [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md)). +> That PR must export: `plan.EntityCacheConfiguration` and the four sibling config types + their `FindBy*` collection methods; `engine.SubgraphCachingConfig` + `WithSubgraphEntityCachingConfigs`; `resolve.LoaderCache`, `resolve.CacheEntry`, `resolve.EntityCacheInvalidationConfig`; `resolve.CachingOptions` and `ctx.ExecutionOptions.Caching`; and `resolve.ResolverOptions.Caches`/`EntityCacheConfigs`. +> Bump **both** `v2` and `execution` modules to the tag that includes RG-1. + +**Critical contract notes (decided once, here).** +The clean re-implementation MUST standardize on the **current code** signatures, not the stale doc: + +- `LoaderCache.Set(ctx, entries []*resolve.CacheEntry) error` — **no** `ttl` parameter; TTL is per-entry on `CacheEntry.TTL`. + Zero = backend default, negative = indefinite. +- `CacheEntry` has fields `Key`, `Value []byte`, `TTL`, `RemainingTTL`, `WriteReason`. +- The router never implements `CacheKeyTemplate` and never sees `arena.Arena` / `astjson` — those stay engine-internal. + See the doc-drift discussion in [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md). + +**Acceptance criteria.** + +1. With entity caching absent from YAML, the router boots and serves requests with **zero** behavioral change (regression suite green). +2. With `entity_caching.enabled: true` but no storage provider configured, the router boots, L2 stays disabled, and a single clear warning is logged. +3. JSON-schema validation rejects an `L2` block that names a non-existent `storage.provider_id`. +4. `resolveEntityCacheProviderID` unit tests cover all three precedence tiers plus the missing-`default` edge case. +5. Composition fixtures translate to the expected `plan.*` structs — assert the **entire** `FederationMetaData` cache slices with `assert.Equal` against inline expected values (per the repo's exact-assertion rule). +6. `go vet` and the type checker pass; `go.mod` pins the RG-1 release tags with no `replace` directive. + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R1-foundation.md` — explains the four router responsibilities, walks the config struct → JSON schema → plan translation path, shows the provider-ID precedence table, and states the "no backend, no behavior change" guarantee with the exact regression command to run. + +--- + +## R2 — L1 per-request cache wiring + +**Title:** `feat(cache): enable per-request L1 entity cache` + +**Goal.** +Let an operator turn on the per-request in-memory L1 entity cache. +L1 lives entirely inside the engine; the router only flips the `EnableL1Cache` flag per request based on config. + +**Scope.** + +- Map `entity_caching.l1.enabled` → `resolve.CachingOptions.EnableL1Cache` in the per-request options builder. +- Document that L1 also gates `@requestScoped` coordinate L1 (shared flag). +- No new backend, no storage provider — L1 needs none. + +**Exclusions.** + +- No L2 (R3/R4). +- No header overrides (R6) — that PR will let dev requests toggle this flag. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** none beyond R1. +> `EnableL1Cache` ships in RG-1. +> If the L1 engine implementation is split into its own gqtools PR (G-L1), this router PR waits on **that** release instead; coordinate the exact tag with [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md). + +**Acceptance criteria.** + +1. With `l1.enabled: true`, a single request that fetches the same entity twice issues **one** subgraph fetch — assert exact fetch count via the federation test-service tracker. +2. With `l1.enabled: false` (default), behavior matches R1 exactly. +3. L1 is per-request: two sequential requests do **not** share L1 state (assert two fetches across two requests). + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R2-l1.md` — one paragraph: what L1 is (dedup within a request), why it needs no backend, the single flag it flips, and the `@requestScoped` shared-flag note. + +--- + +## R3 — L2 Redis backend + +**Title:** `feat(cache): Redis L2 entity cache backend` + +**Goal.** +Ship a production `resolve.LoaderCache` backed by Redis, registered as a named instance, so entities cache across requests. + +**Scope.** + +- `router/pkg/entitycache`: `RedisEntityCache` implementing `resolve.LoaderCache`: + - Constructor `NewRedisEntityCache(client redis.UniversalClient, keyPrefix string)`. + - `Get` = `MGET` over `keyPrefix:key`; a nil reply maps to a **miss** (nil slot), and the returned slice MUST be the same length as the input keys. + - `Set` = pipelined `SET` per entry with that entry's `CacheEntry.TTL`; clamp `TTL < 0` to `0` (Redis "no expiry"), `TTL == 0` uses backend default if configured. + - `Delete` = `DEL`. + - Thread-safe (Redis client is); document that the engine bulk-reads on the main thread today but the contract still requires concurrency safety. +- Build-path integration: when `l2.enabled` and a Redis storage provider is configured, `buildEntityCacheInstances` constructs a `RedisEntityCache` and registers it under the resolved provider ID **and** aliases it as `"default"` when it is the default provider. +- Per-request: map `l2.enabled` → `resolve.CachingOptions.EnableL2Cache`. +- `GlobalCacheKeyPrefix` from config flows into `CachingOptions.GlobalCacheKeyPrefix`; the backend's own `keyPrefix` is separate (storage namespacing vs. engine key transform). + +**Exclusions.** + +- No circuit breaker (R5). +- No in-memory backend (R4). +- No analytics on Redis errors yet (R8). +- No invalidation deletes from subgraph extensions (R9) — `Delete` exists but is only exercised by tests here. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** none beyond R1. +> `LoaderCache`/`CacheEntry` ship in RG-1; this PR only implements them. + +**Risk to surface in the PR description.** +The engine's bulk L2 lookup fails the **whole batch** back to subgraph on a single `Get` error. +A raw Redis backend therefore turns one transient Redis blip into a full cache-miss for that batch. +This is the motivation for R5 (the breaker masks `Get` errors as a clean miss). +Call this out so reviewers understand R3 is intentionally "fail-open but noisy" until R5. + +**Acceptance criteria.** + +1. Round-trip test against a real Redis (testcontainers or miniredis): `Set` then `Get` returns the exact bytes and a positive `RemainingTTL`; `Get` of an unknown key returns a nil slot. +2. `Get` of N keys with M present returns a slice of length N with exactly the M present slots non-nil — assert the full slice. +3. `TTL < 0` results in a key with no expiry; `TTL > 0` results in the expected expiry (assert exact seconds). +4. End-to-end: two identical requests across the request boundary issue **one** subgraph fetch (second served from Redis) — assert exact tracker counts. +5. Key shape is exactly `keyPrefix:` — assert the full Redis key string. + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R3-redis.md` — the three-method contract, the nil-slot miss convention, the TTL clamping table, the "one Get error fails the batch" risk and why R5 fixes it, and the exact key-shape example. + +--- + +## R4 — L2 in-memory backend + +**Title:** `feat(cache): in-memory L2 entity cache backend` + +**Goal.** +Ship a single-node in-memory `resolve.LoaderCache` (ristretto) for deployments without Redis, and for tests. + +**Scope.** + +- `MemoryEntityCache` in `router/pkg/entitycache`: + - Constructor `NewMemoryEntityCache(maxSizeBytes int64)`; `MaxCost = maxSizeBytes`. + - `Set` via `SetWithTTL`; `Get` reads value + remaining TTL (`GetTTL`) into `CacheEntry.RemainingTTL`; `Delete` via `Del`. + - Expose `Metrics()` and `MaxSizeBytes()` for the analytics PR to register a metric source. + - Atomic `Len()`, `OnEvict` hook for eviction accounting. + - Implements `io.Closer` so `Router.Shutdown` closes it. +- Build-path: register a `MemoryEntityCache` when the storage provider is `Memory`. + +**Exclusions.** + +- Same as R3: no breaker, no analytics export wiring (only the `Metrics()` getter), no invalidation flows. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** none beyond R1. + +**Acceptance criteria.** + +1. `Set`/`Get`/`Delete` round-trip returns exact bytes; eviction respects `MaxCost` (assert `Len` after overflowing the budget). +2. `RemainingTTL` is populated and decreases over time (assert exact value at write time, monotonic decrease at read time — normalize the timestamp, do not use a fuzzy comparison). +3. `Router.Shutdown` closes the instance (assert the `io.Closer` is invoked). +4. Same end-to-end cross-request single-fetch assertion as R3, using the in-memory backend. + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R4-memory.md` — when to choose in-memory vs. Redis (single-node vs. shared), the ristretto cost model, and the `Metrics()`/`io.Closer` hooks R8 and Shutdown rely on. + +--- + +## R5 — Circuit breaker decorator + +**Title:** `feat(cache): circuit breaker for L2 cache backends` + +**Goal.** +Wrap any `resolve.LoaderCache` so that repeated L2 failures stop hammering a sick backend and degrade to clean cache-misses instead of erroring the request batch. + +**Scope.** + +- `CircuitBreakerCache` decorator in `router/pkg/entitycache`: + - States closed / open / half-open (atomic). + - Opens after `FailureThreshold` **consecutive** failures. + - While open: `Get` returns a slice of nil entries (a clean miss, **never** an error); `Set`/`Delete` are no-ops. + - After `CooldownPeriod`, one half-open probe; success closes, failure re-opens. + - Constructor `NewCircuitBreakerCache(cache resolve.LoaderCache, cfg{ Enabled, FailureThreshold, CooldownPeriod })`. +- Build-path: when `l2.circuit_breaker.enabled`, wrap the constructed backend (Redis or memory) before registering it. +- Config: `L2.CircuitBreaker{ FailureThreshold (default 5), CooldownPeriod (default 10s) }` from R1's schema. + +**Why this matters.** +This directly addresses the R3 risk: by masking `Get` errors as clean misses, one Redis blip no longer fails the whole engine batch. +The decorator is the boundary where "fail-open" is made safe. + +**Exclusions.** + +- No analytics on breaker state transitions yet (R8 may add a counter). +- Decorates an existing backend only — no breaker without R3 or R4. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** none. +> The decorator only depends on `resolve.LoaderCache` from RG-1. +> **Depends on a sibling router PR:** R3 or R4 must be merged so there is a backend to wrap. + +**Acceptance criteria.** + +1. After `FailureThreshold` consecutive `Get` errors from a fake backend, the breaker is open and the next `Get` returns all-nil with **no** error — assert the full nil slice and a nil error. +2. While open, `Set` and `Delete` do not call the wrapped backend (assert the fake backend's call counters are unchanged). +3. After `CooldownPeriod`, exactly **one** probe reaches the wrapped backend; success transitions to closed (assert the state and that subsequent calls pass through). +4. A single failure below the threshold does **not** open the breaker (assert state still closed and call passed through). + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R5-breaker.md` — the state machine diagram in words, the "errors become clean misses" guarantee, why it pairs with the R3 batch-failure risk, and the consecutive-vs-cumulative failure semantics. + +--- + +## R6 — Per-request cache-control headers + +**Title:** `feat(cache): per-request cache-control headers (dev/debug)` + +**Goal.** +Allow per-request overrides of caching for debugging and the playground, gated so they cannot be abused in production. + +**Scope.** + +- Honor these request headers, mapping to `resolve.CachingOptions`: + - `X-WG-Disable-Entity-Cache: true` → disable L1 **and** L2. + - `X-WG-Disable-Entity-Cache-L1: true` → disable L1 only (also disables `@requestScoped` coordinate L1, shared flag). + - `X-WG-Disable-Entity-Cache-L2: true` → disable L2 only. + - `X-WG-Cache-Key-Prefix: ` → prepend to keys as `header:global` (header prefix in front of the configured `GlobalCacheKeyPrefix`). +- **Gate:** all of the above are honored **only** when `reqCtx.operation.traceOptions.Enable` is true (dev mode or a valid studio request token). + In production without trace, the headers are ignored. + Place the gate in `GraphQLHandler.cachingOptions` (`graphql_handler.go`). + +**Exclusions.** + +- No new config keys; this is purely a per-request override layer over R2/R3. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** none beyond R1. +> The flags and `GlobalCacheKeyPrefix` ship in RG-1. + +**Risk to surface in the PR description.** +The trace gate is load-bearing. +Moving or weakening it turns these headers into a production DoS / cache-poisoning vector (any client could force-disable caching or shard the key space). +The reviewer guide and a regression test must lock the gate. + +**Acceptance criteria.** + +1. With trace **enabled**, each header has its exact documented effect — assert the resulting `CachingOptions` value (full struct) for each header. +2. With trace **disabled**, every header is ignored — assert `CachingOptions` equals the config-derived value unchanged. +3. `X-WG-Cache-Key-Prefix` composes as `header:global` (assert the exact resulting prefix string). + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R6-headers.md` — the header table, the exact trace gate condition and where it lives, the prod-DoS rationale for the gate, and the prefix composition order. + +--- + +## R7 — Shadow mode wiring + export + +**Title:** `feat(cache): shadow mode wiring and staleness export` + +**Goal.** +Let operators run L2 in "shadow" mode: read/write the cache but always serve fresh data and compare, to measure staleness before trusting the cache. +The comparison happens in the engine; the router only sets the flag and exports the staleness signal. + +**Scope.** + +- Translate `ShadowMode` from composition into the `plan.EntityCacheConfiguration.ShadowMode` field (already wired structurally in R1; R7 makes it functional end-to-end and adds the export). +- After resolution, read `ShadowComparisons` from `ctx.GetCacheStats()` and export `shadowStaleness` (fresh vs. stale counts) to observability. + (This depends on the analytics read path; if R8 lands first, R7 reuses it; otherwise R7 ships the minimal snapshot read needed for staleness only.) + +**Exclusions.** + +- The router does **not** implement the fresh-vs-cached comparison — that is engine-side (RG-7). +- Full analytics export is R8; R7 exports staleness only. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** RG-7 — the gqtools shadow-mode PR (G-shadow) that serves fresh, performs the comparison, and emits `resolve.ShadowComparisonEvent` into the analytics snapshot. +> Bump to the tag that includes RG-7. + +**Open question to resolve before coding (carry from findings).** +`NegativeCacheTTL` interaction with shadow mode is unspecified. +Confirm with the engine PR whether negative-cache sentinels participate in shadow comparison, and document the answer in the reviewer guide rather than guessing. + +**Acceptance criteria.** + +1. With `shadow_mode: true`, a cache **hit** still issues a subgraph fetch (fresh always served) — assert exact fetch count. +2. The served response equals the fresh subgraph response, never the cached bytes — assert the full response body. +3. `ShadowComparisons` are exported as `shadowStaleness` with the correct fresh/stale split for a fixture where cache and fresh differ — assert exact counts. + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R7-shadow.md` — what shadow mode is for (de-risking a rollout), the "always serve fresh" guarantee, the single flag the router sets, what `ShadowComparisonEvent` carries, and the open `NegativeCacheTTL` question with its resolution. + +--- + +## R8 — Analytics export (OTLP / Prometheus) + +**Title:** `feat(cache): entity cache analytics export` + +**Goal.** +Export the full cache analytics snapshot to OTLP and Prometheus so operators can see hit rates, bytes served, fetch timings, mutation impact, and errors. + +**Scope.** + +- Per request: set `resolve.CachingOptions.EnableCacheAnalytics` from config (`metrics.{otlp,prometheus}.entity_caching_stats`). +- After `ResolveGraphQLResponse`, call `ctx.GetCacheStats()` **exactly once** (it snapshots and releases the pooled collector). +- `EntityCacheMetrics.RecordSnapshot(resolve.CacheAnalyticsSnapshot)` maps snapshot fields to metrics: + `L1Reads/L2Reads`, `L1Writes/L2Writes`, `FetchTimings`, `ShadowComparisons`, `MutationEvents`, `CacheOpErrors` → request/keys/latency/invalidations/populations/shadow-staleness/operation-error metrics. +- Register ristretto `Metrics()` (from R4's `MemoryEntityCache`) via a `cacheMetricSource` when configured. +- Gate the whole export on the metrics config flags; when disabled, `EnableCacheAnalytics` is false and `GetCacheStats()` returns empty with zero overhead. + +**Exclusions.** + +- No new analytics **types** invented router-side — consume only what the snapshot exposes. +- Do **not** call the low-level `CacheAnalyticsCollector.Record*`/`Merge*` methods; `GetCacheStats()` is the only sanctioned read path (those methods are flagged for possible internalization upstream — see open questions). + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** RG-8 — the gqtools analytics PR (G-analytics) exporting `CacheAnalyticsSnapshot` and all event types (`CacheKeyEvent`, `CacheWriteEvent`, `FetchTimingEvent`, `ShadowComparisonEvent`, `MutationEvent`, `CacheOperationError`, `HeaderImpactEvent`). +> Bump to the tag that includes RG-8. + +**Open questions to confirm before coding (carry from findings).** + +- Does the router actually consume `HeaderImpactEvents` and `CacheOpErrors`? + If not needed now, R8 may export a smaller subset and add them later — but the snapshot type from RG-8 should still carry them so no re-release is needed. +- Confirm `GetCacheStats()` is the only contract the router needs (vs. the exported collector methods). + Settle this against the engine PR and document the decision. + +**Acceptance criteria.** + +1. `GetCacheStats()` is called exactly once per request (assert via a spy that a second call returns empty). +2. With analytics **disabled**, no snapshot is read and there is no measurable overhead (assert the collector is never initialized). +3. For a fixture with 2 L1 hits, 1 L2 hit, 1 miss: the exported metrics show exactly those counts — assert the full metric set (no fuzzy comparisons). +4. `MutationEvents` and `CacheOpErrors` map to their metrics with exact values for a crafted fixture. + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R8-analytics.md` — the snapshot field → metric mapping table, the "call `GetCacheStats()` exactly once" rule and why (pooled collector release), the disabled-path zero-overhead guarantee, and the open questions on `HeaderImpactEvents`/collector methods. + +--- + +## R9 — Mutation + extension-based invalidation + +**Title:** `feat(cache): mutation and extension-based cache invalidation` + +**Goal.** +Wire cache deletes triggered by mutations and by subgraph-returned `extensions.cacheInvalidation.keys`. + +**Scope.** + +- Populate `resolve.ResolverOptions.EntityCacheConfigs` (subgraphName → entityType → `*EntityCacheInvalidationConfig{ CacheName, IncludeSubgraphHeaderPrefix }`) from composition. + This is the minimal config the engine needs to rebuild keys and call `LoaderCache.Delete`. +- Ensure the executor builds this map via `buildEntityCacheInvalidationConfigs` from the subgraph/type cache config. +- Translate `MutationFieldCacheConfiguration` (`EnableEntityL2CachePopulation`) and `MutationCacheInvalidationConfiguration` (`FieldName`, `EntityTypeName`) from composition (structurally wired in R1; R9 makes the delete path functional and tested end-to-end). +- The router does **not** build keys or call `Delete` itself for the engine-driven paths — the engine does, using `EntityCacheConfigs` + the per-request key transform pipeline (`GlobalCacheKeyPrefix → header prefix → L2CacheKeyInterceptor`). + +**Exclusions.** + +- No subscription invalidation (R10). +- No public "manual invalidation" router API in this PR; if added later it must reconstruct the **full** key including `GlobalCacheKeyPrefix` (call this out as a footgun in the guide). + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** RG-9 — the gqtools invalidation PR (G-invalidation) that parses `extensions.cacheInvalidation.keys`, rebuilds L2 keys through the full transform pipeline, and calls `Delete`, requiring `EntityCacheConfigs` populated and `EnableL2Cache` true. +> Bump to the tag that includes RG-9. +> **Depends on a sibling router PR:** R3 or R4 (a backend whose `Delete` is exercised). + +**Acceptance criteria.** + +1. A subgraph response with `extensions.cacheInvalidation.keys` causes exactly the matching L2 keys to be deleted — assert the exact set of deleted keys (full slice). +2. A delete for a key being **written** in the same fetch is skipped (assert that key is not deleted). +3. A mutation configured with `EntityCacheInvalidationConfiguration` deletes the returned entity's L2 key after the mutation — assert the exact key. +4. Mutations skip L2 **reads** unconditionally and skip L2 writes unless `EnableEntityL2CachePopulation` — assert fetch/write behavior for both settings. +5. The deleted key includes `GlobalCacheKeyPrefix` and the header prefix when configured — assert the full key string (this guards the "forgot the prefix" footgun). + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R9-invalidation.md` — the two invalidation triggers (mutation, subgraph extension), why `EntityCacheConfigs` is a separate minimal struct (to avoid a `resolve → plan` dependency), the full key-transform pipeline that any manual deleter must mirror, and the write-skip rule. + +--- + +## R10 — Subscription cache populate / invalidate + +**Title:** `feat(cache): subscription-driven cache populate and invalidate` + +**Goal.** +Use subscription events to keep the L2 cache warm (populate) or to evict (invalidate), and surface the write/invalidate callbacks for observability. + +**Scope.** + +- Translate `SubscriptionEntityPopulationConfiguration` from composition, setting **both** `TypeName` **and** `FieldName`. + The engine lookup `FindByTypeAndFieldName` matches on both; an empty `FieldName` silently no-ops populate/invalidate. + The router config factory MUST always set `FieldName: cp.FieldName` (populate) and `FieldName: ci.FieldName` (invalidate). +- Map `EnableInvalidationOnKeyOnly`: populate mode writes entity data per event; invalidate mode deletes L2 when the event carries only `@key` fields. +- Optionally set `resolve.ResolverOptions.OnSubscriptionCacheWrite` and `OnSubscriptionCacheInvalidate` callbacks to feed subscription cache activity into analytics/observability (these are real public API the original integration doc omitted). + +**Exclusions.** + +- No new transport work; this rides the existing subscription path. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** RG-10 — the gqtools subscription-cache PR (G-subscriptions) exposing the two `OnSubscription*` callbacks and the per-event populate/invalidate engine behavior. +> Bump to the tag that includes RG-10. +> **Depends on a sibling router PR:** R3 or R4 (a backend to write/delete against). + +**Critical footgun to lock with a test (carry from CLAUDE.md).** +If `FieldName` is empty, the lookup always fails and populate/invalidate become silent no-ops. +A regression test must assert that a config missing `FieldName` is rejected at build time (or at minimum logs a clear error and the test asserts the no-op is detected). + +**Acceptance criteria.** + +1. A populate-mode subscription event writes the entity to L2 — assert the exact L2 entry (key + value bytes). +2. An invalidate-mode event carrying only `@key` fields deletes the L2 key — assert the exact deleted key. +3. The config factory sets both `TypeName` and `FieldName`; a fixture with empty `FieldName` is caught (assert the validation error or the detected no-op). +4. `OnSubscriptionCacheWrite`/`OnSubscriptionCacheInvalidate` fire with the expected arguments — assert the full callback payloads. + +**Reviewer-guide doc.** +`docs/entity-caching/reviewer-guides/R10-subscriptions.md` — populate vs. invalidate modes, the mandatory-`FieldName` footgun and its guard test, and the two observability callbacks. + +--- + +## R11 — Documentation + config reference + +**Title:** `docs(cache): entity caching operator + integration reference` + +**Goal.** +Replace the stale integration doc with an accurate operator-facing reference and an internal integration guide, reflecting the **current** contracts. + +**Scope.** + +- Operator guide: every YAML field with defaults, the provider-ID precedence, the three dev headers and their gate, when to choose Redis vs. in-memory, shadow mode, analytics, invalidation. +- Integration/architecture note: the four router responsibilities, the thin seam, and the corrected contracts: + - `LoaderCache.Set(ctx, entries)` (per-entry TTL, no `ttl` param). + - The two subscription callbacks. + - `SubscriptionEntityPopulationConfiguration.FieldName` is mandatory. + - `EntityCacheConfiguration` has `NegativeCacheTTL` and `HashAnalyticsKeys`. + - Cache trace is gated on `ctx.TracingOptions` (Enable + not ExcludeCacheStats), not a nonexistent `WithRequestTraceOptions` helper. +- Cross-link to [02-DIRECTIVE-INVENTORY.md](02-DIRECTIVE-INVENTORY.md) for the composition-side directives that produce these configs. + +**Exclusions.** + +- No code changes (docs only); CI must still pass markdown lint. + +**Dependency on a gqtools PR (version bump).** + +> **Waits on gqtools release:** none (docs). +> **Depends on:** all prior router PRs so the documented behavior is accurate. + +**Acceptance criteria.** + +1. Every YAML key in `config.schema.json` appears in the operator guide with its default. +2. Every corrected contract above is documented; the stale `Set(ctx, entries, ttl)` and `RenderCacheKeys(ctx, fetch, keys)` signatures do not appear anywhere. +3. Markdown follows the repo line-breaking convention (new line after each sentence-ending period, and after commas in long sentences). + +**Reviewer-guide doc.** +This PR *is* documentation; the reviewer guide is a one-paragraph changelog of what was corrected from the old integration doc, with a diff-style before/after of the two load-bearing signatures. + +--- + +## Dependency graph + +```text + RG-1 release + │ + ▼ + ┌── R1 (foundation) ──┐ + │ │ + ┌────────────┼──────────┬─────────┼───────────┬───────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + R2 (L1) R3 (Redis) R4 (Memory) R6 (headers) R7 (shadow) R8 (analytics) + │ │ ▲ RG-7 ▲ RG-8 + └────┬─────┘ + ▼ + R5 (breaker) + │ + ┌────────────┴────────────┐ + ▼ ▼ + R9 (invalidation) R10 (subscriptions) + ▲ RG-9 + (R3|R4) ▲ RG-10 + (R3|R4) + └───────────┬───────────┘ + ▼ + R11 (docs, after all) +``` + +Edges marked `RG-n` are the **gqtools release gates**: that router PR cannot open until the named gqtools release ships and `go.mod` is bumped. +Edges marked `(R3|R4)` are router-internal: the PR needs at least one L2 backend present. + +--- + +## Where a router PR must wait on a gqtools release (summary) + +| Router PR | Blocking gqtools release | Why | +|-----------|--------------------------|-----| +| R1 | RG-1 (foundation) | needs all `plan.*` config types, `LoaderCache`, `CachingOptions`, factory option | +| R2 | (RG-1; or G-L1 if split) | needs `EnableL1Cache` (and the L1 engine impl if separately released) | +| R3 | RG-1 | implements `LoaderCache`; no new API | +| R4 | RG-1 | implements `LoaderCache`; no new API | +| R5 | none | decorates `LoaderCache` | +| R6 | RG-1 | flags + `GlobalCacheKeyPrefix` already shipped | +| R7 | RG-7 (shadow) | needs `ShadowComparisonEvent` + engine serve-fresh-and-compare | +| R8 | RG-8 (analytics) | needs `CacheAnalyticsSnapshot` + event types | +| R9 | RG-9 (invalidation) | needs `EntityCacheConfigs`-driven `Delete` + extension parsing | +| R10 | RG-10 (subscriptions) | needs `OnSubscription*` callbacks + per-event behavior | +| R11 | none | docs only | + +The router stack therefore tracks the gqtools stack one release behind at each gated step. +The recommended cadence: land the gqtools PR, cut a release, bump and open the matching router PR. +R2–R6 can all proceed in parallel as soon as RG-1 ships; R7/R8/R9/R10 each unlock with their named gqtools release. + +--- + +## Out-of-scope cleanups (do not bundle) + +These were folded into the original 96K-line squash and must be split into their own non-blocking chore PRs so they never block the caching review. +See [07-UNRELATED-FINDINGS.md](07-UNRELATED-FINDINGS.md) for the full list: + +- Regenerated protobuf files. +- Demo applications and example configs. +- Benchmark harnesses. + +Keeping these out of the stack is what lets each caching PR stay small and reviewable. diff --git a/docs/entity-caching/05-ASTJSON-PRIMITIVES.md b/docs/entity-caching/05-ASTJSON-PRIMITIVES.md new file mode 100644 index 0000000000..11962e0b8c --- /dev/null +++ b/docs/entity-caching/05-ASTJSON-PRIMITIVES.md @@ -0,0 +1,338 @@ +# 05 — ASTJSON Primitives: Dependency Spec + +Status: normative. +Audience: a reader with NO prior knowledge of entity caching or astjson internals. + +This document answers three questions: + +1. Which `astjson` APIs does the entity-caching foundation actually require, and why? +2. What are the contracts and safety semantics of each API? +3. Are those APIs released in the `astjson` version `graphql-go-tools` depends on, or do they exist ONLY in an open PR — and therefore, is "land the astjson primitives" a hard prerequisite (PR #0) for everything else? + +Short answer to #3: the APIs are NOT released. +They exist only on the open `astjson` PR #16 branch `feat/two-pass-parser`, pinned via a pseudo-version. +"Land + release the astjson primitives" MUST be PR #0 of the stacked plan. +Section 5 gives a concrete dependency strategy. + +Cross-links: + +- Architecture and the single integration seam: [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md) +- Foundation ADR: [adr/0001-foundation.md](adr/0001-foundation.md) +- graphql-go-tools stacked PRs (this is where PR #0 lives): [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md) +- Test + benchmark plan (astjson regression tests to carry): [06-TEST-AND-BENCH-PLAN.md](06-TEST-AND-BENCH-PLAN.md) +- Out-of-scope findings, including doc drift: [07-UNRELATED-FINDINGS.md](07-UNRELATED-FINDINGS.md) + +--- + +## 1. Why astjson at all — the one-paragraph mental model + +`astjson` is the JSON value library the resolver uses for all response data. +A parsed JSON document is a tree of `*astjson.Value` nodes (objects, arrays, strings, numbers, bools, nulls). +The resolver allocates those nodes on an arena (a bump-pointer memory block from `go-arena`) so the whole tree can be freed in one shot at the end of a request, with no garbage-collector pressure. + +Entity caching's entire job is to take a piece of that tree (an entity, e.g. a `User` object), stash it somewhere (a per-request map for L1, an external store for L2), and later splice it back into a response tree — possibly a DIFFERENT response tree, in a DIFFERENT request. +Doing that safely, cheaply, and without re-parsing JSON is exactly what the astjson primitives below provide. + +If you remember one thing: the load-bearing primitive is `StructuralCopy`. +Everything else supports it or is used at exactly one site. + +--- + +## 2. The five required primitives (and the two that ride along but are NOT required) + +### 2.1 Required by the foundation + +| Primitive | Form | Required because | +|---|---|---| +| `StructuralCopy` | `Parser` method + package func | Isolates a cache value from the live response tree on every cache write, read, and merge. THE core of L1/L2 correctness. | +| `StructuralCopyWithTransform` | `Parser` method + package func | Same isolation PLUS per-field rename / project / passthrough — powers alias normalization and arg-aware cache keys. | +| `Transform` / `TransformEntry` (with `Passthrough`) | types | The data that drives `StructuralCopyWithTransform`. `Passthrough` is the L1-vs-L2 switch. | +| `DeepCopy` (package-level, heap mode) | package func | Used at exactly ONE site: isolate per-request `Variables` onto the heap. | +| `MergeValues` / `MergeValuesWithPath` (2-return form) | package func | Folds cached + fetched data into the response tree (~12 call sites). The signature CHANGED — see 2.3. | +| value constructors + `value.MarshalTo` | methods | Build cache-key objects and serialize L2 entries to bytes. (Pre-existing, stable.) | + +### 2.2 Ships in PR #16 but NOT required by the foundation (droppable from a minimal extraction) + +- `DeepCopyWithTransform` — exists in astjson, never called by the foundation. +- `CoerceToString` — added in the same PR, not used anywhere in `resolve`. +- The two-pass arena parser is a perf optimization, NOT an API the foundation calls directly — see 2.4. It is coupled to `StructuralCopy` via shared internal slab machinery, so it cannot be cleanly dropped, but the foundation never names it. + +### 2.3 Does NOT exist in PR #16 (do not re-introduce — see doc drift) + +- `Transform.Forced` — the real struct has only `Entries`, `ArrayItem`, `Passthrough`. +- `MarshalToWithTransform` / `ParseBytesWithTransform` — not present, not used. + L2 writes use plain `MarshalTo` on an already-structurally-normalized value. + +`resolve/CLAUDE.md` references `Transform.Forced` and `MarshalToWithTransform`. +That is stale aspirational documentation, not the shipping API. +A clean extraction MUST follow the code, not the CLAUDE.md surface. +Tracked in [07-UNRELATED-FINDINGS.md](07-UNRELATED-FINDINGS.md). + +--- + +## 2.4 Contracts and semantics + +#### `StructuralCopy(a arena.Arena, v *Value) *Value` + +Also available as a `Parser` method `func (p *Parser) StructuralCopy(a, v) *Value`. +The foundation uses the Parser method (via `l.parser`) so it reuses the parser's own scratch and avoids a pool mutex. + +Semantics: +clones ONLY container nodes (objects and arrays) onto arena `a`, +while ALIASING every scalar leaf (string, number, bool, null) AND every object key string directly from the source `v`. + +Properties: + +- Cheap — a single tree walk, no byte round-trip, no re-parse. +- The result shares NO mutable container memory with the source, so mutating one tree's object/array does not corrupt the other. +- The result DOES share immutable leaf payloads (string bytes, number bits) with the source. + +Safety precondition (critical): +source and destination MUST share the same arena lifetime — same request, reset together. +If a `StructuralCopy`'d value ever outlives its source arena, the shared leaves are freed underneath it: a use-after-free. +This is why L2 writes never hand a `StructuralCopy`'d value to the external store directly — they serialize it to heap bytes via `MarshalTo` first (see 2.6). + +`StructuralCopy` is NOT a substitute for `DeepCopy` when crossing an arena/heap boundary. + +#### `StructuralCopyWithTransform(a arena.Arena, v *Value, t *Transform) *Value` + +Same container-clone / leaf-alias semantics as `StructuralCopy`, +but applies a `*Transform` to objects during the copy. + +- `t == nil` falls back to plain `StructuralCopy`. +- `a == nil` falls back to a heap-mode transform copy. +- `OutputKey` strings are copied ONTO the arena (not aliased). + This is deliberate and load-bearing: arena memory is `noscan`, so a heap string referenced only by an arena key-value pair would be invisible to the GC and could be collected. + +This single primitive powers all four Loader cache helpers (see 2.5). + +#### `Transform` / `TransformEntry` + +`Transform` has exactly three fields: + +- `Entries []TransformEntry` — rename rules (`InputKey` -> `OutputKey`); when `Passthrough == false` they ALSO project (unlisted source fields dropped). +- `ArrayItem *Transform` — applied per array element; mutually exclusive with `Entries`. +- `Passthrough bool` — the L1-vs-L2 switch. + +`TransformEntry`: + +- `InputKey string` — source field name to read. +- `OutputKey string` — destination field name to write (rename / arg-hash suffix); copied onto the arena for GC safety. +- `Child *Transform` — `nil` = plain value copy; non-`nil` = recurse. + +There is NO `Forced` field. + +`Passthrough` semantics: + +- `Passthrough == false` (L2 write): + output contains ONLY the fields listed in `Entries` (rename + project; unlisted dropped). + L2 entries are minimal and self-contained. +- `Passthrough == true` (L1 write): + listed fields are renamed, and UNLISTED source fields pass through verbatim. + This preserves `@key` fields not in `ProvidesData`, plus fields accumulated by sibling fetches across the request. + +Collision rule (important for correctness): +rename wins. +If an `OutputKey` was emitted and a same-named source field also exists, the source field is dropped to avoid a duplicate JSON key. +If the rename could NOT emit (source missing the `InputKey`), the slot is not claimed and the source field passes through. + +Plan/fill alignment hazard (silent corruption): +`StructuralCopyWithTransform` internally runs a counting pass and a fill pass that MUST make byte-for-byte identical structural decisions, including the passthrough field-skip and the de-duplication of duplicate source keys under `Passthrough`. +The exact pinned commit `f600d161463f` IS the fix for a misalignment bug here (duplicate keys under `Passthrough` panicking during marshal). +A clean re-implementation must port that dedupe logic and the consumption guard, and carry its regression tests. +See [06-TEST-AND-BENCH-PLAN.md](06-TEST-AND-BENCH-PLAN.md). + +#### `DeepCopy(a arena.Arena, v *Value) *Value` + +Clones EVERYTHING, including scalar string/number payloads, onto the destination. +The result shares NO mutable memory with `v`. + +- `a != nil` -> arena-allocated copy. +- `a == nil` -> heap-allocated, always independent. +- Immutable singletons (`valueTrue`/`valueFalse`/`valueNull`) are shared, not cloned. +- `nil` in -> `nil` out. + +Purpose: safely move a value across a heap<->arena boundary. +Without it, an arena (which is `noscan`) could hold the only reference to a heap value and the GC would collect it -> use-after-free. + +The foundation uses `DeepCopy` in EXACTLY ONE place: +`context.go:398` — `astjson.DeepCopy(nil, c.Variables)` — heap-mode deep copy to isolate per-request variables. +Verified present in the worktree. +The `DeepCopyWithTransform` variant exists in astjson but is NOT used by the foundation. + +#### `MergeValues` / `MergeValuesWithPath` — BREAKING signature change + +New form (what the foundation compiles against): + +```go +func MergeValues(ar arena.Arena, a, b *Value) (*Value, error) +func MergeValuesWithPath(ar arena.Arena, a, b *Value, path ...string) (*Value, error) +``` + +Previous form (released `v1.0.0` / `v1.1.0`): +`(*Value, changed bool, error)` — the `changed bool` return was DROPPED. + +These functions are pre-existing (not new in PR #16), but the dropped return value means the foundation will NOT compile against `astjson v1.0.0` / `v1.1.0`. +The foundation calls them ~12 times in `loader.go` (verified) to fold cached + fetched data into the response tree. + +Two semantics matter for caching: + +- `MergeValues` ALIASES nested container nodes from `b` into `a`. + This is precisely why `StructuralCopy` must isolate a cache value BEFORE merging it — otherwise the longer-lived response tree (`a`) ends up aliasing cache-owned containers (`b`). +- `MergeValues` is NON-ATOMIC on failure: a partial mutation can corrupt the destination. + This is why the L1 merge-into-existing path uses working-copy-and-swap (StructuralCopy the live entry, merge into the copy, store the copy on success or the fresh incoming value on failure) — never mutate a live cache entry in place. + +#### value constructors + `MarshalTo` + +Pre-existing, stable API surface: +`ObjectValue` / `ArrayValue` / `StringValue` / `StringValueBytes` / `NumberValue` / `IntValue` / `FloatValue` / `TrueValue` / `FalseValue(a arena.Arena)`, plus the `NullValue` global const, plus `func (v *Value) MarshalTo([]byte) []byte`. + +The foundation builds cache-key objects with the constructors and serializes L2 entries to heap bytes with `MarshalTo` (verified: `loader_cache.go` uses plain `MarshalTo`, NOT a `WithTransform` variant — the value is structurally normalized FIRST, then marshaled). + +#### Two-pass arena parser (internal — not called by the foundation) + +`ParseWithArena` / `ParseBytesWithArena` are UNCHANGED public entry points. +When `arena != nil` they now run two internal passes (`parseArenaTwoPass`, `planArenaParse`): +Pass 1 plans (validates, records exact totals and per-container counts, allocates no tree nodes); Pass 2 fills (one exactly-sized arena slab per node kind, bump-pointer fill, strings sliced from recorded spans). +Heap mode (`a == nil`) stays single-pass. +`true`/`false`/`null` produce shared singletons consuming no slab slot. +`finish()` verifies every slab/plan list was consumed exactly and returns a BUG error otherwise. + +Why it is in scope here even though the foundation never names it: +`StructuralCopy` REUSES the same plan+fill slab architecture, so the two are coupled in the same source file and the same PR. +A minimal extraction cannot ship `StructuralCopy` without the shared machinery. + +--- + +## 2.5 The single integration seam in graphql-go-tools + +All transform-driven copies are funneled through one file: +`v2/pkg/engine/resolve/loader_cache_transform.go`. +Four Loader helpers wrap the two Parser copy methods (all verified present in the worktree): + +| Helper | Path | Transform config | +|---|---|---| +| `structuralCopyNormalized` | L2 write | rename alias->schema + arg-hash, `Passthrough=false`, project to `ProvidesData` | +| `structuralCopyDenormalized` | L2 read | schema->alias, project | +| `structuralCopyNormalizedPassthrough` | L1 write | rename but keep all fields, `Passthrough=true` | +| `structuralCopyDenormalizedPassthrough` | L1 read | restore alias, keep all, `Passthrough=true` | + +`buildNormalizeTransform` / `buildDenormalizeTransform` construct ephemeral `*Transform` trees on reusable `l.transformEntries []astjson.TransformEntry` and `l.transforms []astjson.Transform` slabs. + +Other consuming sites: + +- `loader.go` — `StructuralCopy` at every cache<->response-tree merge boundary (entity splice into batch arrays, full/partial L1 hit merge, working-copy-and-swap); plus the ~12 `MergeValues` / `MergeValuesWithPath` call sites. +- `loader_cache.go` — L2 write path: structural-normalize then `value.MarshalTo(nil)` to produce heap bytes for the external cache; working-copy-and-swap L1 merge-into-existing. +- `context.go:398` — the sole `DeepCopy(nil, ...)` site. + +Architecture detail of these seams: [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md). + +--- + +## 3. Release status — the crux + +The released astjson tags are `v1.0.0` and `v1.1.0` (latest, 2026-02-20). +The three API-bearing source files — `parser_deep_copy.go`, `parser_arena_twopass.go`, `transform.go` — DO NOT EXIST at the `v1.1.0` tag (404 on the tag). + +Both `graphql-go-tools` modules pin an UNRELEASED pseudo-version (verified in the worktree): + +- `v2/go.mod:31` -> `github.com/wundergraph/astjson v1.1.1-0.20260419105127-f600d161463f` +- `execution/go.mod:18` -> same pseudo-version. + +That pseudo-version resolves to commit `f600d161463f` dated 2026-04-19, which lives on the OPEN PR #16 branch `feat/two-pass-parser`. +`master` of graphql-go-tools pins `astjson v1.0.0`. + +Consequence: +the entity-caching foundation cannot be reviewed or merged against `master`'s `astjson v1.0.0`. +It depends on code that exists only in an open upstream PR, referenced by commit hash. +This is the single highest-priority risk in the whole project. + +`go-arena` is consistent: PR #16 bumps `go-arena` to `v1.2.0` and both graphql-go-tools modules already pin `go-arena v1.2.0` (verified) — no coordinated bump needed there, but confirm it stays aligned when the astjson tag is cut. + +--- + +## 4. Therefore: "land astjson primitives" is PR #0 + +Because the required APIs are unreleased, the FIRST step of the stacked plan — before any caching code can be reviewed or merged on its own dependency graph — is to land and release the astjson primitives upstream, then bump graphql-go-tools to that real tag. + +PR #0 must deliver, in `wundergraph/astjson`: + +1. `StructuralCopy` + `StructuralCopyWithTransform` as `Parser` methods (and package-level funcs). +2. `Transform` / `TransformEntry` with `Entries` / `ArrayItem` / `Passthrough` (and NO `Forced`). +3. Package-level `DeepCopy` (heap-mode behavior required). +4. The 2-return `MergeValues` / `MergeValuesWithPath`. +5. Value constructors + `MarshalTo` (already present; confirm stable). +6. The two-pass arena parser, including the `f600d161463f` Passthrough-dedupe fix and the `finish()` BUG-check consumption guard, with regression tests. + +PR #0 may DROP, to shrink the reviewable surface (none are transitively required by the kept pieces — confirm via the open question in section 7): + +- `DeepCopyWithTransform`, `CoerceToString`, and any escape-flag / coerce / benchmark churn that is not needed by `StructuralCopy` / `DeepCopy` / `Transform` / two-pass. + +Sequencing in the broader plan lives in [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md); the foundation ADR records the decision in [adr/0001-foundation.md](adr/0001-foundation.md). + +--- + +## 5. Recommended dependency strategy (concrete) + +Three options, in order of preference. + +### Option A — Land astjson PR #16, cut a real tag, bump (RECOMMENDED) + +1. Review and merge the astjson primitives upstream (the PR #0 content from section 4). +2. Cut a real, immutable astjson tag. + This is a minor/major bump, NOT a patch — the `MergeValues` `changed bool` removal is a breaking change for any external consumer on the 3-return form. +3. Bump `astjson` in both `v2/go.mod` and `execution/go.mod` from the pseudo-version `v1.1.1-0.20260419105127-f600d161463f` to the new real tag. +4. Confirm `go-arena v1.2.0` stays aligned across the workspace. + +Why preferred: +clean, reviewable, reproducible; no pseudo-versions or `replace` directives in merged `master`; other astjson consumers migrate intentionally at the tag boundary. + +### Option B — Pin the pseudo-version (the CURRENT state — acceptable only as interim) + +The repo already does this. +It compiles and resolves to a real commit, so development can proceed. +But a pseudo-version pointing at an OPEN PR branch must NOT be the state in which the foundation merges to `master`: +the upstream branch can be force-pushed, rebased, or closed, breaking the build retroactively. +Treat this strictly as the pre-PR-#0 development state and gate merge of the foundation on Option A completing. + +### Option C — `replace` directive to a local/fork checkout (development only) + +Use a `go.mod` `replace` pointing at a local astjson checkout while iterating on both repos simultaneously. +Never merge a `replace` to `master`. +Useful only when actively co-developing the astjson primitives and the caching foundation in the same session; remove before review. + +### Decision + +Adopt Option A as the merge gate. +Option B is the legitimate development-time state (and is what the worktree is in today). +Option C is for local co-development only. +Do not merge the caching foundation while astjson is referenced by pseudo-version or `replace`. + +--- + +## 6. Safety invariants checklist (carry into review) + +- Every cache WRITE `StructuralCopy`s INTO the cache. +- Every cache READ `StructuralCopy`s OUT of the cache before use. +- Every merge-into-response-tree `StructuralCopy`s the read value BEFORE `MergeValues` (because `MergeValues` aliases `b`'s containers into `a`). +- `StructuralCopy`'d values NEVER outlive their source arena. + L2 hand-off to an external store goes through `MarshalTo` to heap bytes first. +- L1 merge-into-existing uses working-copy-and-swap, never in-place mutation (because `MergeValues` is non-atomic on failure). +- `Transform` `OutputKey` strings are arena-copied, not aliased (GC safety on `noscan` arena memory). +- Two-pass plan and fill, and `StructuralCopyWithTransform` count and fill, make byte-identical structural decisions (port the `f600d161463f` dedupe + the `finish()` BUG check; carry the regression tests). + +--- + +## 7. Open questions (resolve before/during PR #0) + +1. Cut the astjson release from PR #16 as-is (DeepCopy + StructuralCopy + two-pass + Transform + the 2-return MergeValues together), or split further (two-pass parser as one release, copy APIs as another)? + The foundation needs StructuralCopy + Transform + 2-return MergeValues; the two-pass parser is coupled via shared slab code but is conceptually separable. +2. Is the `MergeValues` `changed bool` removal actually required by the foundation, or incidental cleanup? + A grep shows all ~12 foundation call sites use the 2-tuple form, so none NEED the dropped `changed` value. + If kept backward-compatible, the astjson release could be a non-breaking minor and other consumers would not be forced to migrate. + Confirm and decide whether to keep the breaking change or restore compatibility. +3. Can the minimal extraction drop `DeepCopyWithTransform`, `CoerceToString`, and the escape-flag / coerce / benchmark churn, keeping only StructuralCopy(+WithTransform), DeepCopy, Transform/TransformEntry, the two-pass parser, and the MergeValues signature? + Confirm none of the dropped pieces are transitively required by the kept ones. +4. Does `go-arena` need a coordinated bump? + PR #16's release notes bump `go-arena` to `v1.2.0`; graphql-go-tools already pins `v1.2.0`. + Confirm the astjson release and graphql-go-tools agree on the `go-arena` version to avoid a workspace mismatch. diff --git a/docs/entity-caching/06-TEST-AND-BENCH-PLAN.md b/docs/entity-caching/06-TEST-AND-BENCH-PLAN.md new file mode 100644 index 0000000000..11b2080462 --- /dev/null +++ b/docs/entity-caching/06-TEST-AND-BENCH-PLAN.md @@ -0,0 +1,823 @@ +# 06 — Test & Benchmark Plan + +> Part of the entity-caching re-implementation document set. +> Navigation: [00-OVERVIEW](./00-OVERVIEW.md) · +> [01-ARCHITECTURE-SPEC](./01-ARCHITECTURE-SPEC.md) · +> [02-DIRECTIVE-INVENTORY](./02-DIRECTIVE-INVENTORY.md) · +> [03-PR-PLAN-graphql-go-tools](./03-PR-PLAN-graphql-go-tools.md) · +> [04-PR-PLAN-router](./04-PR-PLAN-router.md) · +> [05-ASTJSON-PRIMITIVES](./05-ASTJSON-PRIMITIVES.md) · +> **06-TEST-AND-BENCH-PLAN (this file)** · +> [07-UNRELATED-FINDINGS](./07-UNRELATED-FINDINGS.md) · +> [08-EXECUTION-RUNBOOK](./08-EXECUTION-RUNBOOK.md) + +--- + +## 0. Who this document is for + +You are an engineer (or an AI agent) about to re-implement the entity-caching +feature as a stack of small PRs. +You have never seen this feature before. +This document tells you, for every PR, **what tests prove the PR is correct**, +**what benchmarks guard against performance regressions**, +and **the non-negotiable rules every test must follow**. + +The single most important idea: +this feature already has a complete, working test suite on the +`entity-caching-v2` branch. +We are not inventing tests from nothing. +We are **re-deriving** a clean, stacked test suite from a proven reference suite, +keeping its taxonomy, its conventions, and its assertions, while splitting the +large monolithic test files into reviewable per-PR slices. + +Two source trees matter throughout this document. + +- The **reference tree** (already implemented, the thing we copy patterns from): + `entity-caching-v2/v2/pkg/engine/resolve/` and + `entity-caching-v2/execution/engine/`. +- The **target tree** (where we re-implement): + the same relative paths under the re-implementation worktree. + +All file paths below are relative to the repo root unless stated otherwise. + +--- + +## 1. The two test tiers + +The suite splits cleanly into two layers. +Understand this split before writing a single line. + +### 1.1 Unit tier — white-box, package `resolve` + +Location: `v2/pkg/engine/resolve/`. +These tests reach inside the package. +They build a `Loader` struct literal directly, inject a gomock `DataSource`, +drive `Loader.LoadGraphQLResponseData`, and assert on rendered JSON, on cache +state, and on internal helper return values. +They are fast, deterministic, and exercise the mechanics: cache-key rendering, +L1/L2 copy invariants, merge sites, analytics collection, negative/mutation/ +request-scoped paths. + +### 1.2 E2E tier — black-box, package `engine_test` + +Location: `execution/engine/`. +These tests stand up a **real 3-subgraph federation gateway** +(accounts / products / reviews) over HTTP, send GraphQL through it, and assert on +the HTTP response body, on a recorded cache-operation log, on subgraph HTTP call +counts, and on a full `CacheAnalyticsSnapshot` returned via the +`X-Cache-Analytics` response header. +They prove the feature works end to end through real planning, real fetch trees, +and a real (fake-backed) cache. + +> **Do not blur the tiers.** +> A copy-invariant bug belongs in a unit test. +> A "two requests, second one hits cache, zero subgraph calls" assertion belongs +> in an E2E test. +> If you find yourself standing up a gateway to test cache-key string rendering, +> stop — that is a unit test. + +--- + +## 2. MANDATORY conventions — the checklist + +These rules are enforced by `CLAUDE.md`, +`v2/pkg/engine/resolve/CLAUDE.md`, and `execution/engine/CLAUDE.md`. +They are machine-checkable and reviewers will reject violations. +Treat this as a pre-flight checklist for **every** test you write or edit. + +### 2.1 Universal rules (both tiers) + +- [ ] **Exact assertions only.** + Use `assert.Equal` with the full expected value. + Never `assert.Contains`, `assert.GreaterOrEqual`, `assert.Greater`, + `assert.Less`, or any fuzzy comparison. + A fuzzy comparison is a code smell: it means you did not compute the real value. + Investigate until you know the exact value, then assert it. +- [ ] **Assert entire structs inline.** + Assert a whole `CacheAnalyticsSnapshot` or a whole `[]CacheLogEntry` in one + `assert.Equal`. + Never loop over fields with individual assertions. + For large structs, write the full expected value inline anyway — readability + inside the test beats line count. +- [ ] **Inline literal data.** + GraphQL queries, cache keys, byte sizes, expected JSON responses appear + inline at the assertion or setup site that uses them. + Never in file-level `const` blocks or shared vars that force a reviewer to + scroll away. +- [ ] **Snapshot "why" comments.** + Every event line in a `CacheAnalyticsSnapshot` (or any event-stream / cache-log + assertion) carries a trailing comment explaining **why** that event occurred + (the causation), not what its value is. + Good: `// First request, L2 empty`. + Bad: `// this is a miss`. +- [ ] **Cache-log clear+assert pairing.** + Every `cache.ClearLog()` is followed by `cache.GetLog()` plus full assertions + **before** the next `ClearLog()` or the end of the test. + Never clear a log without first verifying its contents. +- [ ] **Vertical multi-key literals.** + Any struct literal with two or more nested slices/maps/long-string fields + (cache-log entries, snapshot events) wraps one item per line. + Never a 200-character single line. +- [ ] **Modern Go.** + Before writing Go, load the project guidelines via the `/use-modern-go` skill. + Use range-over-int, range-over-func where it reads cleaner, `errors.Is`/`As`, + structured idioms. + No legacy `for i := 0; i < n; i++` where `for i := range n` reads better. + Benchmarks use `for b.Loop()`, not `for i := 0; i < b.N; i++`. + +### 2.2 Unit-tier-only rules (package `resolve`) + +- [ ] **Singleflight off.** + Set `ctx.ExecutionOptions.DisableSubgraphRequestDeduplication = true`. + Forgetting this silently changes fetch counts. + This is the most commonly-forgotten line — see [§9 risk](#9-known-risks-and-anti-patterns). +- [ ] **Caching flags explicit.** + Set `ctx.ExecutionOptions.Caching = CachingOptions{EnableL1Cache: ..., EnableL2Cache: ...}` + for every test, even when both are false. +- [ ] **Arena every time.** + `arena.NewMonotonicArena(arena.WithMinBufferSize(1024))`, then + `NewResolvable(ar, ResolvableOptions{})`, then + `resolvable.Init(ctx, nil, ast.OperationTypeQuery)`. +- [ ] **Shared setup helpers are allowed here** (the no-shared-helper rule is + E2E-only) — a small `newCachingTestLoader(...)` inside the package is fine and + is the recommended home for the singleflight-off + arena boilerplate. + +### 2.3 E2E-tier-only rules (package `engine_test`) + +- [ ] **Self-contained subtests.** + Each `t.Run` reads top-to-bottom on its own. + **Duplication is preferred over sharing.** + Do NOT extract `newXxxFederationTestEnv(...)` helpers. + Do NOT lift `SubgraphCachingConfigs` / `CachingOptions` into a top-level var + used by one subtest — inline them into the subtest body. +- [ ] **Inline GraphQL.** + Use `QueryStringWithHeaders` with the query string inline. + Do not load queries from `.query` files via `cachingTestQueryPath(...)` + (that helper is legacy — see [§9](#9-known-risks-and-anti-patterns)). +- [ ] **Full snapshot assertions.** + Assert the entire normalized `CacheAnalyticsSnapshot`, not a partial one. +- [ ] **Header `.Clone()`.** + Any `http.Header` returned from a mock must be `.Clone()`'d — the HTTP client + mutates the map in place, which corrupts header hashes used in cache keys. +- [ ] **Subscription cleanup immediate.** + Register `t.Cleanup(closeFn)` immediately after creating a subscription/setup — + a `t.Fatal`/`require` triggers `runtime.Goexit` and skips later explicit closes. +- [ ] **No new shared helpers** in `execution/engine/` without explicit approval. + The one sanctioned shared-helper file is `federation_caching_helpers_test.go` + (the fake cache, call tracker, gateway builder, snapshot normalizer). + +### 2.4 The acceptance-criteria sync rule (hard requirement) + +Whenever you add or modify a caching test you **must** update +`docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md`: +every acceptance criterion links to its covering tests with a relative path, +a line number, and the test name; +every new test links back to its AC; every new AC needs at least one test link. +Treat the AC doc as **the index of which test proves which behavior**. +The `@requestScoped` block AC-RS-01..07 must stay covered. +A PR that touches tests but not the AC doc is incomplete. + +--- + +## 3. Unit-tier taxonomy (what file proves what) + +The reference suite groups unit tests by concern. +The re-implementation keeps these groupings but slices each into per-PR files. +For each group below: the **concern**, the **reference file** to copy patterns +from, and the **key behaviors** it must prove. + +### 3.1 Cache-key rendering + +Reference: `cache_key_test.go`, `cache_key_parity_test.go`, +`l2_cache_key_interceptor_test.go`. + +Proves the `CacheKeyTemplate.RenderCacheKeys` contract end to end: + +- `RootQueryCacheKeyTemplate` renders `{"__typename":"Query","field":F,"args":{...}}` + for no-args, single arg, multiple args, string/bool/array/object/null/missing + arg shapes. +- `EntityQueryCacheKeyTemplate` renders `{"__typename":T,"key":{...}}` for single + key, composite key, array key fields, nested key fields, with prefix. +- `DerivedEntityCacheKey` via `EntityKeyMappings`: simple ID, integer→string + coercion (an int arg and a string arg that name the same value produce + identical keys), nested object path, deep path, array-index path (empty/null → + skip caching, zero keys produced), multiple key fields, dot-notation merging + into one object, multiple mappings → multiple keys, partial-missing skips only + that one key, prefix, variable remapping. +- Read/write/invalidation **key parity** — the same logical entity yields the + same key on the read path, the write path, and the invalidation path. +- `L2CacheKeyInterceptor` transform (e.g. tenant isolation) applied after the + header-hash prefix. + +Assertion shape: build the template struct directly, call +`tmpl.RenderCacheKeys(arenaOrNil, ctx, []*astjson.Value{data}, prefix)`, +and `assert.Equal` the full `[]*CacheKey` slice — including the `Item` pointer. +The number-coercion case is table-driven; assert the full `Keys` slice per row. + +### 3.2 L1 cache + +Reference: `l1_cache_test.go`, `l1_cache_normalize_test.go`, +`l1_l2_cache_e2e_test.go` (in-package end-to-end with a mock `DataSource`). + +Proves: dedup (same entity fetched twice → one fetch), partial-loading +(`EnablePartialCacheLoad` true and false), L1-only partial, nested entities, +`UseL1Cache`-disabled gate; plus the normalize machinery — alias validation, +projected copy, `ComputeHasAliases`, arg-suffix, `mergeEntityFields`, +`validateItemHasRequiredData`. + +### 3.3 Loader internals + +Reference: `loader_cache_test.go`, `loader_cache_merge_test.go`, +`loader_cache_phase2_test.go`, `loader_cache_transform_test.go`, +`cache_load_test.go`, `cache_utility_coverage_test.go`. + +Proves the phase machinery and the StructuralCopy transform helpers +(`structuralCopyNormalized` / `Denormalized` and their passthrough variants) +in isolation. + +### 3.4 Batch entity caching + +Reference: `batch_entity_cache_test.go`. + +Proves: all-miss → all-hit, partial-hit (only missing entities refetched), +multi-candidate projection merge, negative hit, analytics accounting, +mutation-skip, tracing, L2-disabled, interceptor interaction. + +### 3.5 Negative cache + +Reference: `negative_cache_test.go`. + +Proves: null-sentinel store / serve / TTL / mutation-interaction / +overwrite-after-expiry lifecycle, plus nullable-field regression guards. + +### 3.6 Mutation cache + +Reference: `mutation_cache_test.go`. + +Proves the helpers `navigateProvidesDataToField`, `buildEntityKeyValue`, +`buildMutationEntityCacheKey`, `detectMutationEntityImpact`, and the TTL override. + +### 3.7 @requestScoped coordinate L1 + +Reference: `request_scoped_test.go`. + +Proves: injection (no-hints → false; missing L1 key → false; field-widening +check rejects a narrow cached value against a wider `ProvidesData`; +collect-then-inject all-or-nothing; L1-gating on `EnableL1Cache`), export +(copy-on-export via `structuralCopyNormalized`), round-trip, copy-independence +(mutate the response, assert L1 intact), alias handling +(`{subgraph}.{key}` is alias-independent, stored value normalized to schema +names, denormalized read re-applies the query alias), `ProvidesData` shapes, +synthetic alias, GC-survival and arena-residency +(a reflection helper proving injected values live on the request arena under +`debug.SetGCPercent(1)` plus heap churn). + +### 3.8 Extensions invalidation + +Reference: `extensions_cache_invalidation_test.go`. + +Proves: a subgraph that returns `extensions.cacheInvalidation.keys` triggers a +`Delete`, with the skip-delete-if-being-written optimization. + +### 3.9 Cache analytics + +Reference: `cache_analytics_test.go` (very large — split per concern in the +re-implementation, see [§9](#9-known-risks-and-anti-patterns)). + +Proves: collector record/merge, field hashing, entity counts, derived metrics +(`L1HitRate`, `L2HitRate`, `CachedBytesServed`), shadow freshness, snapshot +independence. + +### 3.10 Copy invariants (the load-bearing four) + +Reference: `loader_cache_copy_invariant_test.go`. + +Exactly four adversarial tests, one per StructuralCopy merge site: + +- `TestCopyInvariant_MergeBatchCacheHit` +- `TestCopyInvariant_MergeBatchPartialResponse` +- `TestCopyInvariant_MergeResultCacheSkipFetch` +- `TestCopyInvariant_MergeResultPartialCache` + +Each merges, then mutates a nested container under the merged value +(e.g. `mergedValue.Get("profile")`), then asserts the cache entry's `FromCache` +stays intact. +Removing any one of the four StructuralCopies corrupts the nested container and +the test fails. +These four pair 1:1 with the four `BenchmarkMerge*` benches and with the +**Copy Budget** table in `v2/pkg/engine/resolve/CLAUDE.md`. +See [§7](#7-the-copy-budget-triangle). + +--- + +## 4. E2E-tier taxonomy (what file proves what) + +Reference files live in `execution/engine/`. +The shared plumbing lives in `federation_caching_helpers_test.go` (the one +sanctioned shared-helper file). + +| Concern | Reference file | Proves | +|---|---|---| +| Basics | `federation_caching_test.go` | miss-then-hit, mutation-skips-L2-read, plan-time typename | +| L1 | `federation_caching_l1_test.go` | HTTP-call reduction, field accumulation w/ aliases, 3-fetch accumulation, interface/union, self-referential, child entity list, nested list dedup, root-field entity-list population, union-of-provider-fields, entity-union optimization | +| L2 | `federation_caching_l2_test.go` | L2-only, L1+L2 combined, partial entity fetch, root-field caching, error-skips-cache, mutation invalidation | +| Batch | `federation_caching_batch_test.go` | batch all-miss/all-hit/partial-hit through the gateway | +| Root args | `federation_caching_root_args_test.go`, `federation_caching_root_entity_test.go`, `federation_caching_root_split_test.go` | root-field caching variants + L1 promotion short-circuit | +| Entity field args | `federation_caching_entity_field_args_test.go` | arg-aware xxhash suffix keys | +| Extensions | `federation_caching_ext_invalidation_test.go` | extension-driven `Delete` + analytics | +| Analytics | `federation_caching_analytics_test.go` | full-snapshot assertions (split per concern in re-impl) | +| Trace | `federation_caching_trace_test.go` | cache trace UI fields (L1Hit/L1Miss, LoadSkipped) | +| Source | `federation_caching_source_test.go` | `FieldSource` tracking | +| Remap vars | `federation_caching_remap_variables_test.go` | per-request variable remapping in cache keys | +| Subscription | `federation_subscription_caching_test.go` | populate/invalidate via `NewManualFederationSetup` + product subscription `Emit()` | +| Partial | `partial_cache_test.go` | partial cache loading through the gateway | +| @requestScoped widening | `request_scoped_widening_e2e_test.go` | documented EXCEPTION — package `engine`, top-level recorder helpers | +| @requestScoped | `federation_caching_request_scoped_test.go` | currently `t.Skip` pending planner work | + +### 4.1 The canonical E2E shape + +Every E2E caching subtest follows the same skeleton. +Inline everything. +Request 1: drive a query, then `cache.GetLog()` and `assert.Equal` the full log +(get-miss + set per entity type) **and** assert subgraph call counts equal 1, +**and** assert the full response body. +`cache.ClearLog()`. +Request 2: drive the same query, assert the full all-hit get log, assert call +counts equal 0, assert the identical response body. +Always pair `ClearLog → GetLog + assert` around each request. + +### 4.2 E2E infrastructure (in `federation_caching_helpers_test.go`) + +Carry these forward verbatim — they are the seam every test plugs into. + +- `FakeLoaderCache` — implements `resolve.LoaderCache`; records a + `[]CacheLogEntry{Operation, Items: []CacheLogItem{Key, Hit, TTL}}` log; + `ClearLog` / `GetLog` / `Peek` (no-log read); `WaitForOperation` (channel for + async subscription assertions); `setCurrentTime` (deterministic TTL); copies + bytes in and out to prevent aliasing. + `CacheOperation` ∈ `{"get","set","delete"}`. +- `subgraphCallTracker` — an `http.RoundTripper` wrapper counting requests per + host; `GetCount(host)` / `Reset` / `GetCounts`. +- `addCachingGateway(opts...)` — functional-options builder + (`withCachingLoaderCache`, `withHTTPClient`, `withCachingOptionsFunc`, + `withSubgraphEntityCachingConfigs`, `withDebugMode`, `withResolverOptions`, + `withRemapVariables`, ...) wrapping `federationtesting.NewFederationSetup`. +- `parseCacheAnalytics` — reads the `X-Cache-Analytics` response header into a + `CacheAnalyticsSnapshot`. +- `normalizeSnapshot` — sorts all event slices, zeros non-deterministic + `CacheAgeMs` / `DurationMs`, nulls `FetchTimings`, collapses empty slices to + nil. `normalizeFetchTimings` preserves fields but zeros `DurationMs`. + `sortCacheLogEntries` for non-deterministic key order. +- `typenameStrippingTransport` — simulates a non-compliant subgraph. +- Two `SubgraphHeadersBuilder` mocks — a manual-hash mock and a real + header-forwarding mock (the latter `.Clone()`s its headers). + +### 4.3 Federation test services + +`accounts`, `products`, `reviews` live under `execution/federationtesting/`. +The standard entity graph used across all E2E caching tests: +accounts owns `Query.me → User{id:1234, username:Me}`; +reviews extends `User` with `reviews[]` and `Product` with `reviews[]`; +products owns `Query.topProducts → Product{upc: top-1 Trilby, top-2 Fedora}`. +Canonical cache keys you will see in assertions: +`{"__typename":"Query","field":"topProducts"}`, +`{"__typename":"Product","key":{"upc":"top-1"}}`, +`{"__typename":"User","key":{"id":"1234"}}`. +Query tests use `NewFederationSetup`; +subscription tests use `NewManualFederationSetup` plus +`setup.NextProductSubscription(ctx).Emit()` to drive deterministic events. + +--- + +## 5. Per-directive test matrix (what each PR must prove) + +Each directive ships as a PR (see +[03-PR-PLAN-graphql-go-tools](./03-PR-PLAN-graphql-go-tools.md) and the +per-directive specs under `directives/`). +For each directive: the unit tests it must add (and the reference file to mirror), +the E2E tests it must add, and the acceptance-criteria range it satisfies. +A directive PR is not done until **both** tiers pass and the AC doc links them. + +### 5.1 `@key` entity caching (L1 + L2) + +Specs: [directives/key.md](./directives/key.md), +[adr/0002-key-entity-caching.md](./adr/0002-key-entity-caching.md). + +**Unit** (mirror `cache_key_test.go`, `l1_cache_test.go`, +`batch_entity_cache_test.go`): + +- `EntityQueryCacheKeyTemplate` table: single key, composite key, array/nested + key fields, prefix — assert the full `{"__typename":T,"key":{...}}` string. +- L1 dedup: same entity twice → one fetch; field accumulation across fetches; + `UseL1Cache`-disabled gate. +- Batch: all-miss → all-hit; partial-hit (only missing entities refetched); + multi-candidate projection merge. + +**E2E** (mirror `federation_caching_l2_test.go`): +request 1 asserts the full cache log (get-miss + set per entity type) **and** +subgraph call counts = 1; request 2 asserts the all-hit get log **and** call +counts = 0; both requests assert the identical full response body. +Opt-in via +`SubgraphCachingConfigs{EntityCaching: EntityCacheConfigurations{{TypeName, CacheName, TTL}}}`. + +### 5.2 Root-field caching + `EntityKeyMappings` + +Specs: [directives/root-field-caching.md](./directives/root-field-caching.md), +[adr/0003-root-field-caching.md](./adr/0003-root-field-caching.md). + +**Unit** (mirror the `TestDerivedEntityCacheKey` table in `cache_key_test.go`): +`RootQueryCacheKeyTemplate` for no-args / single / multiple / string / bool / +array / object / null / missing args, plus `EntityKeyMappings` derivation — +simple ID, integer→string coercion, nested object path, deep path, +array-index path (empty/null → skip caching, zero keys), multiple key fields, +dot-notation merge, multiple mappings → multiple keys, partial-missing skips +that one key only, prefix, variable remapping. +Assert the full `Keys` slice per case. + +**E2E** (mirror `federation_caching_root_args_test.go` / +`_root_entity_test.go` / `_root_split_test.go`): +assert the root-field L2 get/set log; assert that root-field L1 promotion lets a +later entity fetch short-circuit (call count drops); +smart-backfill — assert the requested-vs-rendered key write decisions via +`cacheKeysToExactRootFieldEntityEntries` on a value mismatch. + +### 5.3 `@requestScoped` coordinate L1 + +Specs: [directives/request-scoped.md](./directives/request-scoped.md), +[adr/0004-request-scoped.md](./adr/0004-request-scoped.md). +AC range: **AC-RS-01..07**. + +**Unit** (mirror `request_scoped_test.go`): +`TestTryRequestScopedInjection` (no-hints → false; missing L1 key → false; +field-widening check; collect-then-inject all-or-nothing; L1-gating); +`TestExportRequestScopedFields` (copy-on-export); +`TestRequestScopedRoundTrip`; +`TestExportedValuesAreIndependentCopies` (mutate response → L1 intact); +`TestRequestScopedAliasHandling` + `TestRequestScopedProvidesDataShapes` + +synthetic-alias; +GC-survival + arena-residency. +Build the `RequestScopedField{FieldName, FieldPath, L1Key:"{subgraph}.{key}", ProvidesData:*Object}` +inline. + +**E2E** (mirror `federation_caching_request_scoped_test.go` — +currently `t.Skip`): +assert that symmetric dedup reduces subgraph call counts with **exact** +counts. +Do NOT copy the skipped tests' temporary fuzzy `if reviewsCalls == 0` smoke +checks — those violate the exact-assertion rule. +The sanctioned style exception `request_scoped_widening_e2e_test.go` +(package `engine`, top-level recorder helpers) stays as the one documented +exception; do not replicate its style elsewhere. + +> **Open question for this PR** (see [§10](#10-open-questions)): +> are the skipped E2E tests un-skipped now (planner work landed) so AC-RS-01..07 +> get real E2E coverage, or do they stay skipped with unit coverage only? + +### 5.4 Mutation invalidation + +Specs: [directives/mutation-invalidation.md](./directives/mutation-invalidation.md). + +**Unit** (mirror `mutation_cache_test.go`): `navigateProvidesDataToField`, +`buildEntityKeyValue`, `buildMutationEntityCacheKey`, +`detectMutationEntityImpact`, TTL override. +**E2E** (mirror `federation_caching_test.go` `MutationSkipsL2Read` + +`federation_caching_l2_test.go` `MutationInvalidation`): +assert a `Delete` log entry for the impacted entity key, with the +skip-delete-if-being-written optimization. + +### 5.5 Negative caching + +Specs: [directives/negative-cache.md](./directives/negative-cache.md). + +**Unit** (mirror `negative_cache_test.go`): null-sentinel store/serve/TTL/ +mutation-interaction/overwrite-after-expiry + nullable-field regression guards. + +### 5.6 Subscription entity caching + +Specs: [directives/subscription-caching.md](./directives/subscription-caching.md), +`docs/entity-caching/SUBSCRIPTION_CACHE_SPEC.md`. + +**E2E** (mirror `federation_subscription_caching_test.go`): +`NewManualFederationSetup` + product subscription `Emit()`; +populate-mode writes the entity to L2 across events; +invalidate-mode deletes; +`t.Cleanup(closeFn)` registered immediately. +Note the invariant: `SubscriptionEntityPopulationConfiguration` requires BOTH +`TypeName` and `FieldName` set, else the lookup silently no-ops. + +### 5.7 Extensions invalidation + +Specs: [directives/extensions-invalidation.md](./directives/extensions-invalidation.md). + +**Unit** (mirror `extensions_cache_invalidation_test.go`) + **E2E** +(mirror `federation_caching_ext_invalidation_test.go`): +subgraph returns `extensions.cacheInvalidation.keys` → assert `Delete` + +analytics event. + +### 5.8 Shadow mode + +Specs: [directives/shadow-mode.md](./directives/shadow-mode.md). + +**Unit** (mirror the shadow tests in `cache_analytics_test.go` — +`FieldSourceShadowCached`, `ShadowComparisonEvent`, `ShadowFreshnessRate`): +assert `Shadow:true` on read events, assert the freshness rate, assert cached +data is **never served** (fresh data always rendered). + +### 5.9 Cache analytics (cross-cutting) + +Specs: [directives/analytics.md](./directives/analytics.md). + +Full `normalizeSnapshot(CacheAnalyticsSnapshot{...})` assertions with a per-event +"why" comment, covering `L1Reads`/`L2Reads` (`CacheKeyEvent`), +`L1Writes`/`L2Writes` (`CacheWriteEvent` with `ByteSize` + `TTL` + `CacheLevel`), +`EntityTypes`, `FieldHashes`, `MutationEvents`, `ShadowComparisons`. + +--- + +## 6. Benchmark suite + +Benchmarks are a **deliberate ladder**: each rung isolates one cost so a +regression points at one layer. +All benchmarks use `b.ReportAllocs()`, `b.ResetTimer()`, the `for b.Loop()` +form, and `arena.Reset()` per iteration. +Reference files all end in `_bench_test.go` under `v2/pkg/engine/resolve/`. + +### 6.1 The overhead ladder — `caching_overhead_bench_test.go` + +Drives the full `Loader.LoadGraphQLResponseData` over a realistic `topProducts` +root → batch-entity tree. +Shared `benchDataSource` (fixed bytes) and `benchCache` (zero-latency RWMutex +map). +Three top-level benchmarks, each with the named sub-benchmarks below. + +`BenchmarkCachingOverhead_Sequential` sub-rungs (`b.Run` names): + +| Sub-bench | Measures | +|---|---| +| `Disabled` | True zero baseline — no template at all | +| `ConfiguredButDisabled` | Template SET but flags off — **catches guard leaks** (if this is slower than `Disabled`, a guard is leaking) | +| `L1Only` | L1 enabled, L2 off | +| `L1L2_Miss` | Both enabled, cold cache (every entity misses) | +| `L1L2_Hit` | Both enabled, warm cache (every entity hits) | + +`BenchmarkCachingOverhead_Parallel` — the same 5-rung ladder over the 4-phase +parallel path (root → 2 parallel batch fetches, 5 products + 5 reviews). +Measures the goroutine / main-thread split overhead under caching. + +`BenchmarkCachingOverhead_Analytics` — `AnalyticsOff` vs `AnalyticsOn` over an +L1+L2 hit. +The delta is the cost of `EnableCacheAnalytics` (per-entity events, field +hashing, timings). + +### 6.2 Isolated copy primitives — `structural_copy_bench_test.go` + +Eight benchmarks isolating each cache-flow StructuralCopy primitive, each in a +`_NoTransform` and a `_WithTransform` variant over a fixed 10-field aliased +`Product` payload: + +`BenchmarkStructuralCopy_L1Write_NoTransform` / `_WithTransform`, +`BenchmarkStructuralCopy_L1Read_NoTransform` / `_WithTransform`, +`BenchmarkStructuralCopy_L2Read_NoTransform` / `_WithTransform`, +`BenchmarkStructuralCopy_L2Write_NoTransform` / `_WithTransform`. + +The `WithTransform` delta is the alias/arg-normalization cost over a plain +structural copy. + +### 6.3 The Copy-Budget pair — merge benches + non-caching floor + +`loader_cache_copy_bench_test.go` — the four merge-site benches, entity counts +`{1, 10, 100}`, one per StructuralCopy merge site: + +- `BenchmarkMergeBatchCacheHit` +- `BenchmarkMergeBatchPartialResponse` +- `BenchmarkMergeResultCacheSkipFetch` +- `BenchmarkMergeResultPartialCache` + +`loader_noncaching_bench_test.go` — the **zero-copy floor**, entity counts +`{1, 10, 100}`: + +- `BenchmarkNonCachingParseMergeCore` (raw `ParseBytesWithArena` + + `MergeValuesWithPath`) +- `BenchmarkNonCachingMergeResult` (full `mergeResult` with caching disabled) + +The non-caching floor is the baseline against which each merge bench's extra +StructuralCopy is measured. +These pair 1:1 with the four copy-invariant tests and the Copy Budget table — +see [§7](#7-the-copy-budget-triangle). + +### 6.4 Cache-hit fast path — `entity_cache_hit_bench_test.go` + +`BenchmarkEntityCacheHitPath` — nested `{L1, L2} × {tracing off, on} × +entity counts {1, 32}` over deeply-nested `Article` entities. +The cache is pre-populated; a miss is a `b.Fatal` (the bench must measure a +complete hit, never a partial). + +### 6.5 Analytics micro-benches — in `cache_analytics_test.go` + +`BenchmarkCacheAnalytics_Disabled`, `BenchmarkCacheAnalytics_Enabled`, +`BenchmarkFieldHashing` — micro-benches for the collector and the xxhash field +hashing. + +### 6.6 Arena model justification — `arena_thread_safety_bench_test.go` + +`BenchmarkConcurrentArena` vs `BenchmarkPerGoroutineArena` — justifies the +main-thread-only arena model versus per-goroutine arenas. +This is a **one-time justification bench**, not a per-PR regression gate +(see [§6.8](#68-which-benches-are-regression-gates)). + +### 6.7 How to run and how to compare before/after + +Run targeted unit tests: + +```sh +go test -run TestL1Cache ./v2/pkg/engine/resolve/... -v +go test -run TestFederationCaching ./execution/engine/... -v +go test -race ./v2/pkg/engine/resolve/... +``` + +Run a single benchmark family: + +```sh +go test -run=^$ -bench BenchmarkCachingOverhead -benchmem ./v2/pkg/engine/resolve/... +``` + +Compare before/after a change — capture two runs and diff with `benchstat`: + +```sh +# on the base branch +go test -run=^$ -bench 'BenchmarkCachingOverhead|BenchmarkMerge|BenchmarkNonCaching' \ + -benchmem -count=10 ./v2/pkg/engine/resolve/... | tee /tmp/before.txt +# on the change branch +go test -run=^$ -bench 'BenchmarkCachingOverhead|BenchmarkMerge|BenchmarkNonCaching' \ + -benchmem -count=10 ./v2/pkg/engine/resolve/... | tee /tmp/after.txt +benchstat /tmp/before.txt /tmp/after.txt +``` + +Use `-count=10` so `benchstat` can compute variance and flag a real +regression versus noise. +The two rungs to watch hardest: +`ConfiguredButDisabled` must stay at parity with `Disabled` (any gap is a guard +leak), and each `BenchmarkMerge*` must stay within one StructuralCopy of the +matching `BenchmarkNonCaching*` floor. + +### 6.8 Which benches are regression gates + +Carry these forward as **load-bearing regression gates** (referenced by +`CLAUDE.md`): + +- the `BenchmarkCachingOverhead_*` ladder (guard-leak + per-layer overhead); +- the four `BenchmarkMerge*` + the two `BenchmarkNonCaching*` (the Copy Budget). + +Treat these as **one-time justification benches** (run once when the model is +chosen, not gated per PR): + +- `arena_thread_safety_bench_test.go`; +- the `structural_copy_bench_test.go` micro-benches. + +--- + +## 7. The Copy-Budget triangle + +This is the single most important structural rule in the test suite. + +The **Copy Budget table** in `v2/pkg/engine/resolve/CLAUDE.md` records the +minimum StructuralCopy count for each data flow. +It is the contract between three artifacts that must always move together: + +1. the **table** (the documented budget); +2. the four `TestCopyInvariant_*` tests (prove each copy is necessary by removing + it and corrupting a nested container); +3. the four `BenchmarkMerge*` benches plus the two `BenchmarkNonCaching*` floor + benches (measure the cost of each copy against the zero-copy baseline). + +The merge sites the table pins (loader.go line references in the reference tree, +for orientation only): batch L2 cache-hit splice (`mergeBatchCacheHit`, ~1220), +partial batch response interleave (`mergeBatchPartialResponse`, ~1372), +full L1 cache-hit merge (`mergeResult` cacheSkipFetch, ~1472), +partial-cache L1 merge (`mergeResult` partialCache, ~1491). + +**Rule:** any PR that changes the copy budget updates the table, the invariant +tests, and the benches together — in one reviewable PR per merge site. +The recommendation for the re-implementation: keep this triangle as a single PR +unit so a reviewer sees the table change, the test that proves it, and the bench +that measures it side by side. + +--- + +## 8. Per-PR test deliverables (the working contract) + +A directive PR in this stack is **done** only when all of the following hold. +Use this as the PR author's and the reviewer's shared checklist. + +- [ ] Unit tests for the directive added, mirroring the reference file named in + [§5](#5-per-directive-test-matrix), passing under `go test` **and** + `go test -race`. +- [ ] E2E tests for the directive added (where the matrix calls for them), + self-contained, inline GraphQL, full normalized snapshot assertions. +- [ ] If the PR touches a StructuralCopy merge site: the Copy-Budget triangle + (table + invariant test + bench) updated together — see [§7](#7-the-copy-budget-triangle). +- [ ] No new shared helpers in `execution/engine/` (the no-shared-helper rule). +- [ ] `docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md` updated: + every new/changed test linked from its AC with relative path + line + name. +- [ ] Every convention in the [§2 checklist](#2-mandatory-conventions--the-checklist) + satisfied — especially exact assertions, full-struct asserts, "why" comments, + and the cache-log clear+assert pairing. +- [ ] Modern Go throughout (`/use-modern-go` loaded before writing). + +--- + +## 9. Known risks and anti-patterns + +These are concrete traps surfaced by the reference suite. +Avoid them in the re-implementation. + +- **Legacy `.query` file loading.** + Some older E2E tests load queries via `cachingTestQueryPath("queries/*.query")` + from `federationtesting/testdata` (e.g. parts of + `federation_caching_l2_test.go`). + This contradicts the inline-queries convention. + Standardize on inline `QueryStringWithHeaders` and do not carry the + file-loading helper forward. + (See [§10](#10-open-questions) — confirm no query is too large to inline.) +- **The two skipped `@requestScoped` E2E tests** in + `federation_caching_request_scoped_test.go` contain temporary fuzzy + `if reviewsCalls == 0` smoke checks explicitly flagged as placeholder. + They violate the exact-assertion rule. + When re-enabled, replace with exact call-count assertions. + Never copy the fuzzy pattern. +- **Giant analytics files.** + `cache_analytics_test.go` (~2090 lines) and + `federation_caching_analytics_test.go` (~120 KB) are correct per convention but + hard to review as one PR. + In the re-implementation, split analytics tests per concern — collector unit, + L1 integration, L2 integration, shadow, mutation events — into separate small + files. +- **Centralize snapshot normalization.** + `normalizeSnapshot` zeros `CacheAgeMs` / `DurationMs` and nulls `FetchTimings` — + this is the **one sanctioned exception** to "assert exact" (these fields are + non-deterministic). + Keep normalization in a single helper so reviewers know exactly which fields + are intentionally not asserted. + Use `normalizeFetchTimings` only when timing-structure assertions are actually + needed. +- **`request_scoped_widening_e2e_test.go` deliberately breaks conventions** + (top-level recorder helpers, package `engine` not `engine_test`). + It is the ONE documented exception. + Any new request-scoped E2E that copies its style without that documented + exemption is a regression. +- **Forgetting `DisableSubgraphRequestDeduplication = true`** in unit tests + silently changes fetch counts and produces confusing failures. + Bake it into a shared in-package setup helper (allowed in `resolve`, the + no-shared-helper rule is E2E-only) or document it prominently at the top of + each unit test file. + +--- + +## 10. Open questions + +These must be resolved before the corresponding PRs land. + +- **Legacy query files:** remove `cachingTestQueryPath` entirely, or keep it for + the very largest existing queries? + Convention says inline — confirm no query is too large to inline reasonably. +- **Setup unification:** keep both `NewFederationSetup` and + `NewManualFederationSetup`, or unify? + Subscription cache tests depend on the manual trigger (`Emit`), so the manual + path cannot be dropped. +- **`@requestScoped` E2E coverage:** are the skipped E2E tests un-skipped as part + of this re-implementation (planner work landed), giving AC-RS-01..07 real E2E + coverage now — or do they stay skipped with unit-only coverage? +- **Regression-gate set:** confirm the load-bearing gate set is exactly the + `CachingOverhead` ladder + the Copy-Budget benches, and that + `arena_thread_safety` and `structural_copy` micro-benches are one-time + justification benches rather than per-PR gates (see [§6.8](#68-which-benches-are-regression-gates)). + +--- + +## 11. Quick reference — commands + +```sh +# unit, one family +go test -run TestL1Cache ./v2/pkg/engine/resolve/... -v +go test -run TestCopyInvariant ./v2/pkg/engine/resolve/... -v + +# e2e, one family +go test -run TestFederationCaching ./execution/engine/... -v + +# race (always before merge) +go test -race ./v2/pkg/engine/resolve/... +go test -race ./execution/engine/... + +# benchmarks, one family with allocs +go test -run=^$ -bench BenchmarkCachingOverhead -benchmem ./v2/pkg/engine/resolve/... + +# before/after comparison (see §6.7) +go test -run=^$ -bench 'BenchmarkCachingOverhead|BenchmarkMerge|BenchmarkNonCaching' \ + -benchmem -count=10 ./v2/pkg/engine/resolve/... | tee /tmp/after.txt +benchstat /tmp/before.txt /tmp/after.txt +``` + +See [08-EXECUTION-RUNBOOK](./08-EXECUTION-RUNBOOK.md) for how these commands slot +into the Codex-driven implementation loop, and +[07-UNRELATED-FINDINGS](./07-UNRELATED-FINDINGS.md) for out-of-scope issues found +while mapping the suite. diff --git a/docs/entity-caching/07-UNRELATED-FINDINGS.md b/docs/entity-caching/07-UNRELATED-FINDINGS.md new file mode 100644 index 0000000000..ce2f1d7b53 --- /dev/null +++ b/docs/entity-caching/07-UNRELATED-FINDINGS.md @@ -0,0 +1,440 @@ +# 07 — Out-of-Scope Findings Register + +> Part of the entity-caching reimplementation document set. +> See [00-OVERVIEW.md](00-OVERVIEW.md) for the navigation map, +> [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md) for the integration seam, +> [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md) and +> [04-PR-PLAN-router.md](04-PR-PLAN-router.md) for the stacked-PR plans. + +## What this document is + +This is a **read-only register**. +It catalogues everything found inside (or near) the entity-caching branch that is **NOT entity caching**, +so a future reader knows what to ignore, what to peel into a separate PR, and what is pure noise. + +**Nothing here is being fixed, changed, reverted, or re-implemented in this run.** +This is documentation only. +Every item below is described, classified, and given a recommended disposition — +but no code is touched as a result of this register. + +The findings come from analyzing the in-flight PR +("feat: add caching to loader", referred to below as "the caching branch") +against its true upstream base. + +## Why you must read the META finding first + +The single most important fact in this whole register is a **measurement artifact**, not a code problem. +If you skip it, you will mischaracterize roughly half the branch. +Read finding 0 before anything else. + +## How the findings are classified + +Each finding records six things, as requested: + +1. **Title** — short name. +2. **What it is** — plain description, assuming no prior knowledge. +3. **Why it is unrelated to entity caching** — the separation rationale. +4. **Affected files** — the concrete paths. +5. **Genuine bug fix?** — whether it looks like a real fix worth shipping on its own. +6. **Recommended disposition** — one of: **separate PR**, **discard**, **upstream / already upstreamed**. + +--- + +## Finding 0 (META) — Local `master` is stale, so the raw diff overcounts by ~181 files + +**What it is.** +This worktree's **local** `master` ref is frozen at an old commit. +It points at `1dcbd3bd` dated **2026-02-16**, which is the actual merge-base of the branch. +The **real upstream** `origin/master` has moved on to `6a5eb1a3` dated **2026-06-12** (release 2.4.6). +That is roughly four months of upstream work that local `master` does not know about. + +The consequence shows up the moment you run a diff: + +- `git diff --name-only master...HEAD` reports **397 files** (~135102 insertions) — *overcounted*. +- `git diff --shortstat origin/master...HEAD` reports **216 files, +78980 / -1628** — *the true PR size*. + +Both numbers above were re-verified live while writing this register; they match exactly. + +The ~181-file gap between the two diffs is **upstream work that is already merged into the branch** +(via the branch's many `Merge branch 'master'` commits) +**but is not present in the stale local `master`.** +So those files light up as "changes" only because the comparison base is old. + +**Why it is unrelated to entity caching.** +The overcounted files are not authored by the caching work at all. +They are already-merged upstream features. +The entire list of "suspect unrelated diffs" that a casual reviewer would flag falls into this bucket: + +- the gRPC datasource feature line (multiple upstream PRs), +- the subscription-client transport rewrite (the base rewrite, not the two small fixes — see Finding 6), +- `jsonschema` fixes, +- the `astprinter` / `lexer` / `astparser` / `ast` description + variable-description spec feature, +- `grpctest` / `productv1` protobuf regeneration (a ~19770-line generated file), +- playground file deletions, +- starwars testdata. + +Every one of those is **absent** from `git diff --name-only origin/master...HEAD`. +They are NOT part of the caching branch. +They are noise from diffing against a stale ref. + +**Affected files (illustrative — these appear only in the stale diff, not the real one).** + +- `v2/pkg/grpctest/productv1/product.pb.go` (generated protobuf, ~19770 lines) +- `v2/pkg/playground/files/playground.html` (and sibling playground deletions) +- `v2/pkg/lexer/lexer.go` +- `v2/pkg/astprinter/astprinter.go` +- `v2/pkg/engine/jsonschema/variables_schema.go` +- `v2/pkg/ast/ast_variable_definition.go` +- `v2/pkg/starwars/testdata/star_wars.graphql` + +**Genuine bug fix?** +N/A — this is a tooling/measurement observation, not a code change. + +**Recommended disposition: upstream / already upstreamed.** +Do nothing to these files. +They are already on `origin/master`. + +**Action for every downstream reader and every PR-plan step:** +always diff against `origin/master`, never local `master`. +If you write up `grpctest` / playground / `lexer` / `astprinter` / `jsonschema` as +"caching-branch unrelated changes", you are wrong — +they belong to already-merged upstream PRs and must not be attributed to this branch. + +--- + +## Finding 1 — `onError` / `ErrorBehavior` request-extension feature + +**What it is.** +A complete, self-contained feature implementing the GraphQL `extensions.onError` request extension. +It controls how the engine handles an error on a non-nullable field — +whether to propagate the null up the tree, render `null`, or halt execution. +The three modes map to an integer enum. + +New surface area: + +- a new `ErrorBehavior` type with `String()` and a `ParseErrorBehavior(s)` helper, +- a `haltExecution` field and a `HaltExecution()` method on the resolvable, + with the null-propagation path now switching on the configured behavior, +- an `ErrorBehavior` field added to the shared `ExecutionOptions` struct + (gated by an `OnErrorEnabled` flag on the resolver options), +- an option-builder entry `WithErrorBehavior(behavior)` in the execution engine, +- a request-parsing path that reads an `Extensions` raw-JSON field + and exposes `GetOnErrorBehavior()` parsing `{ "onError": "..." }`. + +It ships with dedicated, sizable tests +(a unit test file for parsing, an E2E behavior file, and a request-parsing test file). + +**Why it is unrelated to entity caching.** +This is net-new null-bubbling control. +It never reads or writes any cache, never participates in L1/L2, +and would behave identically with caching fully disabled. +It is its own feature that the author happened to bundle into the same branch. + +**Affected files.** + +- `v2/pkg/engine/resolve/error_behavior.go` +- `v2/pkg/engine/resolve/error_behavior_test.go` +- `v2/pkg/engine/resolve/resolvable.go` +- `v2/pkg/engine/resolve/context.go` +- `execution/engine/execution_engine.go` +- `execution/engine/error_behavior_test.go` +- `execution/graphql/request.go` +- `execution/graphql/request_onerror_test.go` + +**Genuine bug fix?** +No — it is **net-new behavior**, not a fix. +It is well-tested and looks production-ready, but it is a feature. + +**Recommended disposition: separate PR**, landed **before** the caching stack. + +**Caveat (the entanglement that makes this hard).** +This feature **shares two files line-for-line with caching**: +the `ExecutionOptions` struct in `context.go` +and the option-builder file `execution_engine.go`. +The `resolvable.go` null-propagation edits also sit in a caching-heavy file. +So you cannot peel this out by dropping whole files — +the edits are interleaved and must be untangled by hand. +See [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md) for the shared-struct seam. + +--- + +## Finding 2 — `service_datasource` package (`__service` capabilities introspection) + +**What it is.** +An entirely new datasource package implementing a `__service { capabilities { ... } }` +introspection endpoint. +It advertises engine capabilities (for example `graphql.onError` and a default error behavior) +to clients via a spec-aligned introspection field. + +The package adds the usual datasource layers +(config factory, factory, planner, source, types, schema) +plus a schema-extension helper that injects `_Service` / `_Capability` types +and a `__service` field onto the `Query` type, +parallel to how the base schema is normally merged. +It ships with its own test files. +The package is confirmed new on this branch — it does not exist on `origin/master`. + +**Why it is unrelated to entity caching.** +This is the **schema/introspection half of the `onError` feature** (Finding 1) — +it exists to tell clients "this server supports onError". +It has **zero** dependency on entity caching: +no cache reads, no cache writes, no L1/L2 involvement. + +**Affected files.** + +- `v2/pkg/engine/datasource/service_datasource/config_factory.go` +- `v2/pkg/engine/datasource/service_datasource/factory.go` +- `v2/pkg/engine/datasource/service_datasource/planner.go` +- `v2/pkg/engine/datasource/service_datasource/schema.go` +- `v2/pkg/engine/datasource/service_datasource/source.go` +- `v2/pkg/engine/datasource/service_datasource/types.go` +- `v2/pkg/engine/datasource/service_datasource/schema_test.go` +- `v2/pkg/engine/datasource/service_datasource/service_datasource_test.go` + +**Genuine bug fix?** +No — net-new feature. + +**Recommended disposition: separate PR**, shipped **with or just before** the `onError` PR (Finding 1). +Logically these two findings are one feature pair: +`onError` behavior plus the capability advertisement that announces it. +Because it is a clean new package with no shared files, +it is the easiest of the bundled features to extract. + +--- + +## Finding 3 — Embedded planner correctness changes to general `@requires` / `@provides` resolution + +**What it is.** +Inside the request-scoped widening work, several edits change the **general federation planner** — +the code path that runs for every federated query, cached or not. +Three distinct edits stand out: + +- **(a)** an early-return guard was removed from the "are required fields provided" check + (the guard previously bailed out when no fields were provided). + Removing it changes when `@requires` can be skipped even with no provided fields — + a real behavior change to provides/requires planning. +- **(b)** the routine that adds field requirements to the operation now sources the type name + from the field config's own `TypeName` instead of always using the enclosing type definition's name. + This is a correctness fix for `@requires` type names under interface objects, + packaged together with a pure-refactor extraction of the interface-object `@requires` lookup. +- **(c)** the required-fields visitor now adds aliases based on the field's alias-or-name bytes. + +**Why it is unrelated to entity caching.** +These edits alter planning of **non-cached** federation queries. +They fire whether or not any cache is enabled. +They were made *in service of* request-scoped work, +but their effect is on the general `@requires` / `@provides` resolution path, +so they are logically separable from the cache machinery. + +**Affected files.** + +- `v2/pkg/engine/plan/required_fields_provided_visitor.go` +- `v2/pkg/engine/plan/node_selection_visitor.go` +- `v2/pkg/engine/plan/required_fields_visitor.go` +- `v2/pkg/engine/plan/path_builder_visitor.go` + +**Genuine bug fix?** +**Likely yes** — they read as legitimate fixes +(especially the interface-object type-name correction). +But they are **entangled with request-scoped work** and currently have **no isolated regression coverage** +outside the request-scoped tests. + +**Recommended disposition: separate PR**, with **dedicated regression tests**. +This is the highest-risk item in the register for silent breakage: +if these changes are blindly re-created "as part of caching", +they could change behavior for users who never enable caching at all. +They deserve their own scrutiny and their own tests proving the before/after planner behavior. +See the planner seam noted in [01-ARCHITECTURE-SPEC.md](01-ARCHITECTURE-SPEC.md). + +--- + +## Finding 4 — Federation test-harness rewrite (gateway restructure, `http` → `httphandler` rename, options-based gateway) + +**What it is.** +The federation test gateway and the example gateway are substantially rewritten. +The gateway constructor signature changed from a config-bytes form +to a functional-options form taking a handler factory, an HTTP client, a logger, a loader-cache map, and option funcs. +New functional-options plumbing, new handler-factory and datasource-observer/subject interfaces, +a new static-gateway file, a moved/expanded datasource poller, and a new gateway main were added. +The internal `http` package was mechanically renamed to `httphandler` +(touching the handler, http, and ws files in both the execution harness and the examples). + +**Why it is unrelated to entity caching.** +Much of this plumbs the loader-cache map and subgraph caching configs, +so it is **caching-motivated** — but the rename, the options refactor, and the interface introduction +are **structural test-infrastructure changes** that are reviewable independently of any cache logic. +They inflate the diff substantially without being cache behavior. + +**Affected files.** + +- `execution/federationtesting/gateway/gateway.go` +- `execution/federationtesting/gateway/gateway_static.go` +- `execution/federationtesting/gateway/datasource_poller.go` +- `execution/federationtesting/gateway/main.go` +- `execution/federationtesting/gateway/httphandler/handler.go` +- `execution/federationtesting/gateway/httphandler/http.go` +- `execution/federationtesting/gateway/httphandler/ws.go` +- `examples/federation/gateway/gateway.go` +- `examples/federation/gateway/httphandler/handler.go` +- (rides along: `examples/federation/*` and `examples/engine/*` go.mod/go.sum churn, the poller move) + +**Genuine bug fix?** +No — this is a refactor / restructure, not a fix. + +**Recommended disposition: separate PR**, landed **first**, +so the caching test PRs only add cache wiring on top of an already-refactored harness. + +**Caveat.** +The project test conventions discourage shared test helpers +(see the repo and `execution/engine` CLAUDE.md rules on self-contained subtests). +This is **harness / gateway code**, not per-test scaffolding, +so it is a different category — but flag it for explicit team sign-off +since it sits adjacent to the no-shared-helpers rule. +This is an open question, not a settled decision (see the open-questions note below). + +--- + +## Finding 5 — Subscription-client transport bug fixes (connection-leak eviction + WS legacy-subprotocol compat) + +**What it is.** +The subscription-client transport rewrite itself is already upstream (see Finding 0). +On top of it, the caching branch carries **two small, real bug fixes**: + +- **(a)** an SSE close hook so that a naturally-completed stream + (a complete event, an EOF, or a read error) evicts itself from the transport's connection map. + Without it, streams that finish without the consumer calling cancel leak a connection / goroutine. +- **(b)** a WebSocket subprotocol-negotiation fallback: + when the accepted subprotocol is empty and the requested one was the auto value, + it falls back to the legacy `graphql-ws` subprotocol — + restoring compatibility with older upstreams / intermediaries + that strip the `Sec-WebSocket-Protocol` header. + +Both fixes come with test additions. + +**Why it is unrelated to entity caching.** +These are transport-layer correctness fixes. +They have nothing to do with L1/L2, entity resolution, or cache keys. +They simply ride along in the same branch. + +**Affected files.** + +- `v2/pkg/engine/datasource/graphql_datasource/subscriptionclient/transport/sse_conn.go` +- `v2/pkg/engine/datasource/graphql_datasource/subscriptionclient/transport/sse_transport.go` +- `v2/pkg/engine/datasource/graphql_datasource/subscriptionclient/transport/ws_transport.go` +- `v2/pkg/engine/datasource/graphql_datasource/subscriptionclient/transport/sse_conn_test.go` +- `v2/pkg/engine/datasource/graphql_datasource/subscriptionclient/transport/ws_transport_test.go` + +**Genuine bug fix?** +**Yes — both are genuine, testable fixes.** +They are small and easy to review in isolation. + +**Recommended disposition: separate PR** — +a small standalone "subscription transport fixes" PR. +These are the cleanest extraction in the whole register. + +--- + +## Finding 6 — Repo-meta and dependency-version churn (partly intentional, mostly stale-base artifact) + +**What it is.** +The module manifest shows a mix of intentional and accidental changes. + +**Intentional (the real external deps the caching work needs):** + +- `github.com/wundergraph/astjson` bumped to a two-pass-parser pre-release version, +- `github.com/wundergraph/go-arena` bumped to its newer minor version. + +These two are the genuine dependency requirements of the cache implementation — +see [05-ASTJSON-PRIMITIVES.md](05-ASTJSON-PRIMITIVES.md) for why the astjson pre-release is needed. + +**Accidental (stale-base artifact):** +the same manifest also shows numerous **downgrades** +(for example `x/sync`, gRPC, protobuf, OpenTelemetry, `x/net`). +These are not deliberate. +They are a side effect of the 2026-02-16 base (Finding 0) +and will reconcile automatically when the work is rebased onto current `origin/master`. + +**Other meta noise:** + +- an `AGENTS.md` symlink to `CLAUDE.md`, +- `CLAUDE.md` additions, +- a one-line README example fix for the updated response-resolution signature, +- a `go.work` tweak removing a commented-out local astjson replace, +- a `.gitignore` addition, +- the caching docs set and the two caching-related package CLAUDE.md files. + +**Why it is unrelated to entity caching.** +The downgrades and the go.work / .gitignore tweaks are incidental — +they carry no caching behavior. +The docs and the two CLAUDE.md files **are** caching content and stay with the caching PRs. + +**Affected files.** + +- `v2/go.mod` +- `v2/go.sum` +- `go.work` +- `.gitignore` +- `AGENTS.md` +- `CLAUDE.md` +- `README.md` +- `examples/engine/go.mod` +- `examples/federation/go.mod` + +**Genuine bug fix?** +No. + +**Recommended disposition: split.** + +- Keep ONLY the astjson and go-arena bumps — these are real and required. +- **Discard** every downgrade — do not carry it into the clean re-implementation; + it will resolve on rebase onto current `origin/master`. +- Keep the caching docs and the two package CLAUDE.md files with the caching PRs. +- Treat the rest (symlink, README one-liner, go.work, .gitignore) as incidental; + fold or drop per the PR plan in [03-PR-PLAN-graphql-go-tools.md](03-PR-PLAN-graphql-go-tools.md). + +--- + +## Disposition summary + +| # | Finding | Genuine fix? | Disposition | +|---|---------|--------------|-------------| +| 0 | META: stale local `master` overcounts diff | N/A | Already upstreamed — diff against `origin/master`, change nothing | +| 1 | `onError` / `ErrorBehavior` feature | No (net-new) | Separate PR, before caching; shares files with caching | +| 2 | `service_datasource` `__service` capabilities | No (net-new) | Separate PR, with/before Finding 1; clean new package | +| 3 | Embedded planner `@requires`/`@provides` changes | Likely yes | Separate PR, needs isolated regression tests; highest silent-break risk | +| 4 | Federation test-harness rewrite | No (refactor) | Separate PR, landed first; needs team sign-off | +| 5 | Subscription transport fixes | Yes (both) | Separate PR; cleanest extraction | +| 6 | Repo-meta + dependency churn | No | Split: keep astjson/go-arena bumps + docs, discard downgrades | + +## Risks to keep in mind + +- Anyone diffing against **local** `master` will badly mischaracterize the branch: + ~181 of the 397 files are already-merged upstream work. + All of the "suspect" items a casual reviewer would flag are in that bucket. + Always use `origin/master` as the base. +- Findings 1 and 2 **share** the `ExecutionOptions` struct and the execution-engine option-builder file with caching. + They cannot be peeled out by dropping files alone — the edits are interleaved line by line. +- The Finding 3 planner changes alter **non-cached** query planning. + Re-implemented blindly "as caching", they could silently change behavior for users who never enable caching, + and they currently lack isolated regression coverage. +- The dependency **downgrades** in Finding 6 are stale-base artifacts. + Copying them into a fresh branch off current `master` would regress the whole module's dependency tree. + +## Open questions (for the team, not resolved here) + +- Should Findings 1 + 2 (`onError` behavior + `__service` capability advertisement) + be one stacked PR landed before the caching stack, given they share `ExecutionOptions` and the option-builder file? +- Are the Finding 3 planner edits standalone fixes with existing upstream issues, + or are they only correct in the presence of request-scoped resolution? + They need isolated regression tests either way. +- Is the Finding 4 harness rewrite acceptable as a standalone test-infra PR landed first, + given the repo's no-shared-test-helpers convention (this is gateway code, not per-test scaffolding)? +- Confirm that local `master` being frozen at 2026-02-16 is intentional for this worktree. + If so, every reviewer instruction should explicitly say to diff against `origin/master`. + +--- + +**Reminder: this register fixes nothing.** +It is a documentation-only inventory of out-of-scope work. +Extraction into separate PRs, discarding of stale-base artifacts, and regression-test authoring +all happen later, under the PR plans, not as a side effect of writing this file. diff --git a/docs/entity-caching/08-EXECUTION-RUNBOOK.md b/docs/entity-caching/08-EXECUTION-RUNBOOK.md new file mode 100644 index 0000000000..ca9b2e8bec --- /dev/null +++ b/docs/entity-caching/08-EXECUTION-RUNBOOK.md @@ -0,0 +1,492 @@ +# 08 — Execution Runbook (Codex-driven implementation loop) + +> Part of the entity-caching re-implementation document set. +> See [00-OVERVIEW.md](./00-OVERVIEW.md) for navigation, +> [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) for the clean architecture and integration seam, +> [02-DIRECTIVE-INVENTORY.md](./02-DIRECTIVE-INVENTORY.md) for the directive table, +> [03-PR-PLAN-graphql-go-tools.md](./03-PR-PLAN-graphql-go-tools.md) for the gqtools PR stack (PR 0–21), +> [04-PR-PLAN-router.md](./04-PR-PLAN-router.md) for the router PR stack (R1–R11), +> [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md) for the astjson dependency spec, +> [06-TEST-AND-BENCH-PLAN.md](./06-TEST-AND-BENCH-PLAN.md) for the full test and benchmark plan, +> [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md) for out-of-scope topics, +> and the per-directive specs under [directives/](./directives/) plus the decision records under [adr/](./adr/). + +This document is the **operational runbook**. +The other documents say *what* to build and *in what order*. +This one says *how to drive the build*, turn by turn, with two actors. + +--- + +## 0. Who this is for, and the one-sentence model + +You are about to execute a stacked-PR plan with two collaborators working as a team. +You need no prior knowledge of the feature to follow this loop; +the specs carry the knowledge, this runbook carries the procedure. + +The one-sentence model: +**Claude plans, scopes, and reviews; +Codex CLI writes every line of production and test code; +a human approves before anything is pushed or opened as a PR.** + +Nothing in this loop touches an existing branch. +Every PR starts from a fresh branch cut off the up-to-date default branch. + +--- + +## 1. The two roles + +There are exactly two automated actors, plus one human gate. + +### 1.1 Claude — orchestrator, planner, reviewer + +Claude (this assistant) never writes production code in this workflow. +Claude's job, per PR, is: + +- Read the relevant spec section, the directive spec, and the ADR for the PR being built. +- Write a short **reviewer guide** for the PR (see [§4](#4-the-reviewer-guide-written-first)). +- Hand Codex a **tightly-scoped task** that references the spec and reviewer guide. +- **Review the resulting diff** against the spec, the reviewer guide, and the repo conventions. +- Drive the iteration with Codex until the diff is correct. +- Run the test and benchmark gates. +- Mark the PR ready and stop at the human gate. + +Claude consults Codex for an independent second opinion via the `/codex` skill +(`codex review` for a diff gate, `codex challenge` to try to break the code, +`codex consult` for a design question). +That is a *review* use of Codex, distinct from the *authoring* use below. + +### 1.2 Codex CLI — every coding task + +Codex CLI is the implementer. +Every change to `.go`, `_test.go`, `go.mod`, YAML config, or docs that ship in a PR +is produced by Codex, driven by a task Claude writes. +Codex works test-first (TDD), applies modern Go idioms, and iterates on Claude's review notes. + +Claude does not edit the source tree directly during a PR build. +The single exception is the planning docs under this directory +(`_entity-caching-reimpl/`), which are Claude's own artifacts and not part of any code PR. + +### 1.3 The human — the approval gate + +A human reviews and approves before: + +- any `git push`, +- any `gh pr create`, +- any merge into the feature branch. + +This is a hard rule. +The loop runs autonomously up to each gate, then waits. +Per the global instructions, neither actor ever comments, reviews, or merges +on GitHub on the human's behalf — the actors *prepare* PR bodies and review notes, +the human *posts* and *merges*. + +--- + +## 2. One-time setup before the first PR + +Do this once, before PR 0. + +### 2.1 Confirm the base ref + +In this worktree the **local** `master` ref is frozen at 2026-02-16 and is **not** +the same as `origin/master` (2026-06-12). +Diffing against the stale local ref overcounts the change by ~181 already-merged files. +See [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md) for the full explanation. + +Always branch from and diff against the **remote** default branch: + +```sh +git fetch origin +# gqtools default branch is `master`; the cosmo router default is `main`. +``` + +### 2.2 Create the long-lived feature branch (gqtools) + +Per [03-PR-PLAN-graphql-go-tools.md §1.1](./03-PR-PLAN-graphql-go-tools.md): + +```sh +git checkout -b feat/entity-caching origin/master +git push -u origin feat/entity-caching # human-gated push +``` + +The router stack uses its own feature branch off `origin/main` +(see [04-PR-PLAN-router.md](./04-PR-PLAN-router.md)). + +Every code PR targets its feature branch as the merge base. +The feature branch merges to the default branch exactly once, at the very end. + +### 2.3 Load the Go guidelines once per session + +Before Codex writes any Go, the `modern-go-guidelines` skill must be loaded +(`/use-modern-go`) so range-over-int, range-over-func, structured logging, and +the other current idioms are applied rather than legacy patterns. +This is restated in the per-PR loop because sessions reset. + +--- + +## 3. Branch and worktree hygiene (NO existing branch is ever touched) + +This is the most important safety property of the loop. + +- **Every PR gets a brand-new branch**, named `feat/entity-caching--` + for gqtools PRs (e.g. `feat/entity-caching-03-cache-key-templates`) + and `feat/entity-caching-r-` for router PRs. +- The branch is cut **fresh** off its parent: + PR N stacks on PR N-1, so PR N branches off **PR N-1's branch tip**, never off an unrelated branch + (see [03-PR-PLAN-graphql-go-tools.md §1.2](./03-PR-PLAN-graphql-go-tools.md)). + PR 1 and the independent leaf PRs branch off the feature branch tip. +- **No existing branch is ever rebased, force-pushed, or reused** to carry new work. + If a dependency merges and a child needs the new tip, the child is **rebased onto the new + parent tip on its own branch** — the parent branch itself is left alone. +- Prefer a **separate git worktree per active PR** so parallel PRs never share a working + directory or step on each other's `go.work` state. + Create one with `git worktree add ../ec-- `; + remove it with `git worktree remove` once the PR has merged. + +The test, at the end of every PR: the only branch this PR's commits exist on is +the new branch created for it; no pre-existing branch has a single new commit. + +--- + +## 4. The reviewer guide, written first + +Before Codex touches code, Claude writes a **reviewer guide** for the PR. +Writing it first forces the scope to be pinned down before any code exists, +and it doubles as the spec Codex implements against and the rubric Claude reviews against. + +A reviewer guide is **short** (a screen or two) and contains: + +- **What this PR adds**, in two or three sentences, for a reader with no context. +- **The exact files** expected to change, and which must *not* change. +- **The contracts** introduced or consumed — tiny signatures only, e.g. + `Get(ctx, keys []string) ([]*CacheEntry, error)`, never a pasted body. +- **The acceptance criteria**, copied from the PR's entry in the PR-plan doc. +- **The conventions that bite here** — for a test PR, the exact-assertion and + full-struct rules from [06-TEST-AND-BENCH-PLAN.md §2](./06-TEST-AND-BENCH-PLAN.md); + for a merge-site PR, the Copy-Budget triangle from [§7 of the test plan](./06-TEST-AND-BENCH-PLAN.md). +- **The link to the authoritative spec** — the PR's `Reviewer-guide doc` field already + names it (architecture spec section, directive spec, or ADR); the reviewer guide + points at it rather than restating it. + +Most PRs already name their reviewer-guide doc in the PR-plan entry +(e.g. PR 3 → [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md), PR 1 → the architecture spec + ADR). +The per-PR reviewer guide Claude writes is the *thin task wrapper* around that doc, +not a duplicate of it. + +The reviewer guide is one of Claude's planning artifacts; it is not committed to the code PR. + +--- + +## 5. The per-PR loop + +This is the core of the runbook. +Run it once per PR, in order, for both the gqtools stack (PR 0–21) and the router stack (R1–R11). + +### 5.1 The seven steps + +1. **Scope.** + Claude opens the PR's entry in the PR-plan doc, reads its `Goal` / `Scope` / `Excludes` / + `Dependencies` / `Acceptance criteria`, reads the named reviewer-guide doc and any directive + spec + ADR, and confirms every dependency PR has already merged into the feature branch. + If a dependency is unmerged, stop — this PR is not ready. + +2. **Reviewer guide.** + Claude writes the reviewer guide ([§4](#4-the-reviewer-guide-written-first)). + +3. **Branch.** + Claude (or the human) cuts a fresh branch off the correct parent and, preferably, a worktree + ([§3](#3-branch-and-worktree-hygiene-no-existing-branch-is-ever-touched)). + No push yet. + +4. **Instruct Codex.** + Claude hands Codex a single tightly-scoped task ([§6](#6-how-claude-instructs-codex)). + The task says: build exactly what the reviewer guide describes, test-first, + modern Go, touch only the listed files. + +5. **Review and iterate.** + Codex implements; Claude reviews the diff against the reviewer guide and the spec + ([§7](#7-how-claude-reviews-the-diff)); Claude returns precise change requests; + Codex revises. + Repeat until the diff matches the guide and passes review. + Optionally run an independent `codex review` pass as a second gate. + +6. **Gate: tests and benchmarks.** + Run the test and benchmark gates for this PR ([§8](#8-the-test-and-benchmark-gates)). + All must pass, `-race` clean, and any benches in scope must hold their regression budget. + +7. **Mark ready, stop at the human gate.** + Claude prepares the PR body (drafted, not posted), summarizes the diff and the gate results, + and **stops**. + The human reviews, then pushes the branch and opens the PR. + +### 5.2 What "tightly scoped" means + +A good Codex task is one PR's worth of work and no more. +The PR-plan already sizes each PR to be reviewable in under ~30 minutes and lists its `Excludes` +(the adjacent work that belongs to *other* PRs). +The Codex task must restate those `Excludes` so Codex does not "helpfully" pull in the next PR's code. +Example: PR 2 (loader copy helpers) explicitly excludes any *caller* of the helpers — they are +dead code until PR 7. +The task must say so, or Codex will wire them up and balloon the diff. + +--- + +## 6. How Claude instructs Codex + +Each Codex task is a short, self-contained brief. +It contains, in order: + +- **One-line goal** — copied from the PR's `Goal`. +- **The reviewer guide** — pasted or referenced by path. +- **Files to change / files to leave alone** — the explicit allow-list and the `Excludes`. +- **Contracts** — tiny signatures only, no bodies. +- **Test-first instruction** — write the failing tests named in + [06-TEST-AND-BENCH-PLAN.md §5](./06-TEST-AND-BENCH-PLAN.md) for this directive first, + then make them pass; mirror the reference test file named there. +- **Convention reminders** — for the resolve package, set + `DisableSubgraphRequestDeduplication = true` and build the arena/loader per the canonical unit + shape; for `execution/engine`, **no new shared helpers**, inline GraphQL, full normalized + snapshot asserts, duplication-over-sharing. + These are not optional and are restated every task because they are the most common miss. +- **Modern Go reminder** — apply current idioms (`/use-modern-go` loaded). +- **Stop condition** — the PR's `Acceptance criteria`. + +A Codex task must never say "and also clean up nearby code" or "refactor while you're there". +Surgical changes only; every changed line traces to the PR's stated scope. +If Codex notices unrelated dead code or a real adjacent bug, it reports it back to Claude, +who records it for [07-UNRELATED-FINDINGS.md](./07-UNRELATED-FINDINGS.md) — it is not fixed in this PR. + +--- + +## 7. How Claude reviews the diff + +Claude reviews every Codex diff before the human ever sees it. +The review checks, in order: + +- **Scope.** Only the allow-listed files changed; nothing from the `Excludes` leaked in; + no unrelated formatting or "improvements" to adjacent code. +- **Contract fidelity.** The signatures match the spec exactly (e.g. the real + `LoaderCache.Set(ctx, entries []*CacheEntry) error` with per-entry TTL — *not* the stale + doc's `Set(ctx, entries, ttl)`; the real `RenderCacheKeys(a arena.Arena, ctx, items, prefix)`). + The known doc-drift traps are catalogued in the public-API findings and called out in the specs — + follow the code, not the stale `ENTITY_CACHING_INTEGRATION.md`. +- **Invariants.** For any cache read/write/merge: StructuralCopy isolation on every cache boundary, + working-copy-and-swap on merge-into-existing (never mutate a live L1 entry in place), + L1 main-thread-only, fail-closed on nil `ProvidesData`, and the L1-gating flag checks. + These are the load-bearing rules from [01-ARCHITECTURE-SPEC.md](./01-ARCHITECTURE-SPEC.md) + and the project `CLAUDE.md`. +- **Tests.** Exact assertions only (`assert.Equal` on full values — never `Contains`, + `GreaterOrEqual`, or fuzzy comparisons), full-struct asserts, inline literals, + one-item-per-line for multi-key literals, a "why" comment on every snapshot/log event line, + and the `ClearLog → GetLog + assert` pairing with no orphan clears. +- **No code comments referencing PRs, issues, review threads, or reviewer names** — + comments explain behavior, not history. +- **Acceptance criteria met**, verbatim from the PR-plan entry. + +Where useful, Claude runs `codex review` for an independent pass and `codex challenge` +to try to break the new code — a second opinion before the human gate. + +If the review finds problems, Claude writes precise, minimal change requests and hands them +back to Codex. +Claude does not silently fix the code itself. + +--- + +## 8. The test and benchmark gates + +Every PR must clear its gates before it is marked ready. +Commands are from [06-TEST-AND-BENCH-PLAN.md §6.7](./06-TEST-AND-BENCH-PLAN.md). + +### 8.1 Build and unit tests + +```sh +go build ./... +go test ./v2/pkg/engine/resolve/... +go test -race ./v2/pkg/engine/resolve/... +``` + +For E2E PRs, also: + +```sh +go test ./execution/engine/... +``` + +Targeted runs while iterating (faster feedback): + +```sh +go test -run TestL1Cache ./v2/pkg/engine/resolve/... -v +go test -run TestFederationCaching ./execution/engine/... -v +``` + +### 8.2 Benchmarks (only PRs in the benchmark scope) + +A PR that touches a StructuralCopy merge site, the loader hot path, or the analytics collector +must run its benchmark and compare against the base. +The regression gates are the overhead ladder, the Copy-Budget merge benches, and the +non-caching floor (see [06-TEST-AND-BENCH-PLAN.md §6.8](./06-TEST-AND-BENCH-PLAN.md)). + +```sh +# base branch +go test -run=^$ -bench 'BenchmarkCachingOverhead|BenchmarkMerge|BenchmarkNonCaching' \ + -benchmem -count=10 ./v2/pkg/engine/resolve/... | tee /tmp/before.txt +# change branch +go test -run=^$ -bench 'BenchmarkCachingOverhead|BenchmarkMerge|BenchmarkNonCaching' \ + -benchmem -count=10 ./v2/pkg/engine/resolve/... | tee /tmp/after.txt +benchstat /tmp/before.txt /tmp/after.txt +``` + +Two rungs to watch hardest: +`ConfiguredButDisabled` must stay at parity with `Disabled` (any gap is a guard leak), +and each `BenchmarkMerge*` must stay within **one** StructuralCopy of the matching +`BenchmarkNonCaching*` floor. + +### 8.3 The acceptance-criteria sync + +If the PR added or changed any caching test, it must update +`docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md`: +every new/changed test linked from its AC with relative path + line + name +(see [06-TEST-AND-BENCH-PLAN.md §2.4](./06-TEST-AND-BENCH-PLAN.md)). +This is part of the gate, not an afterthought. + +--- + +## 9. How PRs stack and stay mergeable + +The stacking discipline is owned by the PR-plan docs; this is the operational summary. + +- PRs stack **linearly**: PR N branches off PR N-1's branch + (see [03-PR-PLAN-graphql-go-tools.md §1.2](./03-PR-PLAN-graphql-go-tools.md)). +- A PR merges into the feature branch (squash) **only after** the PR(s) it depends on have merged. +- When a dependency merges, **rebase the child branch onto the new feature-branch tip** + before merging the child — on the child's own branch, never mutating the parent. +- Use `gh pr create --base ` so the review diff is scoped to just that PR's changes. +- **Every PR is independently mergeable into the feature branch** and leaves the feature branch + green: data-only PRs ship structs/interfaces the runtime ignores until a later "wire it on" PR; + the two behavior-flipping PRs in gqtools are the loader-integration PR (PR 7) and the + visitor-wiring PR (PR 11) — see [03-PR-PLAN-graphql-go-tools.md §1.4](./03-PR-PLAN-graphql-go-tools.md). +- The hard external prerequisite is **PR 0** (cut + pin a real astjson release): nothing in the + stack compiles until the astjson primitives are released and pinned. + See [05-ASTJSON-PRIMITIVES.md](./05-ASTJSON-PRIMITIVES.md). +- The router stack (R1–R11) waits on the matching gqtools releases per the dependency table in + [04-PR-PLAN-router.md](./04-PR-PLAN-router.md); R1 cannot start until the gqtools foundation is released. + +Interleave test PRs with code PRs per the test plan's interleaving rule, so the feature branch is +never carrying behavior without coverage. + +--- + +## 10. The human approval gate (where the loop pauses) + +The loop runs autonomously, then **stops and waits for a human** at three points: + +1. **Before `git push`** — the new branch and its commits exist only locally until approved. +2. **Before `gh pr create`** — Claude prepares the PR body (drafted); the human opens the PR. +3. **Before merge into the feature branch** — the human merges; the actors never merge. + +Neither Claude nor Codex posts, comments, reviews, approves, or merges on GitHub on the human's +behalf — this is a hard global rule. +Reading from GitHub (e.g. `gh pr view`, `gh api` to fetch CI status) is allowed; writing is not. + +At each gate Claude presents: the diff summary, the gate results (tests, race, benchmarks), +the acceptance-criteria checklist, and the drafted PR body — then ends its turn. + +--- + +## 11. Final verification per feature + +A feature (a directive end-to-end, or the foundation, or the router integration) is **done** only +after a full, clean run across both the unit and E2E tiers plus the benchmark gates. + +Per directive / per feature, run and confirm green: + +```sh +go build ./... +go test ./v2/pkg/engine/resolve/... +go test -race ./v2/pkg/engine/resolve/... +go test ./execution/engine/... +go test -run=^$ -bench 'BenchmarkCachingOverhead|BenchmarkMerge|BenchmarkNonCaching' \ + -benchmem -count=10 ./v2/pkg/engine/resolve/... +``` + +Then confirm: + +- the benchmark comparison shows no regression beyond the budget + (`ConfiguredButDisabled ≈ Disabled`; each `Merge*` within one copy of its `NonCaching*` floor); +- `ENTITY_CACHING_ACCEPTANCE_CRITERIA.md` lists every test for this feature with path + line + name; +- the per-directive test matrix in [06-TEST-AND-BENCH-PLAN.md §5](./06-TEST-AND-BENCH-PLAN.md) + is fully covered for the directive; +- the @requestScoped E2E tests (AC-RS-01..07) are *un-skipped and exact* when the planner work has + landed (never copy the placeholder fuzzy `if reviewsCalls == 0` smoke checks). + +When the full gqtools stack is green and the router stack is integrated, the feature branch is +merged into the default branch — once, at the human gate. + +--- + +## 12. Per-PR checklist template + +Copy this block per PR. +Fill the header from the PR's entry in the PR-plan doc, then work top to bottom. + +```text +PR: Stack: gqtools | router +Branch: feat/entity-caching-<nn>-<slug> (fresh, off <parent branch>) +Worktree: ../ec-<nn>-<slug> +Depends on (must be merged): PR <...> +Spec / reviewer-guide doc: <link from the PR-plan entry> +Directive spec: directives/<name>.md (if any) ADR: adr/00NN-<name>.md (if any) + +SCOPE +[ ] Read PR-plan entry: Goal / Scope / Excludes / Dependencies / Acceptance criteria +[ ] Read the named reviewer-guide doc (+ directive spec + ADR) +[ ] All dependency PRs confirmed merged into the feature branch + +PREP +[ ] Reviewer guide written (what it adds, files touched, contracts, AC, conventions) +[ ] Fresh branch cut off the correct parent — NO existing branch touched +[ ] Worktree created +[ ] /use-modern-go loaded for this session + +IMPLEMENT (Codex) +[ ] Codex task written: goal + reviewer guide + file allow-list + Excludes + contracts +[ ] Test-first: failing tests added mirroring the §5 reference file +[ ] Implementation makes them pass; only allow-listed files changed +[ ] Convention reminders included (exact asserts, no shared E2E helpers, inline GraphQL) + +REVIEW (Claude) +[ ] Scope clean: no Excludes leaked, no unrelated edits/formatting +[ ] Contracts match spec exactly (watch the documented doc-drift signatures) +[ ] Invariants held: StructuralCopy isolation, working-copy-and-swap, L1 main-thread-only, + fail-closed on nil ProvidesData, L1-gating flag checks +[ ] Tests: exact assertions, full-struct, inline literals, one-item-per-line, + "why" comments, ClearLog→GetLog+assert pairing +[ ] No PR/issue/reviewer references in code comments +[ ] (optional) codex review + codex challenge passed + +GATES +[ ] go build ./... green +[ ] go test ./v2/pkg/engine/resolve/... green +[ ] go test -race ./v2/pkg/engine/resolve/... clean +[ ] go test ./execution/engine/... green (if E2E in scope) +[ ] Benchmarks run + benchstat vs base within budget (if in bench scope) +[ ] ENTITY_CACHING_ACCEPTANCE_CRITERIA.md updated (path + line + name per test) +[ ] Acceptance criteria from the PR-plan entry all met + +READY (human gate) +[ ] Diff summary + gate results + drafted PR body prepared +[ ] STOP — human pushes branch, opens PR, and merges (actors never do) +[ ] After merge: rebase dependent child branch onto new feature-branch tip; remove worktree +``` + +--- + +## 13. Quick reference — the loop in one screen + +1. Scope from the PR-plan entry; confirm dependencies merged. +2. Claude writes the reviewer guide. +3. Cut a fresh branch + worktree off the correct parent (no existing branch touched). +4. Claude hands Codex a tightly-scoped, test-first, modern-Go task. +5. Codex implements; Claude reviews against the spec + conventions; iterate. +6. Run tests (`-race`) + benchmarks (benchstat vs base); update the AC doc. +7. Mark ready, draft the PR body, **stop at the human gate** for push / PR / merge. +8. After merge, rebase the next child onto the new tip; remove the worktree. diff --git a/docs/entity-caching/adr/0001-foundation.md b/docs/entity-caching/adr/0001-foundation.md new file mode 100644 index 0000000000..707879d6b2 --- /dev/null +++ b/docs/entity-caching/adr/0001-foundation.md @@ -0,0 +1,253 @@ +# ADR-0001: Foundation & Architecture for Entity Caching + +## Status + +Proposed + +## Context + +This project adds a two-level entity cache to the GraphQL router engine. +The engine resolves federated GraphQL queries by walking a tree of fetches against subgraphs. +Each fetch either fetches a root field (`Query.topProducts`) or resolves an entity by its `@key` (the federated `_entities` lookup). +The goal of entity caching is to avoid re-fetching the same entity or root field from a subgraph when the answer is already known, either within a single request (L1) or across requests (L2, an external store such as Redis). + +If you are new to this feature, the rest of this section gives you the minimum mental model. +The deeper references are linked at the end. + +### The two cache levels + +- **L1** is a per-request, in-memory cache. + It lives for the lifetime of one request, is read and written only on the resolver's main thread, and applies only to entity fetches (a root field has no prior entity data to reuse). + Its purpose is to deduplicate identical entity lookups that occur within one query plan. +- **L2** is an external cache, owned and implemented by the router (the engine never opens a Redis connection). + It survives across requests and applies to both root-field and entity fetches. + The engine talks to it only through a narrow interface. + +### Where the engine plugs in + +Two engine components do the actual resolution: + +- the **loader** (`v2/pkg/engine/resolve/loader.go`) drives fetches, batching, and the merge of fetched JSON back into the response tree, +- the **resolvable** renders the final JSON response. + +Both are large, hot, and correctness-critical. +The central design tension of this ADR is that caching must hook into the loader's fetch and merge path **without** rewriting the loader. + +### The JSON substrate + +The engine represents response data as `astjson.Value` trees allocated on an arena (`go-arena`). +Arena memory is released per request, so there is no GC pressure during a request, but it also means any value that escapes the request lifetime is unsafe to keep. +The cache layer leans heavily on one astjson primitive, **`StructuralCopy`**, which clones container nodes (objects, arrays) onto an arena while aliasing scalar leaves and object keys from the source. +This is cheap (one tree walk, no re-parse) and is the basis for keeping the cache and the live response tree from corrupting each other. +Crucially, the astjson APIs this feature needs are **unreleased**: they exist only on an open astjson PR branch, so the cache work cannot compile against the released astjson tag. +This makes the astjson release a hard prerequisite, captured in [05-ASTJSON-PRIMITIVES.md](../05-ASTJSON-PRIMITIVES.md). + +### What "the foundation" must establish + +The full feature spans many directives, planner passes, analytics events, and operation-type-specific behaviors (queries vs mutations vs subscriptions). +Trying to land all of that in one change would produce an unreviewable diff against the engine's most sensitive files. +This ADR exists to fix the **architecture and the seams** first, so that every later directive can land as a small, additive PR against stable contracts. +The directive specs are inventoried in [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) and detailed under [directives/](../directives/); the clean-architecture and seam picture is in [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). + +## Decision + +The foundation PR ships the **interfaces, the loader seam, and the documentation** for entity caching. +It does **not** ship any full directive implementation. +Concretely, the foundation establishes the seven decisions below. + +### 1. A minimal integration seam into the loader and resolvable + +Caching enters the loader at exactly **five call sites plus one boolean per fetch**, and the resolvable is left untouched. + +The five seams are: + +1. a **pre-fetch cache lookup** that, before a fetch is dispatched, asks the cache whether the entities or root field are already known, +2. a **post-fetch cache write** that, after a fetch returns, offers the fresh data to the cache, +3. a **merge point** where cached data is folded into the response tree, +4. a **per-request L1 read/write** for entity fetches on the main thread, +5. an **invalidation hook** that processes subgraph-supplied invalidation hints and mutation/subscription effects. + +The single per-fetch boolean is `cacheSkipFetch` (a fetch fully served from cache is not dispatched to the subgraph), with a companion `cacheMustBeUpdated` driving the write side. +The merge funnel is the existing `mergeResult` path in the loader (around `loader.go:1510`): when `cacheSkipFetch` is set, that funnel merges the cached value (`CacheKey.FromCache`) into the response via `astjson.MergeValues`, with a load-bearing `StructuralCopy` first. + +The collaborator object that owns all cache logic is a thin **`entityCache`** type held by the loader. +The loader calls into it at the five seams; the cache logic (key rendering, L1 map, L2 batching, merge orchestration, analytics emission) lives in the collaborator and its files (`loader_cache.go`, `loader_cache_transform.go`, `caching.go`, `cache_analytics.go`). +The behavior-bearing interfaces the loader depends on are extracted: **`LoaderCache`** (the L2 backend) and **`CacheKeyTemplate`** (key rendering). + +### 2. The L1/L2 cache interface + +**L2** is defined by the `LoaderCache` interface that the router implements: + +```go +Get(ctx, keys []string) ([]*CacheEntry, error) // slice same length as keys, nil = miss +Set(ctx, entries []*CacheEntry) error // TTL is per-entry, NOT a Set parameter +Delete(ctx, keys []string) error +``` + +A `CacheEntry` carries `Key`, `Value []byte` (opaque JSON payload), `TTL`, `RemainingTTL` (set by the backend on read), and `WriteReason`. +Two points are deliberate and contradict the older integration doc, which is stale here: + +- `Set` takes **no** `ttl` argument; TTL rides on each `CacheEntry.TTL`. + This is more flexible because one `Set` call can mix regular and negative-cache TTLs. +- Reads are issued in **bulk on the main thread** (`bulkL2Lookup`): one `Get` per batch of instances, and a single bulk-`Get` error fails the whole batch back to the subgraph rather than failing the request. + The interface still documents `Get`/`Set`/`Delete` as concurrency-safe so backends remain future-proof, but the current call pattern is main-thread bulk. + +**L1** is **not** a router-facing interface. +It is an engine-internal `map[string]*astjson.Value` on the loader, read and written only on the main thread, and gated per request by `ctx.ExecutionOptions.Caching.EnableL1Cache`. +The router never sees L1 except through that toggle. + +The router registers named L2 backends in `ResolverOptions.Caches map[string]LoaderCache`; every declarative config selects one by `CacheName`. + +### 3. The cache-key contract + +A cache key is produced by a **`CacheKeyTemplate`**, an engine-internal interface (see decision 7 on why it is internal): + +```go +RenderCacheKeys(a arena.Arena, ctx *Context, items []*astjson.Value, prefix string) ([]*CacheKey, error) +IsEntityFetch() bool +BatchEntityKeyArgumentPath() []string +EntityMergePath(pp PostProcessingConfiguration) []string +``` + +Two implementations exist: an **entity** template (key shape `{"__typename":"User","key":{"id":"123"}}`, built from `@key` fields only, never `@requires`) and a **root-field** template (key shape `{"__typename":"Query","field":"x","args":{...}}`, args sorted alphabetically). +Templates are alias-independent by construction; alias awareness lives in the separate `ProvidesData` field tree (see decision 5). + +The key contract the router must mirror for manual invalidation is the **transform pipeline**, applied identically on read, write, and delete: + +``` +GlobalCacheKeyPrefix -> subgraph header-hash prefix -> L2CacheKeyInterceptor +``` + +Any caller that forgets `GlobalCacheKeyPrefix` will target the wrong key and leave stale entries; the spec calls this out as an easy router-side footgun. +The exported helper `ParseKeyFields(selectionSet string) []KeyField` lets the router compute entity keys from a `@key` selection-set string for invalidation and analytics. + +### 4. The analytics sink + +Analytics is **pull-based and single-shot**. +The router sets `ctx.ExecutionOptions.Caching.EnableCacheAnalytics` per request; when false the collector is a no-op with zero overhead. +After resolution the router calls `ctx.GetCacheStats()` exactly once, which **snapshots and releases** the pooled collector and returns a `CacheAnalyticsSnapshot`. + +The snapshot is a flat struct of event slices (`L1Reads`/`L2Reads`, `L1Writes`/`L2Writes`, `FetchTimings`, `ShadowComparisons`, `MutationEvents`, plus error and header-impact events) with convenience derivations (`L1HitRate`, `CachedBytesServed`, `EventsByEntityType`, etc.). +A separate, finer-grained surface — per-fetch `CacheTrace` in response extensions — is gated on `ctx.TracingOptions` (`Enable && !ExcludeCacheStats`), not on the analytics flag. + +The foundation exposes `GetCacheStats()` as the **single sanctioned read path**. +The collector also has many exported `Record*`/`Merge*` methods flagged in code as "for external consumers"; the foundation treats `GetCacheStats()` as the contract and defers a decision on whether those lower-level methods stay exported (see Open Questions). + +### 5. The arena / StructuralCopy strategy + +Every boundary where the cache and the response tree meet is crossed with **`StructuralCopy`**, never a raw pointer hand-off. +The invariant: clone container nodes onto the request arena, alias leaves from the source; safe only because cache values and response values share the same arena lifetime within one request. + +Four loader helpers wrap the two astjson primitives (`StructuralCopy`, `StructuralCopyWithTransform`) and are the single seam for all four directions: + +- **L1 write**: `structuralCopyNormalizedPassthrough` — rename aliases to schema names but keep **all** source fields (including `@key` fields not in `ProvidesData`), via `Transform.Passthrough = true`. +- **L1 read**: `structuralCopyDenormalizedPassthrough` — restore aliases, keep all accumulated fields. +- **L2 write**: `structuralCopyNormalized` — rename **and project** to `ProvidesData` fields only (`Passthrough = false`), then `MarshalTo` to heap bytes for the external store. +- **L2 read**: `structuralCopyDenormalized` — restore aliases, project. + +`Transform.Passthrough` is the L1-vs-L2 switch: keep-everything for L1 (so sibling fetches accumulate), project-to-listed for L2 (so entries are minimal and self-contained). +Transforms are ephemeral, built inline on reusable slabs and discarded. +Merges into an existing L1 entry use **working-copy-and-swap**: `StructuralCopy` the live entry into a working copy, `MergeValues` against the copy, store the copy on success or the fresh incoming value on failure. +The live entry is never mutated in place, because `MergeValues` is non-atomic on failure and a partial mutation would corrupt every sibling L1 key pointing at the same entry. +`DeepCopy` (full clone including scalars) is used in exactly one place — heap isolation of per-request `Variables` — because that is the only heap/arena boundary the foundation crosses. +The astjson primitive set, including the breaking `MergeValues` signature change (`changed bool` return dropped), is specified in [05-ASTJSON-PRIMITIVES.md](../05-ASTJSON-PRIMITIVES.md). + +### 6. ProvidesData: the alias-aware field shape + +Alongside the cache-key template, each fetch carries a `ProvidesData *Object` field tree describing what the fetch yields, including aliases and per-field argument metadata. +Cache keys come from the template; **normalization, field-widening checks, and L1 optimization all key off `ProvidesData`**. +This split is part of the foundation contract because the directive PRs populate `ProvidesData` (planner side) and consume it (resolver side). + +### 7. The foundation ships seam + interfaces + docs, NOT directives + +The foundation PR is explicitly scoped to: + +- the loader seam (five call sites plus the `cacheSkipFetch`/`cacheMustBeUpdated` booleans) and the `entityCache` collaborator, +- the interfaces (`LoaderCache`, `CacheKeyTemplate`) and the data shapes (`CacheEntry`, `CachingOptions`, `FetchCacheConfiguration`, `ProvidesData`, the analytics snapshot/event types, the cache-trace types), +- the StructuralCopy helper layer, +- the documentation set this ADR belongs to. + +It does **not** ship the composition-side directive grammar, the per-directive planner wiring (`cachingPlannerState`, `configureFetchCaching`), the `@requestScoped` selection-set widening pass, the `optimizeL1Cache` postprocess, or any production cache backend. +Each of those lands as its own stacked PR against the now-stable seam. +The PR sequencing is in [03-PR-PLAN-graphql-go-tools.md](../03-PR-PLAN-graphql-go-tools.md) (engine) and [04-PR-PLAN-router.md](../04-PR-PLAN-router.md) (router); per-directive ADRs are `adr/00NN-<name>.md`; the implementation loop is [08-EXECUTION-RUNBOOK.md](../08-EXECUTION-RUNBOOK.md). + +### Why this seam keeps the loader mostly untouched + +The loader is the engine's hottest, most correctness-sensitive file. +The seam is designed so that, when caching is disabled, the loader behaves exactly as before: +the five hooks are guard-clause early-returns, and the one boolean per fetch defaults to "fetch normally." +All cache mechanism — key rendering, the L1 map, L2 batching, transform-driven copies, merge orchestration, analytics — lives in the `entityCache` collaborator and its sibling files, not inline in the loader's resolution logic. +The loader's only new responsibility is to **call the collaborator at the right moments and honor `cacheSkipFetch`**, and to route the cached value through its existing `mergeResult` funnel. +Because the merge funnel already exists for non-cache reasons, caching reuses it rather than adding a parallel merge path. +This means the loader diff is small and mechanical, the cache logic is independently testable against the interfaces, and the resolvable (rendering) needs no changes at all — cached data is indistinguishable from fetched data once merged into the response tree. +The result is that future directive PRs touch the collaborator and the planner, almost never the loader, which is exactly what makes the stacked-PR plan safe to execute. + +## Consequences + +### Positive + +- The loader and resolvable stay almost entirely as-is; the cache-disabled path is unchanged behavior. +- Cache mechanism is isolated behind two interfaces and one collaborator, so it is unit-testable without a live subgraph or Redis. +- Every later directive is an additive PR against frozen contracts, keeping each diff small and reviewable. +- L2 backends are pluggable and named; the router owns all I/O, the engine owns none. +- The arena/StructuralCopy discipline gives same-request cache isolation without GC cost and without byte round-trips on the hot path (L2 serialization to bytes happens only at the external boundary). + +### Negative / costs + +- The cache layer hard-depends on **unreleased** astjson primitives; an astjson release must land first, blocking everything. + The breaking `MergeValues` signature change means the engine will not compile against the current released astjson tag. +- `CacheKeyTemplate` is coupled to `arena.Arena` and `astjson.Value`, so it is not cleanly router-facing; the router configures keys declaratively but cannot implement the template itself. +- StructuralCopy's leaf-aliasing is only safe under same-arena/same-request lifetime; any future code that lets a copied value outlive its source arena (without `MarshalTo` to heap bytes) is a use-after-free. +- The L1 map and `@requestScoped` coordinate L1 are main-thread-only; correctness depends on that and would break under naive parallelization. +- Several footguns are inherent to the contract and must be documented, not designed away: byte-identical cache keys across read/write/delete, the full key-transform pipeline for manual invalidation, and `SubscriptionEntityPopulationConfiguration.FieldName` being mandatory. + +### Risks to manage downstream + +- A single bulk-`Get` error now fails an entire cache batch back to the subgraph; backends must keep `Get` reliable. +- `MergeValues` is non-atomic on failure; the working-copy-and-swap pattern must be preserved everywhere L1 entries are merged. + +## Alternatives Considered + +### A. Rewrite the loader to be cache-aware natively + +Bake cache lookup, L1, and L2 directly into the loader's fetch and merge logic instead of a collaborator. +**Rejected.** +It would couple the engine's hottest file to the entire cache feature, make the cache-disabled path harder to keep identical to today's behavior, and force every directive PR to touch the loader. +The thin-collaborator seam achieves the same runtime behavior with a fraction of the loader diff. + +### B. Make L1 a router-facing interface like L2 + +Expose L1 through the same `LoaderCache` shape so the router could supply an in-memory implementation. +**Rejected.** +L1 is request-scoped, main-thread-only, and operates on arena `astjson.Value` trees — exposing it would force arena/astjson types into the router API and invite cross-arena lifetime bugs. +L1 stays engine-internal behind a single per-request boolean. + +### C. Keep the older integration doc's signatures (`Set(ctx, entries, ttl)`, `RenderCacheKeys(ctx, fetch, *keys)`) + +Standardize on the previously-documented API surface. +**Rejected** as stale. +Per-entry TTL on `CacheEntry` is strictly more capable (mixed regular and negative-cache TTLs in one `Set`), and the real `RenderCacheKeys` signature reflects the arena/astjson coupling that makes the template engine-internal. +The foundation standardizes on the actual code, not the doc. + +### D. Ship the whole feature in one PR + +Land directives, planner wiring, postprocess, analytics, and backend together. +**Rejected.** +The diff against the loader and planner would be unreviewable and high-risk. +The interfaces-plus-seam-first approach is what makes the stacked plan tractable. + +### E. Push-based analytics (engine calls a router sink during resolution) + +Have the engine invoke a router callback per cache event instead of snapshotting at the end. +**Rejected for the foundation.** +Pull-based `GetCacheStats()` keeps the disabled path zero-overhead, avoids interleaving router code into hot resolution, and gives one clear release point for the pooled collector. +Subscription writes/invalidations are the deliberate exception, since they are inherently event-driven and use the dedicated `OnSubscriptionCacheWrite`/`OnSubscriptionCacheInvalidate` callbacks. + +## References + +- [00-OVERVIEW.md](../00-OVERVIEW.md) — executive summary and navigation +- [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) — clean architecture and integration seam +- [05-ASTJSON-PRIMITIVES.md](../05-ASTJSON-PRIMITIVES.md) — the astjson dependency and unreleased-primitive prerequisite +- [06-TEST-AND-BENCH-PLAN.md](../06-TEST-AND-BENCH-PLAN.md) — test and benchmark plan +- [07-UNRELATED-FINDINGS.md](../07-UNRELATED-FINDINGS.md) — out-of-scope findings +- `v2/pkg/engine/resolve/loader.go`, `loader_cache.go`, `caching.go`, `cache_analytics.go`, `context.go` — the engine seam and cache types diff --git a/docs/entity-caching/adr/0002-key.md b/docs/entity-caching/adr/0002-key.md new file mode 100644 index 0000000000..47917f523d --- /dev/null +++ b/docs/entity-caching/adr/0002-key.md @@ -0,0 +1,197 @@ +# ADR-0002: @key caching support + +> Part of the entity-caching re-implementation document set. +> Foundation: [0001-foundation.md](0001-foundation.md). +> Directive contract (detailed): [../directives/key.md](../directives/key.md). +> Directive taxonomy and PR mapping: [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). +> Architecture seam this builds on: [../01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). + +## Status + +Proposed + +## Context + +The federation directive `@key(fields: _FieldSet!)`, applied to `OBJECT` and `INTERFACE` types, +names the field set that uniquely identifies an entity — for example `@key(fields: "id")` on `type User`. +It already exists in the schema and the planner. +The caching layer does not redefine it, +it *reads* it. + +For entity caching, `@key` is load-bearing twice over. + +First, it is the **source of cache-key identity**. +An entity's cache key is built from its `@key` fields and nothing else — never from `@requires` fields, +never from whatever arbitrary fields a particular query happened to select. +This is what lets two different requests that both ask for `User` with `id: "1"` collide on the same L1 and L2 entry, +regardless of how each query shapes the rest of its selection. + +Second, `@key` fields must **survive the L1 projection**. +A narrow fetch might select only `{ id, name }`, +but a later, wider entity fetch for the same `User` needs the `@key` present in the L1 entry to merge correctly. +If the L1 write dropped key fields that the current query did not select, accumulation across fetches within one request would break. + +What ADR-0001 already provides, and what this ADR depends on: + +- The `CacheKeyTemplate` interface (engine-internal), + with the entity-shape key format `{"__typename":"User","key":{"id":"123"}}` already defined as a contract. +- The `ProvidesData *Object` field tree carried on each fetch, + the alias-aware shape that drives normalization, projection, and widening. +- The four StructuralCopy helpers and the `Transform.Passthrough` switch that distinguishes L1 (keep all fields) from L2 (project to listed fields). +- The loader seam (the five hook points plus `cacheSkipFetch` / `cacheMustBeUpdated`) and the `entityCache` collaborator, + all of which are frozen contracts by the time this PR lands. + +This ADR records how `@key` plugs into that frozen seam to produce stable entity cache keys. +It does **not** introduce the entity cache *configuration* (binding a type to a named cache and TTL) — that is ADR-0006. +It introduces only the key-identity machinery that everything else keys on. + +## Decision + +`@key` support lands as **gqtools PR 3 (cache-key templates) / PR-CACHE-KEYS**, +stacked directly on the foundation PR (ADR-0001) and consumed by every later caching PR. +It adds plan-time metadata and one engine-internal key-template implementation, +and it touches the loader/resolvable hot path **not at all** beyond the seam ADR-0001 already established. + +### 1. Plan-time: extract the @key field set into a key template + +The planner's existing key-fields visitor already discovers each entity's `@key` selection. +This PR teaches the caching planner state to capture that selection as an alias-aware field tree and hand it to the entity key template. + +The on-the-wire shapes are additive and plain data: + +- `EntityQueryCacheKeyTemplate` — the entity key template, + carrying the `@key` field tree (only `@key` fields, never `@requires`) and the plan-time `TypeName` used as a fallback when `__typename` is absent from response data. + It satisfies `CacheKeyTemplate` with `IsEntityFetch() == true`, + `BatchEntityKeyArgumentPath() == nil`, and `EntityMergePath(...) == nil`. +- `KeyField{Name string, Children []KeyField}` — a recursive description of the key field set, + supporting composite keys (`@key(fields: "sku upc")`) and nested keys (`@key(fields: "address { city }")`). + This is derived from the template's field tree and is also what analytics and manual invalidation consume. +- `ParseKeyFields(selectionSet string) []KeyField` — an exported helper that turns a raw `@key` selection-set string into a `KeyField` tree, + so the router can compute entity keys for manual invalidation without re-implementing the parser. + +The template is attached to the fetch via the `FetchCacheConfiguration.CacheKeyTemplate` field that ADR-0001 already reserved. +Nothing in the planner's existing fetch-construction flow changes shape — the PR only fills a field that was previously empty. + +### 2. Resolve-time: render keys through the existing seam, byte-identically + +The entity key template implements the single rendering method from the foundation contract: + +```go +RenderCacheKeys(a arena.Arena, ctx *Context, items []*astjson.Value, prefix string) ([]*CacheKey, error) +``` + +It is called from `prepareCacheKeys` — the existing pre-fetch hook — and produces one `*CacheKey` per input item. +The rendering rules that make keys correct and collision-free: + +- **Key shape.** `{"__typename":"<Type>","key":{<key fields>}}`, + with `__typename` taken from the item's response data and falling back to the plan-time `TypeName` when absent. +- **Only key fields.** The template walks the `@key` field tree, not the response object, + so non-key fields the query selected never widen the key. +- **Number-to-string coercion.** Numeric key values are coerced to strings before serialization, + so an integer `id: 1` on the write path and a string `id: "1"` on the read path produce the same bytes and hit the same entry. + This applies to flat scalars and to scalars nested inside composite/nested key objects. +- **Empty-key skip.** If the resolved key object is empty — the `@key` fields were not present in the selection — + the item is skipped entirely rather than producing a key that would collide across every entity of the type. +- **Byte-identity.** Field order in the rendered key is deterministic so read, write, and delete always agree on the exact bytes. +- **Prefix application.** The `prefix` argument (the subgraph header-hash prefix from the key transform pipeline) is prepended as `prefix:<key>` when present, + keeping the template alias- and pipeline-independent while still slotting into `GlobalCacheKeyPrefix → header-hash prefix → L2CacheKeyInterceptor`. + +No new resolver hook is added. +The template is invoked through the foundation's existing `prepareCacheKeys` seam, +and its output (`[]*CacheKey`) flows through the existing L1/L2 read/write hooks unchanged. + +### 3. Resolve-time: L1 passthrough retains @key fields + +This is the second load-bearing role and the reason entity L1 uses *passthrough* rather than strict projection. + +The foundation's L1 write helper `structuralCopyNormalizedPassthrough` is configured with `Transform.Passthrough = true`, +which renames aliases to schema names but **keeps every source field**, +including `@key` fields that are not listed in the current fetch's `ProvidesData`. +This PR does not change that helper — it relies on the property the foundation built — but it is the `@key` contract that *requires* it: + +- An L1 entry must always carry its key fields, even when the fetch that wrote it did not select them in `ProvidesData`, + so that a later wider fetch can read the entry back, merge against it, and re-derive the same key. +- L2 (`structuralCopyNormalized`, `Passthrough = false`) projects to `ProvidesData` fields only, + but the key fields used to *compute* the L2 key are taken from the template, not from the projected payload, + so L2 entries stay minimal while still being addressed by their `@key`. + +### 4. How the PR stacks + +`@key` (PR-CACHE-KEYS) depends only on the foundation (ADR-0001): +the `CacheKeyTemplate` interface, the `CacheKey` struct, the `ProvidesData` shape, and the StructuralCopy/Passthrough discipline. +Every other caching PR depends on `@key`: + +- `@provides` / `@requires` (PR-CACHE-PROJECTION) refine the *payload* shape but reuse this key identity unchanged. +- Entity cache config (ADR-0006) binds a `TypeName` to a cache and TTL, but addresses entries by the keys this PR renders. +- Root-field cache config (ADR-0007) optionally maps a root-field key onto *this* entity key shape via `EntityKeyMappings`, + so a root query and an `_entities` fetch share an L2 entry — that mapping is meaningless without this template. + +The PR is small and additive: one template implementation, the `KeyField` tree and `ParseKeyFields` helper, and the planner wiring that fills `FetchCacheConfiguration.CacheKeyTemplate`. +The loader and resolvable are untouched beyond invoking a seam that already exists. + +## Consequences + +### Positive + +- Entity identity is stable and query-independent: the same entity always produces the same key regardless of selection, aliases, or argument variation on non-key fields. +- Integer/string key collision is eliminated by coercion, so subgraphs that emit `id: 1` and clients that send `id: "1"` share one entry. +- L1 accumulation across fetches within a request is correct, because `@key` fields are always retained by the passthrough L1 write. +- The router gets a sanctioned, exported path (`ParseKeyFields` plus the documented key shape) to compute keys for manual invalidation without copying engine internals. +- The change is a pure additive PR against frozen ADR-0001 contracts, keeping the diff reviewable and the cache-disabled path identical to today. + +### Negative / costs + +- Key rendering allocates and marshals a small JSON object per entity per fetch on the request arena. + This is the unavoidable cost of byte-identical keys; it is amortized by arena allocation and a reusable scratch buffer, and only paid when a fetch is actually cacheable. +- Composite and nested keys require a recursive walk of the key field tree; deeply nested `@key` selections cost proportionally more, though such keys are rare. +- The template couples to the arena and astjson (it returns arena-allocated `*CacheKey`s), so it remains **engine-internal** — the router configures key behavior declaratively but cannot implement the template itself. + This is an inherited boundary from ADR-0001, restated here because `@key` is where it first bites. +- Correctness depends on every caller reproducing the key-transform pipeline (`GlobalCacheKeyPrefix → header-hash prefix → L2CacheKeyInterceptor`); a manual-invalidation caller that forgets a stage targets the wrong key. + +### Performance implications + +- One marshal + one arena slice allocation per entity key, with a reused scratch byte buffer to avoid per-key heap churn. +- Number coercion adds a cheap type check and, only for numeric key fields, a coerce-to-string allocation. +- When a fetch can neither read nor write any cache, no key is rendered at all (the L1-enable post-process pass and the L2-enabled flag gate this upstream), so non-cacheable fetches pay nothing. + +### What becomes possible for later directives + +- `@provides` / `@requires` can define and exclude payload fields knowing identity is already pinned to `@key`. +- Entity and root-field cache configs can bind storage and TTL to a stable, shared key. +- Root-field ↔ entity cache sharing (`EntityKeyMappings`) has a concrete entity key shape to map onto. +- Analytics and trace can report per-entity sources and field hashes keyed by `KeyField`, derived from the same tree this PR introduces. + +## Alternatives considered + +### A. Key entities on the full selected field set instead of just @key + +Build the cache key from whatever fields the query selected. +**Rejected.** +Identity would change with every selection variation, so two queries for the same `User` that select different fields would never share a cache entry, +defeating both L1 dedup within a request and L2 dedup across requests. +It would also make keys explode combinatorially and make manual invalidation impossible to reproduce. +`@key` is the only stable, schema-defined notion of identity available. + +### B. Use strict projection (no passthrough) for L1, same as L2 + +Project L1 entries to `ProvidesData` fields only, dropping `@key` fields not in the current selection. +**Rejected.** +A narrow fetch (`{ id, name }`) would write an L1 entry missing key fields needed by a later wider fetch, +breaking within-request accumulation and re-derivation of the key on read. +L1 must use passthrough precisely so `@key` fields are retained; L2 can project because its key is computed from the template, not the stored payload. + +### C. Let the router implement the key template directly + +Expose `CacheKeyTemplate` as a router-facing interface so the router supplies its own key logic. +**Rejected.** +The template is coupled to `arena.Arena` and `astjson.Value` and must produce byte-identical keys consistent with the engine's read/write/delete paths. +Exposing it would force arena/astjson types into the router API and invite key-mismatch bugs. +The router instead configures keys declaratively (entity key mappings, prefixes, interceptors) and uses `ParseKeyFields` for invalidation, while the template stays engine-internal — the boundary decision recorded in ADR-0001. + +### D. Skip number-to-string coercion and rely on canonical JSON + +Assume key field values arrive in one consistent JSON type, so `id: 1` and `id: "1"` would simply be different entries. +**Rejected.** +In practice subgraphs serialize `ID` as a string in responses while clients may send integer literals as arguments, +so the write-path key `{"id":"1"}` and a read-path key `{"id":1}` would silently miss each other. +Coercing numbers to strings makes both paths converge on one entry, which is the behavior every consumer expects. diff --git a/docs/entity-caching/adr/0003-requires.md b/docs/entity-caching/adr/0003-requires.md new file mode 100644 index 0000000000..80e4985ae8 --- /dev/null +++ b/docs/entity-caching/adr/0003-requires.md @@ -0,0 +1,164 @@ +# ADR-0003: @requires caching support + +## Status + +Proposed + +## Context + +`@requires(fields: _FieldSet!)` is a federation directive on `FIELD_DEFINITION`. +It declares external fields that a resolver needs as *input* before it can resolve its own field, +where those required inputs are owned by another subgraph and supplied per request. +A classic example: a `shippingEstimate` field that `@requires(fields: "weight size")`, +where `weight` and `size` come from a different subgraph and are passed into the resolving subgraph as part of the `_entities` representation. + +For entity caching, `@requires` matters for one reason, and that reason is exclusionary. +The required fields are not part of the entity's stable identity, and they are not data the resolving subgraph *owns*. +They are inputs that vary per request, derived from whatever the upstream subgraph happened to return for *this* query. +If `@requires` data leaked into a cached entity shape, two failure modes follow. +First, a later request whose required inputs differ would read back stale, mismatched values from cache. +Second, the cache would fragment on data that has nothing to do with entity identity, so two requests for the same `User:1234` that supply different `@requires` inputs would store and read divergent entries for the same logical entity. + +The architecture foundation (ADR-0001) already provides everything this directive needs as a *substrate*. +The foundation establishes: +- the two-level cache model and the per-fetch `FetchCacheConfiguration`, +- the `ProvidesData *Object` field tree that travels on every fetch and is the single source of truth for *what shape gets cached* (see ADR-0001 decision 6), +- the four StructuralCopy helpers in `loader_cache_transform.go` that perform the alias-rename and field-projection during every cache read and write, + where L2 projection (`structuralCopyNormalized`, `Transform.Passthrough = false`) keeps only the fields listed in `ProvidesData` and drops everything else. + +`@provides` (ADR-0004) defines and populates the `ProvidesData` shape; `@requires` is then expressed as the absence of those fields *from* that shape. +This is why the directive-inventory dependency ordering places `@provides` before `@requires`: once the cached shape is defined positively by `ProvidesData`, the `@requires` rule is just "do not add required fields to it." + +The problem this ADR records is narrow and contract-shaped: +**how does the caching layer guarantee that `@requires` fields never enter the cached entity shape, without changing the loader hot path or the cache-key/projection primitives the foundation already froze?** + +Detailed contract: [../directives/requires.md](../directives/requires.md). +Foundation: [0001-foundation.md](0001-foundation.md). +Directive taxonomy and PR mapping: [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). + +## Decision + +`@requires` plugs into the foundation seam as a **plan-time projection rule only**. +It adds no resolver hooks, no loader call sites, and no new cache interface. +It is, deliberately, the smallest possible directive PR: it constrains what the planner writes into `ProvidesData` and into the cache-key field set, and the already-frozen resolver primitives do the rest for free. + +This directive's PR is **gqtools PR 5 / PR-CACHE-PROJECTION**, stacked on the foundation PR and co-resident with `@provides` (the two share the `ProvidesData` projection machinery, per [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md)). + +### What plugs in, and where + +**1. Cache-key field set excludes `@requires`.** +The entity cache-key template (`EntityQueryCacheKeyTemplate`) is built from `@key` fields only. +The planner must never feed `@requires` field configurations into the key-field list. +The foundation already documents the key shape as `{"__typename":"User","key":{"id":"123"}}` built "from `@key` fields only, never `@requires`" (ADR-0001 decision 3). +This ADR pins that as a hard requirement of the `@requires` contract rather than an incidental property: +the key must be alias-independent *and* `@requires`-independent so entity identity is stable no matter what inputs a query supplies. + +**2. `ProvidesData` omits `@requires` fields.** +The planner builds the per-fetch `ProvidesData *Object` (driven by `@provides` / the selection shape) so that it lists the fields the subgraph genuinely *provides* for the entity. +Fields the planner has marked with the requires dependency reason (the visitor's `IsRequires` flag on dependency origins) must not be emitted as `ProvidesData` entries on the cached shape. +Because the L2 write path projects strictly to `ProvidesData` (`structuralCopyNormalized`, `Passthrough = false`), excluding a field from `ProvidesData` is *sufficient* to keep it out of the L2 entry — no resolver change is required. + +**3. The L1 passthrough nuance is handled by exclusion, not special-casing.** +L1 writes use passthrough (`structuralCopyNormalizedPassthrough`, `Passthrough = true`), which keeps source fields beyond `ProvidesData` (notably `@key` fields needed for later merges, per ADR-0001 decision 5). +This means L1 passthrough *would* retain a `@requires` field if that field were present in the merged response value at write time. +The decision here is that `@requires` correctness is enforced at the **L2 boundary** (the cross-request store, where staleness is the real risk) via `ProvidesData` projection, and that the within-request L1 tier is not a staleness vector because L1 lives and dies inside one request — the required inputs are constant for that single request by construction. +The clean rule: `@requires` exclusion is a property of the *cached-shape definition* (`ProvidesData` for L2, `@key`-only for keys), and the resolver's existing copy primitives enforce it without a `@requires`-aware branch anywhere in the loader. + +### What it does NOT add + +- **No loader/resolvable change.** + The five foundation seams (pre-fetch lookup, post-fetch write, merge point, L1 read/write, invalidation) are untouched. + `@requires` never appears as a runtime condition; it is fully resolved into `ProvidesData` and key-field shape at plan time. +- **No new interface.** + `LoaderCache`, `CacheKeyTemplate`, and the analytics sink are unchanged. +- **No new per-fetch state.** + `cacheSkipFetch` / `cacheMustBeUpdated` semantics are unchanged. + `@requires` adds no boolean and no field to the per-fetch result. + +### Plan-time metadata it relies on (existing, not new) + +The planner already distinguishes dependency reasons via `fieldDependencyKindRequires` / the `IsRequires` flag on dependency origins (`visitor.go`), and exposes `@requires` configurations through `FederationMetaData.Requires` and `RequiredFieldsByRequires(typeName, fieldName)`. +The `@requires` caching work *consumes* this existing metadata to decide field membership of `ProvidesData` and the key-field set. +It introduces no new federation-metadata type; it only adds the projection rule that reads the existing requires metadata when the planner populates `FetchCacheConfiguration` / `FetchInfo.ProvidesData`. + +### How the PR stacks + +```text +astjson release (PR #0) + └─ Foundation (ADR-0001): seam + interfaces + ProvidesData + StructuralCopy helpers + └─ @key (PR-CACHE-KEYS): key-field identity, L1 passthrough keeps @key + └─ @provides + @requires (gqtools PR 5 / PR-CACHE-PROJECTION): + @provides DEFINES ProvidesData; @requires EXCLUDES required fields from it +``` + +`@requires` cannot be reviewed before `@provides`, because the exclusion is meaningless until the positive `ProvidesData` shape exists to exclude *from*. +Both land in the same projection PR. + +## Consequences + +### Positive + +- **Zero hot-path cost.** + Exclusion happens once at plan time; the resolver never branches on `@requires`. + The cache-disabled path and the cache-enabled path both run the same loader code. +- **Correctness by construction.** + Because cached shape is *defined* by `ProvidesData` (L2) and `@key` (keys), keeping `@requires` out of those two structures is the entire fix — there is no second place a required field could sneak into a cross-request entry. +- **Stable entity identity.** + Keys built from `@key` only mean two requests for the same entity collide on one cache entry regardless of which `@requires` inputs each supplied, which is exactly the dedup behavior L1 and L2 exist to provide. +- **Tiny, reviewable diff.** + The PR touches the planner's `ProvidesData` / key-field population and adds tests; it does not touch the loader, the resolvable, or any interface. + +### Negative / costs + +- **The guarantee is implicit, carried by `ProvidesData`.** + There is no runtime assertion that a `@requires` field is absent from a cached value; correctness depends on the planner never listing required fields in `ProvidesData` and never feeding them to the key template. + This must be locked down by tests (a `@requires` field present in the merged response must not appear in the L2 entry, and the key must be byte-identical across two requests with differing required inputs). +- **L1 passthrough retains a `@requires` field if present at write time.** + This is intentional and safe within one request, but it is a subtlety a re-implementer must understand: + `@requires` exclusion is enforced at the L2 boundary, not by stripping the field from every in-memory value. + A future change that promoted L1 entries across requests (which the design forbids) would break this assumption. +- **Coupling to the dependency-reason flag.** + The exclusion reads the planner's existing `IsRequires` / requires-config metadata; if that metadata were ever miscomputed, the cache would silently inherit the error. + The fix lives where the metadata is produced, not in the cache layer. + +### Performance implications + +- No additional copies, no additional allocations, no additional cache lookups. +- L2 entries are *smaller* than they would be if required fields were stored, because projection drops them, which marginally improves serialized payload size and backend bandwidth. + +### What becomes possible for later directives + +- Once `@requires` exclusion is established as "a property of `ProvidesData`," every later config concept (entity-cache config, root-field config, mutation, subscription) inherits correct projection for free — none of them needs to re-reason about required fields. +- The field-widening check (`validateItemHasRequiredData`, introduced by `@provides`) operates against `ProvidesData`, so it automatically does *not* demand `@requires` fields be present in a cached value — a cached entity that legitimately lacks request-derived inputs still satisfies the widening check. + +## Alternatives considered + +### A. Strip `@requires` fields at the L2 write boundary with a dedicated `@requires`-aware copy step + +Add a runtime pass in `updateL2Cache` that walks the value and removes any field marked as `@requires` before serialization. +**Rejected.** +It duplicates work the `ProvidesData` projection already does (the L2 write path *already* drops every field not listed in `ProvidesData`), adds a per-write tree walk on the hot path, and introduces a second source of truth for "what is cached." +Defining the cached shape positively via `ProvidesData` and simply not listing required fields is strictly simpler and free. + +### B. Include `@requires` fields in the cache key so entries with different required inputs never collide + +Widen the entity key to incorporate the `@requires` field values, making each (entity, required-inputs) combination its own entry. +**Rejected.** +This destroys the entire point of entity caching: the same `User:1234` would shard into many entries keyed by incidental request inputs, hit rates would collapse, and L1 within-request dedup would stop working because two fetch paths for the same entity could carry different inputs. +Entity identity must be `@key`-only (ADR-0001 decision 3); `@requires` belongs nowhere near the key. + +### C. Add a per-fetch `HasRequires` boolean and branch in the loader + +Surface a flag on `FetchCacheConfiguration` and let the loader skip or alter caching when a fetch involves `@requires`. +**Rejected.** +It violates the foundation's central constraint of keeping the loader untouched (ADR-0001, "what stays untouched"), pushes a plan-time concern into the runtime hot path, and is unnecessary: nothing about caching needs to *behave* differently for a `@requires` fetch — the field just must not be in the cached shape, which is a plan-time projection decision, not a runtime branch. + +## References + +- [../directives/requires.md](../directives/requires.md) — full `@requires` caching contract +- [0001-foundation.md](0001-foundation.md) — foundation seam, `ProvidesData`, StructuralCopy strategy +- [0004-provides.md](0004-provides.md) — defines and populates the `ProvidesData` shape this directive excludes from +- [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) — directive taxonomy and PR mapping +- [../01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3.4 (L1/L2 projection switch), §4 (cache-key model) +- `docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md` AC-L1-03 (cache keys use only `@key` fields; `@requires` never included) +- `v2/pkg/engine/resolve/loader_cache_transform.go` (`structuralCopyNormalized` L2 projection), `v2/pkg/engine/plan/visitor.go` (`IsRequires` dependency reason), `v2/pkg/engine/plan/federation_metadata.go` (`Requires`, `RequiredFieldsByRequires`) diff --git a/docs/entity-caching/adr/0004-provides.md b/docs/entity-caching/adr/0004-provides.md new file mode 100644 index 0000000000..bd4c515678 --- /dev/null +++ b/docs/entity-caching/adr/0004-provides.md @@ -0,0 +1,208 @@ +# ADR-0004: @provides caching support + +## Status + +Proposed + +## Context + +The federation directive `@provides(fields: _FieldSet!)` applies to a `FIELD_DEFINITION` and declares that, +when a subgraph resolves that field, +it can return additional fields of the referenced entity inline, +without a separate `_entities` fetch. +The directive already exists in the schema and the planner reads it during query planning. +The caching layer does not redefine it. +It *consumes* it, and re-implementing caching cleanly means re-consuming it correctly. + +For caching, `@provides` answers one question that the foundation deliberately left open: +**what is the exact field shape a fetch yields at a given location, and how does that shape relate to the cached entity shape?** + +The foundation, recorded in [0001-foundation.md](0001-foundation.md), already established the machinery this directive needs but did not populate it: + +- Each fetch carries a `ProvidesData *Object` field tree — + an alias-aware description of the shape the query expects at the fetch location (foundation decision 6). +- The four StructuralCopy helpers (`structuralCopyNormalized` / `structuralCopyDenormalized` and their passthrough variants) already accept a `*Object` and drive their rename / project / passthrough behavior from it (foundation decision 5). +- The cache-key templates are alias-independent by construction; + all alias awareness was explicitly deferred to `ProvidesData` (foundation decision 3). + +So the foundation built the slot. +This directive fills it. +Without a populated `ProvidesData`, +the L2 projection has nothing to project to, +the field-widening check has no required-field list to validate against, +and cached values cannot be denormalized back into an aliased response tree. + +Three concrete caching behaviors depend on a correctly populated `ProvidesData`: + +1. **L2 projection.** + An L2 entry must be minimal and self-contained so it round-trips cleanly across requests. + The L2 write path (`structuralCopyNormalized`, `Passthrough = false`) keeps only the provided / listed fields and drops everything else. + `ProvidesData` is the field list it projects to. + +2. **Field-widening check.** + A narrow query (`{ id name }`) must not poison the cache for a wider entity fetch (`{ id name email }`). + Before serving a cached value, the resolver calls `validateItemHasRequiredData(cachedValue, ProvidesData)` + to confirm the cached value contains *all* fields this fetch requires; + if it does not, the hit is downgraded to a miss / partial hit and the fetch proceeds. + `ProvidesData` is the required-field list. + +3. **Alias round-tripping.** + Cache entries are stored under schema field names (normalized); + the active query may alias those fields. + `ProvidesData` carries the response-side aliases (`Field.Name` vs `Field.OriginalName`, `Object.HasAliases`) + so cached values can be denormalized back into the response tree under the names the current query expects. + +`ProvidesData` is also the shape that distinguishes L1 from L2 behavior via the `Passthrough` switch, +and it is read by the L1-enable post-process pass (the union-of-providers coverage check), +but those are L1 and post-process concerns; +this ADR is scoped to the projection / widening / alias contract that `@provides` defines. + +## Decision + +Re-implement `@provides` caching support as the plan-time population of `ProvidesData` +and the resolver-side consumption of it for projection and widening, +**without touching the loader or resolvable hot path**. +This ships as **gqtools PR 5 / PR-CACHE-PROJECTION**, stacked on the foundation PR. + +### What plugs in where + +**Plan time (new work in this PR).** +A provides-fields visitor walks the planned response `Object` tree per fetch and produces the fetch's `ProvidesData *Object`. +The `*Object` it produces is the entity-shaped sub-tree the fetch yields, +with `Field.Name` set to the response key (alias when aliased), +`Field.OriginalName` set to the schema name, +and `Object.HasAliases` set when any field on the object is aliased. +A critical shape rule, carried over from the foundation contract: +for a nested entity fetch, `ProvidesData` must contain the *entity* fields (`id`, `username`), +not the parent field that wraps them (`author`). +The planner is responsible for navigating to the entity level before attaching the shape. + +**Resolve time (no new hot-path code).** +The resolver already has the four StructuralCopy helpers and `validateItemHasRequiredData` from the foundation; +this PR is what makes them meaningful by giving them a populated `*Object` to operate on. +The consumption points are all *inside the existing cache collaborator*, not in the loader's resolution flow: + +- L2 write projects through `structuralCopyNormalized(value, ProvidesData)` (rename + drop unlisted). +- L2 read denormalizes through `structuralCopyDenormalized(value, ProvidesData)` (restore aliases, projected). +- Cache hits are gated through `validateItemHasRequiredData(cachedValue, ProvidesData)` so a narrow value never satisfies a wider fetch. + +When `ProvidesData` is nil or has no aliases, +every helper falls back to a plain `StructuralCopy` and `validateItemHasRequiredData` is a no-op accept — +so a fetch with no `@provides` shape degrades to today's behavior, never to corruption. + +### Why this keeps the loader / resolvable untouched + +The loader still calls the same five seams and honors the same two booleans (`cacheSkipFetch` / `cacheMustBeUpdated`) from the foundation. +This PR changes *what the collaborator computes when those seams fire*, +by threading a now-populated `ProvidesData` through the StructuralCopy helpers and the widening check. +No new seam, no new boolean, no change to `mergeResult`'s signature, +and zero change to the resolvable's two-pass walk. +The new control flow lives in the planner (the provides-fields visitor) and in the collaborator helpers, +which is exactly the property the foundation was designed to preserve. + +### New metadata and annotations + +- **Plan-time annotation:** `FetchInfo.ProvidesData *Object`, populated by the provides-fields visitor (the field already exists from the foundation; this PR fills it). +- **No new fetch-config fields.** + Projection and widening are driven entirely off the existing `ProvidesData` and the existing helpers. +- **No new resolver hooks.** + The consumption sites (`structuralCopyNormalized` / `structuralCopyDenormalized` / `validateItemHasRequiredData`) are foundation surface; + this PR only supplies their input. + +### How its PR stacks + +PR-CACHE-PROJECTION stacks directly on the foundation PR and on PR-CACHE-KEYS (`@key`). +`@key` must land first because cache-key identity and the L1 passthrough that retains `@key` fields are prerequisites; +`@provides` then defines the projected shape *around* that stable identity. +Per the dependency ordering in [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md), +`@provides` lands before `@requires`, +because `@requires` is most cleanly specified as an *exclusion from* the `ProvidesData` shape that this PR introduces. +`@requestScoped` also consumes this PR's `ProvidesData` for its own widening check, +so it stacks after `@provides` even though it is otherwise independent of the L2 / mutation / subscription branch. + +## Consequences + +### Positive + +- L2 entries are minimal and self-contained, + because projection drops everything not in the provided shape, + which keeps external storage small and makes entries safe to round-trip across requests. +- The field-widening check makes cache hits *correct under query variation*: + a narrow root query can no longer serve stale-shaped data to a wider entity fetch. +- Alias handling becomes uniform: + the same `ProvidesData`-driven normalize / denormalize pipeline serves L1, L2, and `@requestScoped`, + so there is one alias contract, not three. +- The loader and resolvable remain untouched, + so this PR is a small, additive diff against the planner plus the already-tested collaborator helpers. + +### Negative / costs + +- `ProvidesData` is now load-bearing for correctness, not just optimization. + A planner bug that attaches the wrong shape (for example the parent field instead of the entity fields) silently corrupts projection and widening, + so the shape rule (entity-level, not wrapper-level) must be tested directly. +- The shape is alias-aware and per-fetch, + which adds planner complexity in the provides-fields visitor (response-key vs schema-name resolution, nested navigation). +- Building the ephemeral normalize / denormalize Transforms from `ProvidesData` costs a tree walk per cache operation, + though it is amortized on the reusable transform slabs and is far cheaper than a byte round-trip. + +### Performance implications + +- L2 projection trims payload bytes before they cross the heap boundary (`MarshalTo`), + reducing external store size and serialization cost. +- The widening check is a single structural walk of the cached value against `ProvidesData`; + it runs only on candidate hits and short-circuits on the first missing field. +- When `ProvidesData` has no aliases the helpers fall back to plain `StructuralCopy` with no Transform build, + so the alias machinery costs nothing for queries that do not alias. + +### What this makes possible for later directives + +- `@requires` (PR-CACHE-PROJECTION, same PR family) can be expressed purely as *exclusion from* this shape: + request-derived `@requires` fields are simply never added to `ProvidesData`, + so they never enter the cached projection. + See [0003-requires.md](0003-requires.md). +- `@requestScoped` reuses this PR's `ProvidesData` and `validateItemHasRequiredData` for its coordinate-L1 widening guard, + inheriting the same correctness property for free. + See [0005-request-scoped.md](0005-request-scoped.md). +- Root-field L1 promotion derives entity-shaped sub-Objects from `ProvidesData`, + so the whole-response caching and entity-key-sharing work depends on this shape being present. + +## Alternatives Considered + +### A. Project / widen off the cache-key template instead of a separate ProvidesData shape + +Fold the field shape into the `CacheKeyTemplate` so one object both computes the key and describes the projection. + +**Rejected.** +The foundation deliberately made templates alias-independent so the same entity produces the same key regardless of aliasing (foundation decision 3). +Mixing the alias-aware projection shape into the key template would either re-introduce alias dependence into keys (breaking cross-request key stability) or bloat the template interface with concerns it should not own. +Keeping `ProvidesData` as a separate, alias-aware `*Object` preserves the clean split: templates do identity, `ProvidesData` does shape. + +### B. Cache the full subgraph response and skip projection entirely + +Store whatever the subgraph returned, unprojected, and rely on the response-shape merge to pick out fields on read. + +**Rejected.** +Unprojected entries leak `@requires` fields and request-derived data into the cache, +which is exactly the staleness hazard `@requires` exclusion exists to prevent (see [0003-requires.md](0003-requires.md)). +They also bloat external storage and make entries non-self-contained, +so two requests selecting different field subsets could not safely share an entry. +Projection to the provided shape is what makes an L2 entry a clean, reusable unit of entity data. + +### C. Skip the field-widening check and trust that any cache hit is complete + +Treat any present cache entry as a full hit without verifying it contains the fields the current fetch needs. + +**Rejected.** +A narrow earlier query (`{ id name }`) would populate an entry that a later wider fetch (`{ id name email }`) would then read as a hit, +serving `email: undefined` and silently corrupting the response. +The widening check via `validateItemHasRequiredData(cachedValue, ProvidesData)` is the only thing that keeps cache hits correct under query-shape variation, +and `ProvidesData` is precisely the required-field list it needs. +Dropping it trades a single short-circuiting structural walk for silent data corruption. + +## References + +- Directive contract: [../directives/provides.md](../directives/provides.md) +- Foundation decision record: [0001-foundation.md](0001-foundation.md) +- Directive inventory and dependency ordering: [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) +- Related directives: [0002-key.md](0002-key.md), [0003-requires.md](0003-requires.md), [0005-request-scoped.md](0005-request-scoped.md) +- Architecture spec (L1/L2 projection, the Passthrough switch): [../01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) diff --git a/docs/entity-caching/adr/0005-request-scoped.md b/docs/entity-caching/adr/0005-request-scoped.md new file mode 100644 index 0000000000..306f3024ad --- /dev/null +++ b/docs/entity-caching/adr/0005-request-scoped.md @@ -0,0 +1,180 @@ +# ADR-0005: @requestScoped caching support + +## Status + +Proposed + +## Context + +`@requestScoped` is the **one new directive** the entity-caching feature introduces. +Every other caching concept either reuses an existing federation directive (`@key`, `@requires`, `@provides`) or arrives as a Go configuration struct synthesised by the router. +`@requestScoped` is declared in subgraph SDL and validated at composition time: + +```graphql +directive @requestScoped(key: String!) on FIELD_DEFINITION +``` + +### The problem it solves + +Some fields return a value that is **identical for the whole request inside one subgraph**, regardless of which entity the value is attached to. +The canonical case is a "current viewer" or per-session value that the same subgraph would re-derive on every entity fetch. +Without coordination, a federated query that touches many entities pays for the same subgraph round-trip many times to recompute one request-constant value. + +`@requestScoped(key: "X")` lets the schema author declare that coordination point. +Its model is **purely symmetric**: every field annotated with the same `key` in the same subgraph shares one per-request coordinate L1 entry, keyed `{subgraphName}.X`. +There is no provider/receiver distinction. +Whichever annotated field resolves first **writes** its value into the coordinate L1; every later field with the same key **reads** it back and may skip its own subgraph fetch entirely. +Each participating field is therefore both a reader (an injection hint) and a writer (an export). + +### What the foundation (ADR-0001) already provides + +`@requestScoped` is a side-branch of the dependency graph. +It depends only on the foundation and on the `@provides` `ProvidesData` machinery; it is **independent of L2, mutations, and subscriptions**. +The foundation already supplies everything the run-time half needs: + +- The **L1 layer concept** and its single per-request gate, `ctx.ExecutionOptions.Caching.EnableL1Cache`. + The coordinate L1 is part of the L1 layer and rides on this same flag — disabling L1 disables `@requestScoped` too. +- The **StructuralCopy copy primitives** and the four loader helpers (`structuralCopyNormalized` / `structuralCopyDenormalized` and their passthrough variants), including the arena-lifetime invariant that every cache boundary is crossed with a copy, never a raw pointer hand-off. +- The **`ProvidesData *Object`** alias-aware field shape carried on each fetch, and the widening validator `validateItemHasRequiredData` that checks a cached value has all fields the current query needs. +- The **`FetchCacheConfiguration`** struct on every fetch, which is the additive carrier for any new per-fetch caching annotation. +- The **main-thread-only resolution discipline**: all cache logic runs on the resolver's main thread; goroutines do subgraph HTTP only. + +The detailed contract (data shapes, the symmetric semantics, the widening check, the copy rules, alias handling) lives in the spec: [../directives/request-scoped.md](../directives/request-scoped.md). +This ADR records only the integration decision. + +## Decision + +Implement `@requestScoped` as a **separate per-request coordinate L1** that plugs into the existing foundation seam **without modifying the loader/resolvable hot path**. +It adds new plan-time metadata, one fetch annotation, and two resolver hooks — all additive, all gated on the foundation's existing `EnableL1Cache` flag. +The work ships as its own stacked PR on top of the foundation: **gqtools PR 17-20 / PR-REQUEST-SCOPED** (see [0001-foundation.md](0001-foundation.md) for why directives stack on the frozen seam). + +### 1. Plan-time metadata (composition + planner) + +- **Composition** introduces the directive grammar and validation. + `key: String!` is mandatory. + Composition **warns** when a `key` appears on only one field in a subgraph, because a lone reader can never coordinate with a second field and the annotation is meaningless. +- The planner carries the directive on `FederationMetaData.RequestScopedFields` as a flat list of `RequestScopedField{FieldName, TypeName, L1Key}`, where `L1Key` is `{subgraphName}.{key}` — alias-independent by construction. + Lookups are symmetric: `RequestScopedFieldsForType(typeName)` and `RequestScopedExportsForField(typeName, fieldName)` both return the field's own L1 key, with no separate "resolve-from" notion. +- The planner's caching pass (`configureFetchCaching`) populates a resolver-side `RequestScopedField` per annotated field selected at a fetch location, and `populateRequestScopedFieldsProvidesData` attaches the alias-aware `ProvidesData *Object` for that field by locating the matching sub-`Object` in the planner's per-fetch objects, rewriting `FieldName`/`FieldPath` to the outer query's alias where needed. +- For interface objects, the planner resolves concrete entity types to their interface types via `InterfaceObjects` config so it can find `@requestScoped` fields declared on the interface. + +### 2. Fetch annotation (additive, no new fetch type) + +The directive rides on the existing `FetchCacheConfiguration` already present on every fetch — no new fetch type, no signature change: + +```go +RequestScopedFields []RequestScopedField // on resolve.FetchCacheConfiguration +``` + +The resolver-side `RequestScopedField` is the tiny on-the-wire surface that carries the directive from planner to resolver: + +- `FieldName` — the response key at the fetch location (alias if present, else schema name), used when writing an injected value onto entity items. +- `FieldPath` — the path in response data, using response keys (aliases), used to read the value out for export. +- `L1Key` — the coordinate key `{subgraphName}.{key}`. +- `ProvidesData *Object` — the alias-aware value shape at this fetch location, used for the widening check on inject and the normalize/denormalize transforms on both sides. + +The datasource's `ConfigureFetch` emits exactly **one** `RequestScopedField` per annotated field — symmetric, no reader/writer split. +When `RequestScopedFields` is empty (the common case), every hook below is a guard-clause early-return, so non-`@requestScoped` fetches pay nothing. + +### 3. Resolver hooks (new state, no hot-path rewrite) + +The run-time half is a **separate coordinate L1 map** on the Loader, distinct from the entity `l1Cache`: + +```go +requestScopedL1 map[string]*astjson.Value // main-thread only +``` + +It is allocated per request, read and written **only on the main thread**, and gated on `EnableL1Cache`. +Two new hook methods carry all the logic; they slot into existing phase boundaries and the loader's resolution shape is unchanged: + +- **`tryRequestScopedInjection(res, cfg, items)`** — the reader. + Collect-then-inject: it first verifies that **all** hints are satisfiable (each L1 entry exists, has `ProvidesData`, and passes `validateItemHasRequiredData`), materialising each value via a denormalizing StructuralCopy onto the arena. + Only if **every** hint succeeds does it mutate items (one independent copy per item when there are several), set `res.fetchSkipped = true`, and return `true` to skip the fetch. + On any failure it leaves items untouched (no partial injection). +- **`exportRequestScopedFields(res, cfg, items)`** — the writer. + After merge, it samples the value from the first entity (or the root data for root-field fetches), normalizes it via `structuralCopyNormalized` (alias → schema name, arg → arg-hash), and stores it under `L1Key`. + A merge into an existing entry uses the foundation's **working-copy-and-swap**: StructuralCopy the live entry, `MergeValues` into the copy, store the copy on success, keep the live entry intact on failure. + +These hooks are invoked at the existing seams, all on the main thread: + +- **Parallel Phase 1.5** — injection before launching HTTP goroutines. +- **Parallel Phase 3.5** — retry injection for hints that became satisfiable after sibling fetches produced the hinted data. +- **Parallel Phase 4** — export after merge. +- **`resolveSingle`** — the same inject/export bracket on the sequential path. + +### 4. Correctness invariants honored + +- **Field-widening (anti-poisoning)**: inject only when the cached value has all fields in the hint's `ProvidesData`; fail closed when `ProvidesData` is nil. + A narrow root query must never poison the L1 for a wider entity fetch. +- **Copy-on-inject / copy-on-export**: cached values are StructuralCopy'd on both read and write to prevent pointer aliasing with the response tree, same-arena/same-request only. +- **L1 gating**: both hooks early-return when `EnableL1Cache` is false; the coordinate L1 is part of the L1 layer. +- **Trace reporting**: on a successful skip, set `LoadSkipped = true` on the fetch trace and `res.cacheTraceRequestScopedHits = res.cacheTraceEntityCount` at all call sites; `buildCacheTrace` folds the dedicated counter into `L1Hit`/`L1Miss` so the UI shows a red L1 hit rather than stale Phase-1 misses — never mutate the L1 hit/miss counters directly at the injection site. + +### Why this does not touch the loader/resolvable hot path + +The loader's `single` / `sequence` / `parallel` dispatch, the four-phase machinery, `mergeResult`, and the two-pass rendering are all unchanged. +The only additions are: one map field and a few state fields on the Loader, two collaborator methods, and call sites at phase boundaries that already exist. +When `@requestScoped` is unused or L1 is disabled, every hook is a no-op, so the disabled path is identical to today. +The resolvable needs no changes at all — injected data is indistinguishable from fetched data once written onto the response items. + +## Consequences + +### Positive + +- A request that touches N entities pays **one** subgraph fetch for a request-constant field instead of N; the first resolver to produce the value short-circuits the rest. +- Purely additive: the diff lands in the planner caching pass, the datasource `ConfigureFetch`, and the cache collaborator — not the loader's resolution logic. +- The symmetric model removes an entire class of "who is the provider" configuration questions; any annotated field can satisfy any other with the same key. +- Reuses the foundation's `ProvidesData` widening check and StructuralCopy helpers, so it inherits the same isolation guarantees and the same copy-budget discipline as entity L1/L2. +- Establishes the pattern for **coordinate caches** keyed by something other than `@key`, which makes future request-constant caching directives cheap to add on the same seam. + +### Negative / costs + +- A second per-request map (`requestScopedL1`) and its main-thread-only constraint: correctness depends on never reading or writing it off the main thread, and would break under naive parallelization. +- Composition can only **warn**, not error, on a lone-key field; a misconfigured schema silently gets no benefit (the lone reader can never coordinate). +- The widening check must be exact and fail-closed; a relaxed check would let a narrow query poison the coordinate L1 for a wider fetch. +- The injection retry in Phase 3.5 adds a second pass over not-yet-satisfied hints; cost is bounded by the number of `@requestScoped` fetches, which is small in practice. + +### Performance implications + +- Net subgraph round-trips drop for request-constant fields; the savings scale with entity count. +- Per-fetch overhead when the feature is unused is zero (empty-slice guard clauses). +- When used, the cost is StructuralCopy on inject/export plus one widening walk per hint — all on the main thread, no goroutine or arena added. + +### What becomes possible for later directives + +- The coordinate-L1 mechanism is a reusable seam: any future "value is constant for the request within a subgraph" concept can reuse `requestScopedL1`, the gate, the copy helpers, and the inject/export hook shape without touching the loader again. + +## Alternatives considered + +### A. Provider/receiver (asymmetric) model + +Designate one field as the provider that writes L1 and others as receivers that only read. +**Rejected.** +It requires composition to pick a provider and validate that exactly one exists, adds a `ResolveFrom`-style indirection to the metadata, and breaks when the "provider" field is not selected by a given query. +The symmetric model needs no provider election: whichever field resolves first writes, and the lookup returns the field's own key on both sides. +This is strictly simpler and more robust to selection-set variation. + +### B. Reuse the entity `l1Cache` keyed on `@key` instead of a separate coordinate map + +Store request-constant values in the existing entity L1 under entity keys. +**Rejected.** +The value is not entity-owned — it is request-constant across all entities — so an entity-keyed store would duplicate it per entity and could not be found by a field on a different entity type with the same `key`. +A separate map keyed `{subgraphName}.{key}` is the natural identity for a coordinate value and keeps the entity L1's `@key`-only invariant intact. + +### C. Compute it as a planner-time constant or a shared variable + +Resolve the request-constant value once up front and thread it through as a variable. +**Rejected.** +The value is produced by a subgraph at resolve time and depends on per-request context; it is not known at plan time. +Threading it as a variable would require the planner to model cross-fetch data dependencies it does not otherwise track, and would not generalise to multiple subgraphs or interface objects. +The run-time coordinate L1, populated by whichever fetch resolves first, captures the dependency naturally without new planner data-flow machinery. + +## References + +- [../directives/request-scoped.md](../directives/request-scoped.md) — the detailed `@requestScoped` contract (data shapes, semantics, widening, alias handling). +- [0001-foundation.md](0001-foundation.md) — the foundation seam, L1 layer, StructuralCopy invariants, and `ProvidesData` that this directive stacks on. +- [../01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) — §5 four-phase touchpoints (Phase 1.5 / 3.5), §9 per-request toggles. +- [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) — directive taxonomy and PR mapping (PR-REQUEST-SCOPED). +- `v2/pkg/engine/resolve/loader.go`, `loader_cache.go`, `fetch.go` — `requestScopedL1`, `tryRequestScopedInjection`, `exportRequestScopedFields`, `validateItemHasRequiredData`, `RequestScopedField`. +- `v2/pkg/engine/plan/federation_metadata.go`, `node_selection_visitor_request_scoped.go`, `visitor.go` — planner metadata and `ProvidesData` population. +- `v2/pkg/engine/datasource/graphql_datasource/graphql_datasource.go` — `ConfigureFetch` emits one `RequestScopedField` per annotated field. diff --git a/docs/entity-caching/adr/0006-entity-cache-config.md b/docs/entity-caching/adr/0006-entity-cache-config.md new file mode 100644 index 0000000000..34a13556ff --- /dev/null +++ b/docs/entity-caching/adr/0006-entity-cache-config.md @@ -0,0 +1,204 @@ +# ADR-0006: Entity caching config caching support + +## Status + +Proposed + +## Context + +The foundation ([0001-foundation.md](0001-foundation.md)) ships the *machinery* for entity caching but deliberately ships **no policy**. +It establishes the integration seam into the loader, +the `LoaderCache` (L2) and `CacheKeyTemplate` (key rendering) interfaces, +the per-fetch `FetchCacheConfiguration` data shape, +the per-request `CachingOptions` toggles, +and the StructuralCopy discipline that keeps cache entries and the live response tree from corrupting each other. +What the foundation does **not** answer is the most basic operational question: *for a given entity type, should it be cached at all, +in which named cache, +and for how long?* + +Without that binding, every entity fetch carries a key template but no L2 destination, +so L2 is effectively off and only the per-request L1 dedup runs. + +Entity caching is **opt-in per entity type per subgraph**. +A subgraph that owns `User` and `Product` may want `Product` cached in a long-lived shared cache, +`User` cached briefly in a tenant-isolated cache, +and a third type not cached at all. +The engine cannot infer this from the schema — +`@key` tells it *how to identify* an entity (see [0002-key.md](0002-key.md)) but says nothing about TTL, +cache backend choice, +or whether a not-found result should be remembered. +That intent has to arrive as configuration. + +This is the problem the **entity caching config** solves. +It is not a wire directive — there is no SDL syntax for it. +It is a Go configuration concept, `EntityCacheConfiguration`, +supplied per subgraph by the router (which typically synthesises it from composition output plus operator config). +It binds one entity type to one named cache, +a TTL, +an optional negative-cache TTL (how long a not-found / null result is remembered), +and the controls that govern serving data that may be stale within bounds (partial cache load and shadow mode). +The full field-by-field contract lives in the directive spec at [../directives/entity-cache-config.md](../directives/entity-cache-config.md); +this ADR records *why and how* it plugs into the foundation seam. + +Per the directive inventory ([02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md)), +this config is consumed by the planner caching state, +the resolver entity L1/L2 read/write paths, +and the cache-key construction. +It depends on `@key` being correct (keys are derived from key fields) and is itself the prerequisite for the root-field, +mutation, +and subscription config concepts that read and invalidate what it populates. + +## Decision + +Re-implement the entity caching config as **gqtools PR 4 / PR-CACHE-CONFIG**, +stacked directly on the foundation PR. +It adds **plan-time policy lookup** and the **per-fetch annotations** that turn the foundation's dormant L2 path on for configured entity types. +It changes the loader/resolvable hot path in **zero** new places — +it only populates the `FetchCacheConfiguration` fields the foundation already defined and the resolver already reads. + +### Where the config lives and how it is supplied + +The router supplies one `EntityCacheConfiguration` per (subgraph, entity type) through the execution-engine factory option `WithSubgraphEntityCachingConfigs`, +which takes `SubgraphCachingConfigs` — +a list of `SubgraphCachingConfig`, each carrying its subgraph name plus an `EntityCaching EntityCacheConfigurations` slice (and the sibling root-field / mutation / subscription configs covered by their own ADRs). +The factory copies the `EntityCaching` slice onto that subgraph's `FederationMetaData.EntityCaching`, +so by plan time each datasource carries its own entity-cache policy. + +The minimal binding contract: + +- One config per (subgraph, type) — keyed by `TypeName`. +- Multiple types may share a backing store by reusing the same `CacheName`. +- A `CacheName` is a logical handle; the actual `LoaderCache` instance is registered separately by the router in `ResolverOptions.Caches map[string]LoaderCache` and resolved by name at resolve time. +- Absence of a config for a type means **L2 disabled for that type** (opt-in model). + L1 is unaffected — it rides on the key template the foundation already preserves. + +Lookup is a single method on the federation metadata, +`FederationMetaData.EntityCacheConfig(typeName) *EntityCacheConfiguration`, +returning `nil` when the type is not configured. + +### How it plugs into the foundation seam (no hot-path changes) + +All new behavior lands in the **planner caching state** (`configureFetchCaching`), +not in the loader. +The planner already builds a `FetchCacheConfiguration` for every fetch and always preserves the `CacheKeyTemplate` (so L1 works regardless of L2 policy). +This PR adds one branch: +for an entity fetch (`RequiresEntityFetch` or `RequiresEntityBatchFetch`), +it reads the entity type name from the fetch's root field, +calls `EntityCacheConfig(entityTypeName)`, +and — when a config exists — fills in the L2-bearing fields of `FetchCacheConfiguration`: + +- `Enabled = true` (this is the single boolean the foundation's loader checks to run the L2 read/write path), +- `CacheName`, `TTL`, `IncludeSubgraphHeaderPrefix`, +- `NegativeCacheTTL` (negative-cache window for null results), +- `EnablePartialCacheLoad` (serve cached entities, fetch only the missing ones — the bounded-staleness serve path), +- `ShadowMode` (read and write L2 but never serve cached data; always fetch fresh and compare — the staleness-detection serve mode), +- `HashAnalyticsKeys`, +- and `KeyFields` (the `@key` fields, extracted from the entity key template for analytics). + +When no config exists, +the planner still attaches `KeyFields` for analytics but leaves `Enabled = false`, +so L1 keeps working and L2 stays off. +`UseL1Cache` is intentionally **not** set here — +it remains the responsibility of the L1-optimizer post-process pass described in the foundation, +keeping the two policies (L2 destination vs. L1 enablement) cleanly separated. + +At resolve time **nothing new is added to the loader**. +The foundation's existing hooks already do the work: +`prepareCacheKeys` renders keys from the preserved template, +`bulkL2Lookup` / `tryL2CacheLoad` consult L2 only when `Enabled` is set and the named cache resolves, +and `updateL2Cache` writes back honoring per-entry `TTL` and the negative-cache TTL via the `CacheEntry.TTL` field (recall `Set` takes per-entry TTL, not a `Set` argument). +The negative-cache behavior — storing a sentinel for a null `_entities` result and serving it on the next request until `NegativeCacheTTL` elapses — is exercised end to end by the resolver in `v2/pkg/engine/resolve/negative_cache_test.go`, +which proves the config-to-behavior path without any loader signature change. + +### How the PR stacks + +PR-CACHE-CONFIG depends on three things already in flight or merged below it: + +1. the **foundation** seam and `FetchCacheConfiguration` shape ([0001-foundation.md](0001-foundation.md)), +2. **`@key`** for entity identity and the entity key template ([0002-key.md](0002-key.md)), +3. **`@provides`** / **`@requires`** for the projected `ProvidesData` shape that the L2 read/write copies project against ([0004-provides.md](0004-provides.md), [0003-requires.md](0003-requires.md)). + +It is the first *config concept* in the stack. +Root-field caching ([0007-root-field-cache-config.md](0007-root-field-cache-config.md)) builds on it (root fields share entity cache entries via key mappings), +and mutation ([0008-mutation-cache-config.md](0008-mutation-cache-config.md)) and subscription ([0009-subscription-cache-config.md](0009-subscription-cache-config.md)) configs read and invalidate what it populates. +Because the diff is confined to the planner caching state plus the config struct and its lookup, +it is small, +additive, +and reviewable in isolation. + +## Consequences + +### Positive + +- The first directive PR that makes L2 actually do something — it activates the dormant foundation path for configured entity types. +- Zero new loader/resolvable touch points; the change is a planner branch plus a config struct and one lookup method. +- Opt-in by type keeps the blast radius small: an unconfigured type behaves exactly as today (L1-only dedup, no external I/O). +- Per-(subgraph, type) granularity with shared `CacheName` lets operators mix policies (long-lived shared cache for one type, short TTL isolated cache for another) without engine changes. +- Negative caching, partial load, and shadow mode are all expressed as plain fields on the same struct, so later behaviors compose without new seams. +- Establishes the config-supply pattern (`SubgraphCachingConfig` → `FederationMetaData`) that the root-field, mutation, and subscription ADRs reuse verbatim. + +### Negative / costs + +- The config is opaque to the schema: a type can be `@key`-correct yet silently uncached because no `EntityCacheConfiguration` was supplied. This is intentional (opt-in) but means "why isn't this cached?" is a config question, not a schema one. +- `CacheName` is a string handle decoupled from the actual backend; a typo or an unregistered name resolves to no cache at resolve time and silently disables L2 for that type. The router must keep `EntityCaching` names and `ResolverOptions.Caches` keys in sync. +- A zero `TTL` means entries never expire — convenient for tests, a footgun in production. The contract documents it; it cannot be enforced at the engine boundary. +- More config surface for operators to get right (TTL, negative TTL, partial load, shadow mode, header-prefix), all per type. + +### Performance implications + +- For configured types: one extra L2 round trip on a miss (already part of the foundation's bulk-Get path), amortised across all keys routed to the same cache instance via `bulkL2Lookup`. +- For unconfigured types: zero added cost — `Enabled = false` short-circuits before any key transform or cache call; the path is identical to the cache-disabled foundation behavior. +- Negative caching trades a small write (a sentinel) for avoiding repeated subgraph lookups of non-existent entities — a net win whenever not-found is hot. +- Partial cache load reduces subgraph fan-out (fetch only missing batch members) at the cost of serving entities that may be stale within their TTL window — a deliberate, configured trade. +- Shadow mode always pays for both the subgraph fetch and the cache read/write/compare; it is a diagnostic mode, not a performance mode, and never serves cached data. + +### What becomes possible for later directives + +- **Root-field caching** can map a root query onto the *same* entity cache entries this config defines, so `Query.user(id:"1")` and an `_entities` fetch for `User{id:"1"}` share L2 storage. +- **Mutation caching** can locate the entity cache config for a mutation's return type to decide what to invalidate or repopulate after a successful mutation. +- **Subscription population** can reuse the same per-type cache binding (cache name, TTL, header prefix) to populate or invalidate L2 on each event. +- All three inherit the `CacheName` → `LoaderCache` resolution and the per-entry-TTL write contract established here. + +## Alternatives considered + +### A. Express caching as a real SDL directive (e.g. `@cache(ttl: ..., name: ...)` on the type) + +Make caching a wire directive so policy travels with the schema and is validated at composition time, +like `@requestScoped`. +**Rejected.** +Caching policy is an *operational* concern (which Redis, what TTL, tenant isolation) that legitimately differs per environment and per subgraph deployment, +not a *semantic* property of the graph. +Baking it into SDL would force schema changes for ops tuning, +couple composition to backend topology, +and lose the ability to vary policy per environment from the same supergraph. +Keeping it as router-supplied Go config (`EntityCacheConfiguration`) keeps the schema clean and the policy where it belongs — +in the router's runtime configuration. +The one genuinely semantic caching concept, `@requestScoped`, *is* a directive, which confirms the split. + +### B. A single global cache policy instead of per-(subgraph, type) config + +Apply one TTL and one cache backend to every entity, configured once on the resolver. +**Rejected.** +Real deployments need different lifetimes and isolation for different entity types (a slowly-changing `Product` vs. a session-sensitive `User`), +and federation means the *same* type can be owned by different subgraphs with different freshness guarantees. +A global knob cannot express "cache `Product` for an hour in the shared store, don't cache `User` at all." +The per-(subgraph, type) model with a shared-by-name backend gives that flexibility while still letting operators collapse to a single cache by reusing one `CacheName`. + +### C. Make caching opt-out (cache every `@key` entity by default) + +Default every entity to cached and let configs disable specific types. +**Rejected.** +Opt-out is unsafe: it would silently cache entities whose freshness or correctness assumptions the operator never reviewed, +and it makes the "is this safe to cache?" decision implicit. +Opt-in (absence of config = L2 off) makes caching a deliberate, auditable choice per type, +keeps the unconfigured path identical to today's behavior, +and means a mistake fails *closed* (no caching) rather than *open* (stale data served). + +## References + +- Directive contract: [../directives/entity-cache-config.md](../directives/entity-cache-config.md) +- Foundation: [0001-foundation.md](0001-foundation.md) +- Directive inventory: [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) +- Architecture spec (seam, L1/L2 model, cache-key model): [../01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) +- Related config ADRs: [0007-root-field-cache-config.md](0007-root-field-cache-config.md), [0008-mutation-cache-config.md](0008-mutation-cache-config.md), [0009-subscription-cache-config.md](0009-subscription-cache-config.md) +- Prerequisite directive ADRs: [0002-key.md](0002-key.md), [0003-requires.md](0003-requires.md), [0004-provides.md](0004-provides.md) diff --git a/docs/entity-caching/adr/0007-root-field-cache-config.md b/docs/entity-caching/adr/0007-root-field-cache-config.md new file mode 100644 index 0000000000..68a1d9a2a7 --- /dev/null +++ b/docs/entity-caching/adr/0007-root-field-cache-config.md @@ -0,0 +1,227 @@ +# ADR-0007: Root-field caching config caching support + +## Status + +Proposed + +## Context + +The foundation ([0001-foundation.md](0001-foundation.md)) ships the caching *machinery* but no policy: +the loader seam (the five hook points plus the `cacheSkipFetch` / `cacheMustBeUpdated` booleans), +the `LoaderCache` (L2) and `CacheKeyTemplate` (key rendering) interfaces, +the per-fetch `FetchCacheConfiguration` data shape, +the per-request `CachingOptions` toggles, +and the StructuralCopy discipline that keeps cache entries and the live response tree from corrupting each other. + +The entity caching config ([0006-entity-cache-config.md](0006-entity-cache-config.md)) added the first slice of policy: +it binds an *entity type* (resolved through an `_entities` fetch) to a named cache and a TTL, +turning on the foundation's dormant L2 path for that type. + +What neither answers is the other half of the cacheable surface: the **root field**. +A federated request does not only resolve entities by `@key` through `_entities`. +It begins with a root-field fetch — `Query.user(id: "1")`, `Query.topProducts(first: 10)`, `Query.products(ids: ["1","2"])` — whose response a subgraph computes from the field name plus its arguments. +Root fields differ from entity fetches in two ways that matter for caching: + +- A root field has **no prior entity data to key on**, + so its cache identity must come from the field name and the per-request argument values, not from a `@key` already present in the data tree. + This is why L1 (which deduplicates by entity identity within a request) does not apply to root fields, and L2 does ([01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §2). +- A root field's arguments very often *are* entity keys in disguise. + `Query.user(id: "1")` returns exactly the `User` whose `@key` is `{id:"1"}`. + If the root-field response and the later `_entities` fetch for that same `User` are cached under unrelated keys, + the same data is stored twice and a root query never warms the entity cache (and vice versa). + +This is the problem the **root-field caching config** solves. +Like entity caching config, it is **not a wire directive** — there is no SDL syntax. +It is a Go configuration concept, `RootFieldCacheConfiguration`, +supplied per subgraph by the router (typically synthesised from composition output plus operator config). +It does two distinct jobs: + +1. Cache a **whole root-field response** under a root-field-shaped key + (`{"__typename":"Query","field":"topProducts","args":{...}}`, with args sorted alphabetically for byte-identity). +2. Optionally, via `EntityKeyMapping`, + rewrite that root-field key into **entity key shape** + (`{"__typename":"User","key":{"id":"1"}}`) + so a root query and an `_entities` fetch for the same entity share one L2 entry. + +The `EntityKeyMapping` binding is expressed as a list of `FieldMapping{EntityKeyField, ArgumentPath, ArgumentIsEntityKey}`: +`EntityKeyField` names the `@key` field (dot-notation for nested keys), +`ArgumentPath` names the root-field argument that supplies it (multi-element for structured argument navigation), +and `ArgumentIsEntityKey` marks an argument whose **list elements** map 1:1 and positionally to the response entities. +When `ArgumentIsEntityKey` is set, the engine can build one entity key per list element, +short-circuit an empty (`[]` or `null`) list before touching the resolver or cache, +and — in partial-fetch mode — send only the cache-missed elements to the subgraph while serving the hits directly. + +Per the directive inventory ([02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md)), +this config is consumed by the planner caching state, +the resolver root-field cache path, +and the batch / partial-fetch optimisation. +It depends on `@key` (entity-mapped keys must match the `@key` field set byte-for-byte) and on entity caching config (so the two share the same L2 entries), +which is why it sits directly above [0006-entity-cache-config.md](0006-entity-cache-config.md) in the dependency chain. +The full field-by-field contract lives in the directive spec at [../directives/root-field-cache-config.md](../directives/root-field-cache-config.md); +this ADR records *why and how* it plugs into the foundation seam. + +## Decision + +Re-implement the root-field caching config as **gqtools PR 4 + PR 8 / PR-CACHE-CONFIG**, +stacked on the foundation PR and on entity caching config. +It adds **plan-time policy and a second key-template implementation**, +plus a small **batch / partial-fetch optimisation in the resolver's existing root-field cache path** — +and it changes the loader/resolvable *hot* path in **zero** new places. +Like entity caching config, it only populates `FetchCacheConfiguration` fields the foundation already defined and rides the hooks the foundation already invokes. + +### Where the config lives and how it is supplied + +The router supplies `RootFieldCacheConfiguration` per (subgraph, root field) through the same factory option entity config uses, +`WithSubgraphEntityCachingConfigs`, +which takes `SubgraphCachingConfigs` — a list of `SubgraphCachingConfig`, each carrying its subgraph name plus a `RootFieldCaching RootFieldCacheConfigurations` slice (alongside the entity / mutation / subscription siblings covered by their own ADRs). +The factory copies the `RootFieldCaching` slice onto that subgraph's federation metadata, +so by plan time each datasource carries its own root-field policy. +This reuses the config-supply pattern (`SubgraphCachingConfig` → `FederationMetaData`) established verbatim by entity caching config. + +The minimal binding contract: + +- One config per (subgraph, `TypeName` + `FieldName`) — e.g. `Query` + `topProducts`. +- `CacheName` is a logical handle resolved to a `LoaderCache` instance by the router via `ResolverOptions.Caches map[string]LoaderCache`; a root field and an entity type may share a backing store by reusing the same name (and *must*, for entity-key sharing to land in the same store). +- Absence of a config for a root field means **L2 disabled for that root field** — the opt-in model, fail-closed. + +### The second key template: `RootQueryCacheKeyTemplate` + +The foundation's `CacheKeyTemplate` interface has two implementations. +Entity caching config uses the entity template; root-field caching config uses **`RootQueryCacheKeyTemplate`**, +constructed from the root field set plus the `EntityKeyMappings`. +It produces one of two key shapes depending on configuration: + +- **No mapping** — root-field shape `{"__typename":"Query","field":"topProducts","args":{...}}`, args sorted alphabetically. + `EntityMergePath()` returns nil, signalling the template stores the **complete** root-field response payload. +- **With `EntityKeyMapping`** — entity shape `{"__typename":"User","key":{"id":"1"}}`, + byte-identical to what the entity template produces for the same `User`, + so the two cache entries coincide. + `EntityMergePath()` returns the path at which entities live inside the root-field response, so cached entity values can be spliced back into the right place. + +The template stays **alias-independent** by construction, exactly like the entity template: +keys are computed from field name + argument values (or the mapped `@key` fields), never from response aliases. +Alias awareness, where needed, lives in the separate `ProvidesData` field tree the foundation already carries. + +A few rendering rules the re-implementation must honour, all confined to the template (not the loader): + +- **Per-mapping independence**: an entity with multiple `@key` directives can carry multiple `EntityKeyMapping` entries. + Each renders a key only when all its argument paths are present in the request variables; a mapping with missing arguments is skipped, the others still produce keys. +- **One key per list element** when `ArgumentIsEntityKey` is set on a list argument, with `BatchIndex` recording each key's position for positional response reassembly. +- **TypeName fallback**: when `__typename` is absent from the response, the plan-time `TypeName` on the template is the fallback for the entity type, never a hardcoded default. +- **Argument hash suffix** for arguments that are *not* entity keys, consistent with the foundation's argument-aware key rule (computed at resolve time from per-request variables, captured at plan time). + +### How it plugs into the foundation seam (no hot-path changes) + +All plan-time behavior lands in the **planner caching state** (`configureFetchCaching`), +mirroring entity caching config. +For a root-field fetch, the planner reads the root field's `TypeName` + `FieldName`, +looks up the `RootFieldCacheConfiguration`, +and — when one exists — attaches a `RootQueryCacheKeyTemplate` (built with the `EntityKeyMappings`) and fills the L2-bearing `FetchCacheConfiguration` fields: +`Enabled = true` (the one boolean the foundation's loader checks to run L2), +`CacheName`, `TTL`, `IncludeSubgraphHeaderPrefix`, +`ShadowMode`, +and the batch flag (`PartialBatchLoad`) plus the precomputed batch-argument metadata. +When no config exists, nothing is attached and the root field behaves exactly as today (no L2, and L1 does not apply to root fields anyway). + +At resolve time the foundation's existing hooks do the read/write: +`prepareCacheKeys` renders keys from the template (root-field shape or, with mapping, entity shape); +`bulkL2Lookup` / `tryL2CacheLoad` consult L2 only when `Enabled`; +`mergeResult` honors `cacheSkipFetch` to splice cached values in (at `EntityMergePath` for entity-mapped templates, or the whole payload otherwise); +`updateL2Cache` writes back honoring per-entry `TTL`. + +The **one genuinely new piece of resolver behavior** — and the reason this stacks as a small *additional* PR (PR 8) on top of the config PR (PR 4) — is the **batch / partial-fetch optimisation** for `ArgumentIsEntityKey` list arguments. +It is deliberately *not* a change to the four-phase machinery or to `mergeResult`'s shape. +It is logic inside the foundation's existing root-field cache path: + +- **Empty-list short-circuit**: when the mapped list argument is `[]` or `null`, return an empty response immediately, before the resolver or any cache call. +- **Full-fetch mode** (`PartialBatchLoad = false`, default): any miss in the batch sends the full list to the subgraph; all returned entities are written back, all-or-nothing on the read side. +- **Partial-fetch mode** (`PartialBatchLoad = true`): filter the input list variable to only the cache-missed elements, send that reduced list to the subgraph, and merge the fresh entities with the cache hits in correct positional order using each key's `BatchIndex`. +- **Smart write-back**: on write, existing keys that hit on read are refreshed only when the data changed or a subgraph returned fresh data; *requested-but-missing* keys are backfilled only when the final entity value proves them (the mapped key field is present and renders to the exact same key string — request arguments alone never prove a write association); and *derived* keys for other `EntityKeyMapping` entries are written when the final entity contains those mapped fields (so a query by `id` can warm the `username` key for later cross-lookup). + These distinctions are surfaced through the foundation's `CacheEntry.WriteReason` (refresh / backfill / derived). + +This optimisation touches only the cache collaborator and the template, never the loader's dispatch or rendering, which is what keeps the seam intact. + +### How the PR stacks + +PR-CACHE-CONFIG (root-field portion) depends on, in order below it: + +1. the **foundation** seam, `FetchCacheConfiguration`, and the `CacheKeyTemplate` interface ([0001-foundation.md](0001-foundation.md)), +2. **`@key`** for the entity identity that entity-mapped keys must reproduce byte-for-byte ([0002-key.md](0002-key.md)), +3. **`@provides`** / **`@requires`** for the projected `ProvidesData` shape used by the L2 read/write copies ([0004-provides.md](0004-provides.md), [0003-requires.md](0003-requires.md)), +4. **entity caching config** ([0006-entity-cache-config.md](0006-entity-cache-config.md)), because entity-key sharing only pays off when the same entity type is *also* cached under the same `CacheName`. + +The work splits cleanly into two stacked diffs: +**PR 4** lands the plan-time policy plus the `RootQueryCacheKeyTemplate` (whole-response and entity-mapped scalar keys), +and **PR 8** layers on the batch / partial-fetch optimisation for `ArgumentIsEntityKey` lists. +Each is confined to the planner caching state, the template, and the cache collaborator — small, additive, and reviewable in isolation. +Behavioral coverage already exists for the target shape: +`v2/pkg/engine/resolve/aliased_root_field_caching_test.go` proves alias-independent root-field keys, +`v2/pkg/engine/resolve/batch_entity_cache_test.go` proves per-element batch keys and partial fetch, +and `execution/engine/federation_caching_root_*.go` exercise the config end to end (whole-response, entity-mapped, and split-batch behavior). + +## Consequences + +### Positive + +- Caches the *entry point* of a federated query — the root field — which is otherwise un-cacheable by the entity-only path, because L1 cannot key a root field and only L2 applies. +- **Entity-key sharing** is the headline win: `Query.user(id:"1")` and an `_entities` fetch for `User{id:"1"}` collide on one L2 entry, so a root query warms the entity cache and an entity fetch warms the root query — no double storage, no cold-start for the other access path. +- Zero new loader/resolvable *hot-path* touch points; the change is a planner branch, a second key template, and optimisation logic in the existing root-field cache path. +- Opt-in per root field keeps the blast radius small: an unconfigured root field behaves exactly as today. +- Reuses the entire config-supply and `CacheName` → `LoaderCache` resolution pattern from entity caching config, so operators learn one model. + +### Negative / costs + +- `EntityKeyMapping` is the most error-prone config surface in the feature: the `EntityKeyField` / `ArgumentPath` bindings must match the `@key` field set *and* the actual argument names, and a wrong binding silently produces keys that never coincide with the entity cache (cache sharing quietly fails open to double storage rather than erroring). +- `ArgumentIsEntityKey` carries a hard, unchecked assumption: the response array is the **same length and order** as the input list argument (`ids[i]` ↔ `data.products[i]`). + A subgraph that reorders, dedups, or drops elements breaks positional reassembly; the engine cannot detect this, so it is a documented contract the subgraph must satisfy. +- Two key shapes from one config (root-field vs entity-mapped) means "which key did this write?" is answered by configuration, not by the schema — harder to reason about than a single shape. +- A typo in `CacheName`, or a name not registered in `ResolverOptions.Caches`, silently disables L2 for the root field, and — worse — a *mismatched* name between the root field and the entity type silently defeats entity-key sharing even though both are "cached." + +### Performance implications + +- For configured root fields: one extra L2 round trip on a miss, amortised through the foundation's `bulkL2Lookup` (root-field and entity keys batch together when they share a cache instance). +- **Empty-list short-circuit** is a pure win — zero resolver and zero cache work for a trivially empty query. +- **Partial-fetch mode** cuts subgraph fan-out to only the missing batch members at the cost of serving cache hits that may be stale within their TTL window — a deliberate, configured trade, identical in spirit to entity partial cache load. +- Entity-key sharing roughly *halves* effective cache footprint for entities reachable both by root query and by `_entities`, and lifts hit rate because either access path warms the shared entry. +- For unconfigured root fields: zero added cost — `Enabled = false` short-circuits before any key transform or cache call; the path is identical to the cache-disabled foundation behavior. + +### What becomes possible for later directives + +- **Mutation caching** ([0008-mutation-cache-config.md](0008-mutation-cache-config.md)) can invalidate root-field cache entries (whole-response and entity-mapped) after a successful mutation, reusing the same template-driven key shapes and the `WriteReason` write semantics established here. +- **Subscription population** ([0009-subscription-cache-config.md](0009-subscription-cache-config.md)) inherits the entity-key sharing model: an event that carries entity fields can populate the *same* L2 entry a root query reads, keeping all access paths coherent. +- The batch / partial-fetch path and `BatchIndex` reassembly become a reusable mechanism for any future list-of-entities caching beyond root fields. + +## Alternatives considered + +### A. Cache root fields only as whole responses; never share with entity cache + +Drop `EntityKeyMapping` entirely and store every root-field response under its own root-field key. +**Rejected.** +It is simpler, but it stores entity data twice (once under the root key, once under the entity key) and means a root query never warms the entity cache and an entity fetch never warms the root query — exactly the cross-path cold-start problem this config exists to solve. +The whole-response mode is still supported (it is what an unmapped config does), but making it the *only* mode would forgo the headline win. + +### B. Auto-derive `EntityKeyMapping` from the schema instead of explicit config + +Infer the argument-to-`@key` binding by matching argument names against `@key` field names at composition time, so operators never write `FieldMapping`. +**Rejected.** +The binding is genuinely ambiguous from the schema: an argument named `id` need not be the entity `@key` `id` (it could be an unrelated filter), list arguments may or may not be positional entity keys, and nested keys need explicit dot-notation / multi-element path navigation that the schema does not encode. +Guessing would produce keys that *look* shared but silently diverge, the worst failure mode for a cache. +Explicit `FieldMapping` makes the operator's intent auditable and keeps a wrong binding a config review item rather than a silent inference bug. + +### C. Treat a list argument as one opaque cache key (no batch / per-element keys) + +Cache `products(ids: ["1","2","3"])` under a single key derived from the whole list, rather than three per-element entity keys. +**Rejected.** +A single opaque list key cannot share entries with `_entities` fetches or with scalar root fields for the same entity, never benefits from partial reuse (`["1","2"]` and `["1","2","3"]` would be unrelated entries), and forfeits empty-list short-circuit and partial-fetch. +Per-element entity keys (`ArgumentIsEntityKey`) are strictly more capable: they share with entity caching, reuse across overlapping lists, and enable fetching only the missing members — at the modest cost of the positional-correspondence contract. + +## References + +- Directive contract: [../directives/root-field-cache-config.md](../directives/root-field-cache-config.md) +- Foundation: [0001-foundation.md](0001-foundation.md) +- Directive inventory: [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) +- Architecture spec (seam, L1/L2 model, cache-key model): [../01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) +- Prerequisite config ADR: [0006-entity-cache-config.md](0006-entity-cache-config.md) +- Prerequisite directive ADRs: [0002-key.md](0002-key.md), [0003-requires.md](0003-requires.md), [0004-provides.md](0004-provides.md) +- Downstream config ADRs: [0008-mutation-cache-config.md](0008-mutation-cache-config.md), [0009-subscription-cache-config.md](0009-subscription-cache-config.md) +- Behavioral coverage: `v2/pkg/engine/resolve/aliased_root_field_caching_test.go`, `v2/pkg/engine/resolve/batch_entity_cache_test.go`, `execution/engine/federation_caching_root_*.go` diff --git a/docs/entity-caching/adr/0008-mutation-cache-config.md b/docs/entity-caching/adr/0008-mutation-cache-config.md new file mode 100644 index 0000000000..a882427fe4 --- /dev/null +++ b/docs/entity-caching/adr/0008-mutation-cache-config.md @@ -0,0 +1,164 @@ +# ADR-0008: Mutation cache config caching support + +## Status + +Proposed + +## Context + +The foundation ([ADR-0001](0001-foundation.md)) establishes the entity-caching seam: a thin `entityCache` collaborator on the loader, +the `LoaderCache` (L2) interface, the `CacheKeyTemplate` key seam, the `ProvidesData *Object` alias-aware field shape, +the per-fetch `cacheSkipFetch` / `cacheMustBeUpdated` booleans, and the StructuralCopy isolation discipline. +Entity and root-field config (ADR-0006, ADR-0007) sit on top: they bind types and root fields to a named cache, a TTL, and a key template, +and drive the query-path read/write of L1 and L2. + +Mutations need different cache behavior than queries, and the existing seam does not express it. +The problems this configuration concept solves: + +1. **Mutations must never serve stale reads.** +A mutation observes or changes state, so reading the answer from L2 would be a correctness bug. +The rule is unconditional: mutations always skip L2 *reads* and always fetch fresh from the subgraph. + +2. **Mutation-triggered entity fetches must not silently pollute the cache.** +A mutation like `updateUser` typically resolves the root mutation field, then issues a follow-up entity fetch (`_entities`) to fill the rest of the selection. +That entity fetch is an ordinary cacheable entity fetch, but writing its result to L2 by default is dangerous, +because the mutation may have left the entity in a transient or partially-committed shape. +So mutation-triggered L2 *writes* must be **off by default** and **opt-in per mutation field**, +with a TTL that can differ from the entity's default (the `@cachePopulate(maxAge:)` use case). + +3. **A successful mutation must be able to evict what it changed.** +After `updateUser` succeeds, the L2 entry for that `User` is now stale and must be deleted so the next query re-fetches. +The engine must detect which entity (or list of entities) the mutation returned, build the exact same cache key the read path would build, and delete it. + +None of these are wire directives. +They arrive as Go configuration, supplied per subgraph through `SubgraphCachingConfig`: +`MutationFieldCaching{FieldName, EnableEntityL2CachePopulation, TTL}` controls opt-in population, +and `MutationCacheInvalidationConfiguration` controls post-success eviction. +The full contract — config-struct fields, the populate gate, the invalidation matching rules, list handling, and analytics — +lives in [../directives/mutation-cache-config.md](../directives/mutation-cache-config.md). + +## Decision + +Mutation cache config is implemented as **plan-time fetch annotations plus existing resolver hooks**, with **no new loader hot-path code**. +It reuses the five seams of ADR-0001 rather than adding a sixth. + +### How it plugs into the foundation seam + +**Plan-time metadata (new, additive).** +The planner attaches two pieces of configuration to the relevant fetches inside the existing `FetchCacheConfiguration`: + +- On the **root mutation fetch**, two fields gate population: + `EnableMutationL2CachePopulation bool` and `MutationCacheTTLOverride time.Duration`. + These come from `MutationFieldCaching{EnableEntityL2CachePopulation, TTL}` for the matching `FieldName`. +- On the **mutation fetch that returns an entity** (or on the root fetch for single-subgraph mutations), + a `*MutationEntityImpactConfig` describes what to evict or populate after success. + Its shape: `{EntityTypeName, KeyFields []KeyField, CacheName, InvalidateCache bool, PopulateCache bool, PopulateTTL, IncludeSubgraphHeaderPrefix}`. + +`MutationEntityImpactConfig` is a plain data struct, not a `plan` type, so the resolver does not pull a `plan` dependency in — consistent with the `EntityCacheInvalidationConfig` split in ADR-0001 §7.4. + +**Resolver hooks (existing, not new).** +Mutation behavior is expressed entirely through the foundation's existing seams: + +- **Read-skip** rides the *operation type*, not a cache hook. + When `FetchInfo.OperationType == ast.OperationTypeMutation`, the L2 read path is simply not entered, so no `bulkL2Lookup` / `tryL2CacheLoad` Get is issued. + This is the unconditional "mutations skip L2 reads" rule, and it costs nothing because it is a guard, not a new branch in the merge funnel. +- **Write-gate** rides the existing post-fetch write seam (`updateL2Cache`). + The loader carries a per-mutation flag `enableMutationL2CachePopulation`, set when the root mutation fetch's `EnableMutationL2CachePopulation` is true, + and **inherited by the follow-up entity fetch** (the entity fetch propagates the flag from the root mutation fetch during sequential resolution). + `updateL2Cache` only writes when this flag is set; when it writes, it uses `MutationCacheTTLOverride` if non-zero, otherwise the entity's default TTL. + When the flag is false, mutations produce **zero L2 operations** — no get, no set. +- **Invalidation / populate** rides the existing invalidation seam (the fifth seam of ADR-0001), at the same point as extension-based invalidation. + After `mergeResult` folds the mutation response into the response tree, `detectMutationEntityImpact(result, fetchInfo, responseData)` runs: + 1. It returns `nil` for non-mutation operations, nil `FetchInfo`, missing `MutationEntityImpactConfig`, nil `ProvidesData`, missing `caches`, or a non-object/non-array entity payload — pure guard clauses that keep the disabled path free. + 2. It navigates `ProvidesData` to the entity object under the mutation root field (`navigateProvidesDataToField`), then builds the entity cache key from the response data and `KeyFields` (`buildEntityKeyValue` → `buildMutationEntityCacheKey`). + 3. The key passes through the **same transform pipeline** as every other key — `IncludeSubgraphHeaderPrefix` header-hash prefix, then `GlobalCacheKeyPrefix`, then `L2CacheKeyInterceptor` — so the deleted/written key is byte-identical to the read-path key. This is the load-bearing footgun ADR-0001 calls out; mutation invalidation must reproduce it exactly or it targets the wrong entry. + 4. If `InvalidateCache` is set, it deletes the key(s) and returns the deleted-key set. + 5. If `PopulateCache` is set (the single-subgraph case with no follow-up entity fetch to inherit the population flag), it projects the entity through `ProvidesData` (`structuralCopyNormalized`) and writes it to L2 — but only when `EnableL2Cache` is on. + 6. **Lists** are handled by iterating array items, building one key per object item and skipping non-object items, so a `deleteUsers: [User]` mutation evicts every returned entity. + +**No loader hot-path changes.** +The walker dispatch, the four-phase parallel machinery, and `mergeResult`'s signature are untouched. +Mutation read-skip is a guard on operation type, the write-gate is one boolean checked inside the already-existing `updateL2Cache`, +and impact detection hangs off the already-existing post-merge invalidation seam. +All mutation-specific logic lives in the `entityCache` collaborator files, not inline in the resolution loop. + +### Analytics + +When `EnableCacheAnalytics` is on, `detectMutationEntityImpact` records one `MutationEvent` per impacted entity +(`MutationRootField`, `EntityType`, `EntityCacheKey` as the display key without the global prefix, `HadCachedValue`, `IsStale`, hashes, byte sizes). +Critically, analytics **does not issue a mutation-time cache read** to compute staleness — the read path is avoided even with analytics on, +so `HadCachedValue` is reported as false and `CachedHash`/`CachedBytes` are zero. +Records are emitted whether or not `InvalidateCache` is set (recording-without-deleting is a valid analytics-only mode). + +### How the PR stacks + +This work ships as **gqtools PR 13 / PR-CACHE-INVALIDATION**, stacked on the foundation PR and on the entity + root-field config PRs (ADR-0006, ADR-0007). +It depends on them because mutation invalidation builds entity-shaped keys (needs `@key` and the entity key template) +and mutation population writes through the same L2 projection (needs `ProvidesData` and the entity cache config). +Because it adds only plan-time annotations and reuses existing hooks, the loader diff is mechanical and the PR is independently reviewable against the now-frozen seam. + +## Consequences + +### Positive + +- **Correctness by default.** Mutations never serve stale reads and never write to L2 unless explicitly opted in, so adding the feature cannot silently corrupt a query-side cache. +- **Self-cleaning cache.** A mutation can evict exactly the entities it touched, including whole lists, keeping downstream queries fresh without a manual invalidation API. +- **Zero new hot-path surface.** Read-skip is a guard on operation type, the write-gate is one boolean inside `updateL2Cache`, and impact detection reuses the post-merge invalidation seam — the loader stays as in ADR-0001. +- **TTL flexibility.** `MutationCacheTTLOverride` lets mutation-triggered writes use a shorter lifetime than the entity default, supporting the `@cachePopulate(maxAge:)` pattern. + +### Negative / costs + +- **Key-pipeline duplication risk.** Mutation invalidation must reproduce the full key-transform pipeline (header prefix → global prefix → interceptor); any drift between the read path and the invalidation path leaks stale entries. This is mitigated by routing both through the same key-building helpers, but it remains a place where the two must stay in lockstep. +- **Population is post-success only.** L2 population for mutations happens after the mutation returns; a mutation that errors writes nothing, which is correct but means there is no speculative caching. +- **Flag inheritance coupling.** The follow-up entity fetch inherits `enableMutationL2CachePopulation` from the root mutation fetch via sequential propagation; this implicit inheritance must be preserved or mutation-triggered entity writes silently stop. + +### Performance implications + +- A mutation with population disabled (the default) issues **zero** L2 operations, so the cache layer is effectively free on the mutation path. +- Invalidation is at most one `Delete` batch per mutation (one key, or one per list element); population is at most one `Set`. There is never a mutation-time `Get`. +- Analytics deliberately skips the staleness read, so enabling analytics on the mutation path does not add a cache round-trip. + +### What becomes possible for later directives + +- The post-merge impact-detection seam this directive uses is the same one **subscription population** (ADR-0009) hangs its per-event populate/invalidate logic off, so subscriptions reuse this pattern rather than inventing a new one. +- Extension-based invalidation (subgraph-supplied `cacheInvalidation` keys) and mutation invalidation share the delete path and the de-duplication optimization (skip a delete when the same key is being written), so both can coexist on one fetch. + +## Alternatives considered + +### A. Make mutation L2 population opt-out (write by default, suppress per field) + +Treat mutation-triggered entity fetches like any query fetch and write to L2 unless a field opts out. +**Rejected.** +A mutation can leave an entity in a transient state, and a wrong default that writes that state to a cross-request cache is a silent correctness bug that is hard to detect. +Opt-in is the safe default; the cost is one explicit config field per mutation that wants population. + +### B. Add a dedicated mutation hook (a sixth loader seam) + +Introduce a `tryMutationCacheEffects` hook in the loader's hot path, separate from the existing invalidation seam. +**Rejected.** +It violates the ADR-0001 constraint of keeping the loader at five seams. +Mutation effects are post-merge cache writes/deletes, which is exactly what the existing invalidation seam already does for extension-based invalidation; reusing it keeps the loader diff minimal and the disabled path unchanged. + +### C. Let the router invalidate manually after a mutation + +Expose mutation results to the router and have the router call `Delete` itself. +**Rejected.** +The router cannot reliably reconstruct the entity cache key — it would have to reproduce the entity key template, the header-hash prefix, the global prefix, and the interceptor in exactly the engine's byte order. +That is the documented manual-invalidation footgun; pushing it onto the router for the common mutation case multiplies the chance of targeting the wrong key and leaving stale entries. +The engine owns key construction, so the engine owns mutation invalidation. + +### D. Always skip both L2 reads and writes for mutations (no population at all) + +Make mutations purely cache-invalidating with no ability to populate. +**Rejected.** +Some mutations (single-subgraph `@cachePopulate`) usefully prime the cache with their just-written entity so the immediate follow-up read hits. +A blanket no-write rule would forbid that, and the opt-in `EnableEntityL2CachePopulation` plus `PopulateCache` path supports it without compromising the safe default. + +## References + +- [ADR-0001: Foundation](0001-foundation.md) — the integration seam, L1/L2 model, key-transform pipeline, StructuralCopy invariants. +- [../directives/mutation-cache-config.md](../directives/mutation-cache-config.md) — the detailed mutation cache config contract. +- [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) — directive taxonomy and PR mapping. +- `v2/pkg/engine/resolve/mutation_cache_test.go` — `detectMutationEntityImpact`, `buildMutationEntityCacheKey`, `MutationCacheTTLOverride`, list-invalidation, and population behavior. +- `v2/pkg/engine/resolve/extensions_cache_invalidation_test.go` — shared delete path and mutation-event recording for extension-driven invalidation. +- `docs/entity-caching/ENTITY_CACHING_INTEGRATION.md` — router-facing config (`MutationFieldCacheConfiguration`, `MutationCacheInvalidationConfiguration`). diff --git a/docs/entity-caching/adr/0009-subscription-cache-config.md b/docs/entity-caching/adr/0009-subscription-cache-config.md new file mode 100644 index 0000000000..da6473f42d --- /dev/null +++ b/docs/entity-caching/adr/0009-subscription-cache-config.md @@ -0,0 +1,191 @@ +# ADR-0009: Subscription population config caching support + +## Status + +Proposed + +## Context + +The foundation ([ADR-0001](0001-foundation.md)) establishes the entity-caching seam: a thin `entityCache` collaborator on the loader, +the `LoaderCache` (L2) interface, the `CacheKeyTemplate` key seam, the `ProvidesData *Object` alias-aware field shape, +the per-fetch `cacheSkipFetch` / `cacheMustBeUpdated` booleans, the StructuralCopy isolation discipline, +and the load-bearing key-transform pipeline (`GlobalCacheKeyPrefix` → subgraph header-hash prefix → `L2CacheKeyInterceptor`). +Entity cache config (ADR-0006) sits on top: it binds an entity type to a named cache, a TTL, and the entity key template, +and drives the query-path read/write of L1 and L2. + +Subscriptions are the third GraphQL operation type, and they have a cache behavior that neither queries nor mutations cover. +A subscription emits entity data on **every event**, and that stream is the freshest possible view of an entity's state. +The problems this configuration concept solves: + +1. **Subscription streams are a free source of fresh entity data.** +A subscription such as `updateProductPrice(upc:)` pushes a new `Product` on every change. +If that data is dropped after fanout, then every downstream query (and every subscriber that runs a child `@key`-resolved entity fetch) round-trips to the entity-owning subgraph, +even though the gateway just observed the entity's current state on the wire. +Writing each event's entity into L2 lets those later reads hit cache instead. + +2. **Key-only subscriptions are an invalidation signal, not a population signal.** +Some subscriptions return only the entity's `@key` fields and mean "this entity changed, drop your copy." +For those, the correct action is to **delete** the L2 entry, not to write a stub that has no payload fields. +The two behaviors live behind one config: populate when the event carries fields beyond `@key`, invalidate when it carries only `@key` and `EnableInvalidationOnKeyOnly` is set. + +3. **The cache operation must happen once per event, ordered before fanout, and never block delivery.** +N subscribers sharing one trigger must cause exactly one cache operation per affected entity, not N. +A subscriber that runs a child entity fetch for the same event must see the just-written value, so populate must complete before fanout. +And the subscription stream is the contract with the client, so a cache backend error must never abort an event. + +None of this is a wire directive. +It arrives as Go configuration, supplied per subgraph through `SubgraphCachingConfig` as a `SubscriptionEntityPopulationConfiguration` +(carried to the resolver on the subscription, with an `*EntityQueryCacheKeyTemplate` for key rendering). +The config has a sharp footgun the foundation already flags: the lookup `FindByTypeAndFieldName` matches on **both** `TypeName` and `FieldName`, +so a config with an empty `FieldName` silently never fires. +The full behavioral contract — populate/invalidate semantics, once-per-event ordering, union/interface filtering, `__typename` injection, the key pipeline, and the callbacks — +lives in [../directives/subscription-cache-config.md](../directives/subscription-cache-config.md). + +## Decision + +Subscription population config is implemented as a **trigger-level cache operation on the subscription resolution path**, with **no new loader hot-path code**. +The query/mutation seams of ADR-0001 are reused conceptually (key rendering, projection, the transform pipeline, StructuralCopy isolation), +but the subscription path is a distinct code path in the resolver, so the operation hangs off it rather than off `mergeResult`. + +### How it plugs into the foundation seam + +**Plan-time / config metadata (additive, no schema syntax).** +The router supplies a `SubscriptionEntityPopulationConfiguration` per (subgraph, type, field), selected by `FindByTypeAndFieldName(typeName, fieldName)`. +Its load-bearing fields: + +- `TypeName` and `FieldName` — **both mandatory**; the lookup matches on both, and an empty `FieldName` is a silent no-op. + This pair is also what disambiguates two subscriptions on the same entity type with different TTLs (one config per field name). +- `CacheName` — selects the named L2 backend from `ResolverOptions.Caches`; a missing name is a silent no-op (defensive guard). +- An `*EntityQueryCacheKeyTemplate` (`CacheKeyTemplate`) — renders entity keys from the event's items, exactly the entity key shape of ADR-0001 §7.5. +- `EntityTypeName` — the concrete entity type, used to filter union/interface events and to inject `__typename` when absent. +- `SubscriptionFieldName` — if set, the resolver navigates into this field of the event data before treating items as entities. +- `EnableInvalidationOnKeyOnly` — selects invalidate-on-key-only behavior over populate. +- `TTL`, `DataSourceName`, `IncludeSubgraphHeaderPrefix` — the same TTL and key-prefix inputs every other L2 write uses. + +`SubscriptionEntityPopulationConfiguration` is plain config, not a new `plan` dependency pulled into `resolve`, +consistent with the `EntityCacheInvalidationConfig` split in ADR-0001 §7.4. + +**Resolver hook (the subscription path, not the merge funnel).** +On every trigger event the resolver runs one cache operation over the event payload, before fanning the event out to subscribers: + +1. **Guards first.** + Return early (no cache op, no error) when the config or its key template is nil, + when `CacheName` is not present in `Caches`, + or when the captured request context has `ExecutionOptions.Caching.EnableL2Cache == false`. + These guards keep the disabled path free and ensure misconfiguration never blocks delivery. +2. **Parse a working copy.** + The event bytes are parsed onto an arena value; the resolver navigates into `SubscriptionFieldName` if set, then treats the result as one entity item or a list of items. + `__typename` injection and any normalization happen on this parsed copy — **never** on the bytes handed to subscribers (R6). +3. **Per-item type filtering.** + For union/interface return types, items whose `__typename` differs from `EntityTypeName` are skipped; + items with no `__typename` get `EntityTypeName` injected so the entity key template renders a correctly-typed key (R5, R6). +4. **Key construction reuses the exact query-path pipeline.** + `EntityQueryCacheKeyTemplate.RenderCacheKeys` builds the entity key from the item; + the key then passes through the **same transform pipeline** as every other key — + global prefix concatenated with the subgraph header-hash prefix (when `IncludeSubgraphHeaderPrefix` is set) into the rendered prefix, then `L2CacheKeyInterceptor` applied after. + Byte-identity with the read path is mandatory or the entry is targeted at the wrong key (R7). +5. **Populate vs invalidate.** + In populate mode the resolver projects the item to its present fields and `Set`s a `CacheEntry{Key, Value, TTL}` per entity (R1). + In invalidate mode (key-only event with `EnableInvalidationOnKeyOnly`) the resolver `Delete`s the rendered key and performs no `Set` (R2). +6. **Once per event, ordered before fanout, non-blocking.** + The cache operation runs exactly once for the whole event payload regardless of subscriber count (R3), + completes before the per-subscriber resolution path that may trigger a child entity fetch (R4), + and swallows backend `Set`/`Delete` errors so the event is always delivered (R10). +7. **Key lifetime.** + Rendered keys are cloned off the trigger arena before being handed to the backend, because the arena is released when the trigger event's resolve completes and the backend may retain the key (R13). + +**No loader hot-path changes.** +The walker dispatch, the four-phase parallel machinery, and `mergeResult`'s signature are untouched. +Subscription caching lives on the subscription resolution path inside the `entityCache` collaborator and the subscription event handling, +not inline in the query/mutation merge funnel. +Whether the operation runs synchronously on the trigger goroutine or asynchronously with a completion barrier before fanout is implementation latitude, +provided the once-per-event (R3) and ordered-before-fanout (R4) guarantees hold. + +### Event-driven callbacks (the deliberate push exception) + +Analytics in the foundation is pull-based (`GetCacheStats()`), but subscriptions are inherently event-driven and outlive any single request's snapshot point, +so subscription cache effects are reported through dedicated push callbacks instead — the exception ADR-0001 already records: + +- `ResolverOptions.OnSubscriptionCacheWrite` fires once per cached entry after a successful populate, + with a `CacheWriteEvent{CacheKey, EntityType, ByteSize, DataSource, CacheLevel: CacheLevelL2, TTL, Source: CacheSourceSubscription}` (R11). +- `ResolverOptions.OnSubscriptionCacheInvalidate` fires once after a successful invalidate, + with `(entityType string, keys []string)` listing the finalized post-interceptor keys deleted (R12). + +These are router-set, opt-in, and zero-cost when unset. + +### How the PR stacks + +This work ships as **gqtools PR 15 / PR-CACHE-SUBSCRIPTION**, stacked on the foundation PR and on the entity cache config PR (ADR-0006). +It depends on them because subscription population builds entity-shaped keys (needs `@key` and the `EntityQueryCacheKeyTemplate`) +and writes through the same L2 projection and transform pipeline (needs the entity cache config and the key-prefix contract). +It reuses the post-effect cache-write/delete pattern that mutation invalidation (ADR-0008) established, rather than inventing a new one. +Because it adds only a trigger-level operation on the already-distinct subscription path plus two opt-in callbacks, it is independently reviewable against the now-frozen seam. + +## Consequences + +### Positive + +- **Subscriptions warm and refresh the cache for free.** Every event keeps L2 consistent with the latest observed entity state, so downstream queries and child entity fetches hit cache instead of round-tripping to the owning subgraph. +- **One config, two correct behaviors.** Populate and key-only invalidate are selected by `EnableInvalidationOnKeyOnly` plus the event's field set, so a "this changed, drop it" stream and a "here's the new value" stream are both handled without separate machinery. +- **Delivery is never coupled to cache health.** Backend errors are swallowed, a missing cache name or disabled L2 is a silent no-op, and the cache op is ordered before fanout — so caching can be enabled on a live subscription with no risk to event delivery. +- **Zero new loader surface.** The query/mutation hot path is untouched; the operation lives entirely on the subscription path and the `entityCache` collaborator. + +### Negative / costs + +- **Key-pipeline duplication risk.** The subscription path must reproduce the full key-transform pipeline (header prefix → global prefix → interceptor) byte-for-byte, or populated/invalidated keys miss the read-path entry. Mitigated by routing through the same key-building helpers `prepareCacheKeys` uses, but the two must stay in lockstep. +- **The mandatory-pair footgun.** `FindByTypeAndFieldName` matches on both `TypeName` and `FieldName`; an empty `FieldName` makes the whole config a silent no-op with no error. The router integration must set `FieldName` on **both** populate and invalidate configs, and this must be tested, because the failure mode is invisible. +- **No L1 participation.** Subscription caching is L2-only; L1 hit/miss accounting and `@requestScoped` coordinate L1 within an event are explicitly out of scope, so the per-event win is cross-request, not within a single event's fanout. +- **Ordering constraint is load-bearing.** The before-fanout completion barrier (R4) is required for a same-event child entity fetch to see the populated value; any refactor of the subscription event loop must preserve it. + +### Performance implications + +- One cache operation per **event**, not per subscriber: N subscribers on one trigger amortize to a single `Set`/`Delete` batch per affected entity (R3), so high fan-out subscriptions do not multiply backend load. +- Populate writes are projected to present fields only and serialized to heap bytes once per entity, exactly like the query-path L2 write; there is no extra round-trip and no mutation-style staleness read. +- With L2 disabled per request, a missing cache name, or no config, the path is a guard-clause early return — effectively free. + +### What becomes possible for later directives + +- The trigger-level populate/invalidate pattern, plus the push callbacks, give a template for any future event-driven cache effect (for example, change-data-capture style invalidation feeds) without touching the request-scoped pull-analytics model. +- Sharing the key pipeline and L2 projection across query, mutation, and subscription writes means a single place governs cache-entry shape and identity, so later directives that add new write sources inherit byte-identical keys for free. + +## Alternatives considered + +### A. Reuse the query/mutation merge funnel for subscription caching + +Route subscription events through `mergeResult` and the post-merge invalidation seam, so all three operation types share one write/delete site. +**Rejected.** +The subscription path is structurally different: it is trigger-driven, fans one event out to many subscribers, must run the cache op exactly once before fanout, and outlives any single subscriber's request context. +Forcing it through the per-request merge funnel would either run the op once per subscriber (violating R3) or contort the funnel with subscription-only branching. +A dedicated trigger-level operation that reuses the key pipeline and projection helpers (but not the merge funnel) keeps both paths clean. + +### B. Pull-based analytics for subscription cache effects instead of callbacks + +Record subscription writes/invalidations into the same pooled collector that `GetCacheStats()` snapshots. +**Rejected.** +A subscription is long-lived and emits events indefinitely, so there is no single "after resolve" point at which to snapshot, and the pooled collector is released per request. +Event-driven `OnSubscriptionCacheWrite` / `OnSubscriptionCacheInvalidate` callbacks fit the lifetime, which is exactly the deliberate exception ADR-0001 carves out for subscriptions. + +### C. Make `FieldName` optional and match on `TypeName` alone + +Drop the mandatory-pair rule and let one config apply to all subscription fields of an entity type. +**Rejected.** +Two subscriptions on the same entity type can legitimately need different TTLs or different populate/invalidate behavior (the field-name disambiguation case, R14). +Matching on `TypeName` alone would make those ambiguous and force the first config to win. +Keeping both fields mandatory is the explicit, testable contract; the cost is the silent-no-op footgun, which is documented and guarded by router-side tests rather than designed away. + +### D. Always invalidate on every event (never populate) + +Treat every subscription event purely as a "this entity changed, drop your copy" signal and only ever `Delete`. +**Rejected.** +That discards the freshest data the gateway will ever see and forces a subgraph round-trip on the next read, when the event already carried the new value. +Populate-when-fields-present plus invalidate-on-key-only captures both real-world stream shapes; a blanket invalidate would waste the populate opportunity entirely. + +## References + +- [ADR-0001: Foundation](0001-foundation.md) — the integration seam, L1/L2 model, key-transform pipeline, StructuralCopy invariants, and the subscription-callback exception to pull-based analytics. +- [ADR-0006: Entity cache config](0006-entity-cache-config.md) — binds the entity type to a named cache, TTL, and the entity key template the subscription path renders against. +- [ADR-0008: Mutation cache config](0008-mutation-cache-config.md) — the post-effect populate/invalidate pattern this directive mirrors on the subscription path. +- [../directives/subscription-cache-config.md](../directives/subscription-cache-config.md) — the detailed subscription population config contract. +- [../02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) — directive taxonomy and PR mapping. +- `docs/entity-caching/SUBSCRIPTION_CACHE_SPEC.md` — the behavioral requirements R1–R14 this ADR summarizes. +- `execution/engine/federation_subscription_caching_test.go` — populate, key-only invalidate, once-per-event, before-fanout ordering, header-prefix, union/interface filtering, field-name disambiguation, and callback coverage. diff --git a/docs/entity-caching/directives/entity-cache-config.md b/docs/entity-caching/directives/entity-cache-config.md new file mode 100644 index 0000000000..7b1c339c94 --- /dev/null +++ b/docs/entity-caching/directives/entity-cache-config.md @@ -0,0 +1,308 @@ +# Directive Specification: Entity Caching Config + +> Part of the entity-caching re-implementation document set. +> Cross-links: +> [adr/0006-entity-cache-config.md](../adr/0006-entity-cache-config.md) (rationale), +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) (integration seam, L1/L2 model, StructuralCopy invariants, cache-key model), +> [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) (directive taxonomy and PR mapping). +> +> Re-implementation PR: **gqtools PR 4 / PR-CACHE-CONFIG**. + +This document assumes no prior knowledge of the feature. +It describes *what* the entity caching config is, *how* it is supplied, and *how the resolver acts on it* — without copying implementation code. + +--- + +## 1. Purpose & responsibility + +The "entity caching config" is the unit of declaration that turns L2 (cross-request) caching **on** for one entity type in one subgraph. +It is **not a GraphQL wire directive** — there is no `@entityCache` in any subgraph SDL. +It is a Go configuration struct (`plan.EntityCacheConfiguration`), one per `(subgraph, entity type)`, that binds an entity type name to a named cache instance, a TTL, and a small set of per-type behavior flags (negative caching, partial loads, shadow mode, header-aware keys, analytics key hashing). +Caching is strictly **opt-in**: an entity type with no config is never written to or read from L2 (its L1 behavior is independent — see §6). +The config is consumed in two stages: the **planner** copies the matched config onto each entity fetch's `FetchCacheConfiguration`, and the **resolver** reads that per-fetch config to decide whether to consult L2, what cache instance and TTL to use, and how to shape the stored value. + +--- + +## 2. Configuration definition (Go struct, not SDL) + +There is no SDL. +The authoritative shape is `plan.EntityCacheConfiguration` (in `v2/pkg/engine/plan/federation_metadata.go`). +Fields and their meaning: + +```go +type EntityCacheConfiguration struct { + TypeName string // GraphQL entity type, must match the __typename returned by _entities + CacheName string // which registered LoaderCache instance backs this type + TTL time.Duration // lifetime of a positive (real-data) cache entry; 0 = never expire + IncludeSubgraphHeaderPrefix bool // when true, prefix the L2 key with a hash of forwarded headers + EnablePartialCacheLoad bool // when true, fetch only cache-missed entities in a batch + HashAnalyticsKeys bool // when true, analytics record a hashed key instead of the raw key + ShadowMode bool // when true, read+write L2 but never serve cached data (staleness probe) + NegativeCacheTTL time.Duration // when > 0, cache "entity not found" (null) results for this duration +} +``` + +A collection type wraps it: + +```go +type EntityCacheConfigurations []EntityCacheConfiguration +func (c EntityCacheConfigurations) FindByTypeName(typeName string) *EntityCacheConfiguration // nil = not configured +``` + +It is supplied per subgraph via the execution-engine factory option `WithSubgraphEntityCachingConfigs`, which carries `SubgraphCachingConfig.EntityCaching`: + +```go +type SubgraphCachingConfig struct { + SubgraphName string + EntityCaching plan.EntityCacheConfigurations // one entry per entity type cached in this subgraph + // ... RootFieldCaching, MutationFieldCaching, SubscriptionEntityPopulation, MutationCacheInvalidation +} +``` + +The factory copies `subgraphCachingConfig.EntityCaching` onto the matching subgraph's `FederationMetaData.EntityCaching`. +At plan time the planner reads it back through `FederationMetaData.EntityCacheConfig(typeName) *EntityCacheConfiguration`. + +**Note on the inventory's "serve-stale window".** +The directive inventory describes this config as binding "a named cache, a TTL, optional negative-cache TTL, and a serve-stale window." +The first three map directly to `CacheName`, `TTL`, and `NegativeCacheTTL`. +There is **no dedicated serve-stale-window field** in the current struct. +The two adjacent behaviors that exist instead are `ShadowMode` (always refetch and compare, never serve cached data) and `EnablePartialCacheLoad` (serve cached batch members directly while refetching only the missing ones, accepting in-TTL staleness). +The re-implementation should treat "serve-stale window" as **not yet a field**; if a real stale-serving window is wanted, it is a new field and must be specified in [adr/0006-entity-cache-config.md](../adr/0006-entity-cache-config.md) before it is added. + +--- + +## 3. Composition rules & validation + +There is no schema syntax, so there is **no composition-side directive validation** for this config — it never appears in subgraph SDL and composition neither emits nor checks it. +The rules are structural, enforced by construction and by the lookup contract rather than by a validator: + +- **One config per `(subgraph, type)`.** + `EntityCacheConfigurations` is a flat slice and `FindByTypeName` returns the **first** match. + Two entries with the same `TypeName` in one subgraph's `EntityCaching` are a configuration error: the second is silently shadowed. + The re-implementation should treat duplicate `TypeName` within one subgraph's list as invalid input (the router/composition layer that builds these lists is responsible for de-duplicating). + +- **`TypeName` must match the subgraph's `__typename`.** + The lookup key is the entity type name as returned in `_entities` responses. + A typo means the config never matches and the entity is silently uncached. + +- **`CacheName` must be registered at runtime.** + The name is resolved against the resolver's `Caches map[string]LoaderCache`. + An unregistered name means the fetch finds no backing cache and falls through to the subgraph (no panic; the L2 path is simply skipped). + Multiple types may share one `CacheName` — sharing a backing cache instance is explicitly allowed and common. + +- **`@key` must exist for the type.** + This config has no key information of its own; the entity cache key is built from the type's `@key` fields by the planner's key-fields machinery (see [directives/key.md](key.md)). + A type with no resolvable `@key` cannot be entity-cached even if a config is present, because no stable key shape can be produced. + +- **Opt-in default.** + Absence of a config = L2 disabled for that type. + There is no "cache everything" mode at this layer. + +--- + +## 4. Runtime semantics (plan + four-phase resolve) + +### 4.1 Plan time (once per operation) + +For each entity fetch the planner (`configureFetchCaching` in `caching_planner_state.go`) does the following: + +1. Always attach the `CacheKeyTemplate` (an `EntityQueryCacheKeyTemplate`) regardless of whether L2 is configured — L1 needs the template even when L2 is off. +2. Look up `FederationMetaData.EntityCacheConfig(entityTypeName)`. + - If nil: leave `Enabled = false` (L2 off), but still record `KeyFields` for analytics and keep the template for L1. + - If present: produce a `FetchCacheConfiguration` with `Enabled = true` and copy `CacheName`, `TTL`, `IncludeSubgraphHeaderPrefix`, `EnablePartialCacheLoad`, `HashAnalyticsKeys`, `ShadowMode`, and `NegativeCacheTTL` onto it. +3. Leave `UseL1Cache = false`. + The L1-enable decision is made later by the post-process optimizer (see §6 and [01-ARCHITECTURE-SPEC.md §6](../01-ARCHITECTURE-SPEC.md)), independent of this config. + +The planner does **not** touch any cache; it only annotates the fetch. + +### 4.2 Resolve time — where this config acts in the four phases + +The resolver path is the one described in [01-ARCHITECTURE-SPEC.md §5](../01-ARCHITECTURE-SPEC.md) and `resolve/CLAUDE.md`. +The governing rule holds throughout: **the main thread runs all cache logic and the arena; goroutines do subgraph HTTP only.** +This config influences these touchpoints: + +- **Phase 1 — prepare keys + L1 check (main thread).** + `prepareCacheKeys` renders the entity key from `@key` fields via the template. + The entity-cache config's `IncludeSubgraphHeaderPrefix` selects whether the rendered L2 key is prefixed with the subgraph header hash. + L1 is consulted here, but L1 eligibility is governed by `UseL1Cache`, not by this config's `Enabled` flag. + +- **Phase 2-L2 — bulk L2 lookup (main thread).** + Only fetches with `Enabled = true` (set from this config) participate. + Fetches are grouped by `LoaderCache` instance (resolved from `CacheName`); one `Get` is issued per instance. + Returned bytes are parsed verbatim onto the Loader arena. + `applyEntityFetchL2Results` then validates each returned value against the fetch's `ProvidesData` (the field-widening check from [directives/provides.md](provides.md)) and sets `cacheSkipFetch` for entities whose L2 entry covers all required fields. + `ShadowMode` short-circuits the *serve* decision here: the read still happens and a `ShadowComparisonEvent` is recorded, but `cacheSkipFetch` is never set — the subgraph is always fetched and compared. + `EnablePartialCacheLoad` decides batch behavior: when false, any miss in the batch forces a full refetch; when true, only missing entities are fetched and cached members are spliced in directly. + A **negative-cache sentinel** (a stored literal `null`, written under `NegativeCacheTTL`) is a *hit*: it satisfies the fetch as a known-absent entity and skips the subgraph. + +- **Phase 2-HTTP — parallel HTTP (goroutines).** + Entity fetches not satisfied by L1 or L2 fetch from the subgraph; goroutines return raw bytes only. + +- **Phase 4 — merge + populate (main thread).** + `mergeResult` merges the body (or the cached value when `cacheSkipFetch`) into the response tree. + `updateL2Cache` then writes back when `Enabled = true` and the fetch was a miss/partial/refresh: + - A real entity → stored with `TTL`, projected to `ProvidesData` fields only (see §5). + - A null entity (`_entities` returned `null` with no errors) → when `NegativeCacheTTL > 0`, stored as a negative sentinel under `NegativeCacheTTL` (not `TTL`); when `NegativeCacheTTL == 0`, not cached at all and refetched next request. + `populateL1Cache` runs independently and uses passthrough copying (§5, §6). + +### 4.3 Alias / normalization handling + +The cache key is **alias-independent** by construction: it is built from `@key` fields through the template, never from response aliases. +The stored L2 *value* is normalized to schema field names (aliases stripped) and projected to `ProvidesData` via `structuralCopyNormalized` before serialization, so two queries that alias the same entity produce byte-identical keys and compatible stored shapes. +On read, the cached value is denormalized (aliases re-applied for the current query) via `structuralCopyDenormalized` before merging into the response tree. + +### 4.4 Ordering / threading constraints + +- All key rendering, L2 Get/Set, parsing, merging, and cache population happen on the **main thread**; this config never crosses into a goroutine. +- L2 writes serialize to heap bytes (`MarshalTo*`) before handing them to the backend — the heap boundary that makes external storage safe across requests. +- The `LoaderCache` backend named by `CacheName` must be concurrency-safe (forward-compatible contract), even though the engine currently issues bulk reads on the main thread. + +--- + +## 5. Cache key & data shape + +**Key shape (entity key).** +This config produces the entity key shape, built by `EntityQueryCacheKeyTemplate` from `@key` fields only: + +```text +{"__typename":"User","key":{"id":"123"}} +``` + +Numbers in keys are coerced to strings (an integer `id` and a string `id` collide). +The key transform pipeline (applied identically on read, write, delete) is: + +```text +GlobalCacheKeyPrefix → subgraph header-hash prefix (when IncludeSubgraphHeaderPrefix) → L2CacheKeyInterceptor +``` + +`TTL`, `ShadowMode`, `NegativeCacheTTL`, `EnablePartialCacheLoad`, and `HashAnalyticsKeys` do **not** change the key — they change behavior and storage, not identity. +`IncludeSubgraphHeaderPrefix` is the only field here that affects the final key bytes. + +**Stored data shape — projection, not passthrough (L2).** +L2 entries are minimal and self-contained: `updateL2Cache` stores the value **projected to `ProvidesData` fields only** (`structuralCopyNormalized`, `Transform.Passthrough = false`), dropping non-provided fields and excluding `@requires`-derived fields (see [directives/requires.md](requires.md)). +This is the L1/L2 split from [01-ARCHITECTURE-SPEC.md §3.4](../01-ARCHITECTURE-SPEC.md): L2 projects, L1 passes through. + +**Negative sentinel shape.** +When `NegativeCacheTTL > 0` and the entity came back null: +- with no prior positive entity data for that key → the stored value is the literal `null` sentinel. +- with prior positive non-key data already present for that key → the stored value preserves the existing object and materializes newly requested nullable fields as explicit `null`, so the same selection validates from cache. + +**L1 shape (for contrast).** +L1 writes use passthrough (`structuralCopyNormalizedPassthrough`): rename aliases but **keep all source fields**, including `@key` fields not in `ProvidesData`, so L1 entries accumulate across sibling fetches within a request. +L1 is governed by `UseL1Cache`, not by this config's `Enabled` flag. + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation seam ([01-ARCHITECTURE-SPEC.md §7](../01-ARCHITECTURE-SPEC.md)).** + This config feeds the additive `FetchCacheConfiguration` struct on each entity fetch; it adds no new interface. + It rides the existing hook points (`prepareCacheKeys`, `bulkL2Lookup`/`tryL2CacheLoad`, `mergeResult`, `updateL2Cache`) and the two-boolean merge contract (`cacheSkipFetch`, `cacheMustBeUpdated`). + It depends on the `LoaderCache` backend interface (resolved by `CacheName`) and the engine-internal `CacheKeyTemplate` seam, and it depends on the StructuralCopy isolation discipline of [01-ARCHITECTURE-SPEC.md §3](../01-ARCHITECTURE-SPEC.md) for correctness of both writes and reads. + +- **`@key` ([directives/key.md](key.md)) — hard dependency.** + The entity cache key is derived from `@key` fields. + Without a resolvable `@key`, this config cannot produce a key and the type cannot be entity-cached. + +- **`@provides` ([directives/provides.md](provides.md)) — shapes the stored value.** + `ProvidesData` drives the L2 projection, the field-widening validation on read, and the alias denormalization on serve. + This config does not define the shape; it relies on `@provides`/selection-derived `ProvidesData`. + +- **`@requires` ([directives/requires.md](requires.md)) — exclusion rule.** + Request-derived `@requires` inputs must never be written into the cached entity shape; the projection inherits this exclusion. + +- **Per-request toggles ([01-ARCHITECTURE-SPEC.md §9](../01-ARCHITECTURE-SPEC.md)).** + L2 participation also requires `Context.ExecutionOptions.Caching.EnableL2Cache`; this config is necessary but not sufficient. + L1 is gated by `EnableL1Cache` and by `UseL1Cache` (set by the post-process L1 optimizer of [01-ARCHITECTURE-SPEC.md §6](../01-ARCHITECTURE-SPEC.md)), independently of this config. + +- **Downstream configs that build on this one.** + Root-field config ([directives/root-field-cache-config.md](root-field-cache-config.md)) shares L2 entries with entity fetches via `EntityKeyMapping` — it needs the entity cache config to populate the shared keys. + Mutation ([directives/mutation-cache-config.md](mutation-cache-config.md)) and subscription ([directives/subscription-cache-config.md](subscription-cache-config.md)) configs read and invalidate exactly what this config populates; mutation L2 writes may override `TTL` via the mutation field config. + +--- + +## 7. End-to-end test plan + +These cases target the federation services `accounts`, `products`, `reviews` (under `execution/federationtesting/`) through the gateway, and the resolve-package unit harness for the negative-cache lifecycle. +**Mandatory assertion style for every case** (from the package CLAUDE.md files): + +- Exact assertions only — `assert.Equal` on the full value. + Never `assert.Contains`, `assert.GreaterOrEqual`, `assert.Greater`, or any fuzzy comparison. +- Assert entire structs — full `CacheAnalyticsSnapshot` / full `[]CacheLogEntry`, not selected fields. +- Inline literals — queries, cache keys, byte sizes, TTLs, and expected JSON inline at the assertion/setup site; no shared `const`/var for expected values; configs inline in the setup call. +- Vertical multi-key literals — one `Keys`/`Hits`/event entry per line. +- Snapshot comments — every event line carries a trailing `// why` comment. +- Cache-log discipline — every `ClearLog()` is immediately followed by `GetLog()` + full assertions before the next `ClearLog()` or end of test. +- Self-contained subtests under `execution/engine/` — duplicate setup per `t.Run`; no shared `newXxxEnv` helpers. + +### Case 1 — L2 miss then hit (single entity type) + +- **Setup:** subgraph `reviews` with `EntityCaching: plan.EntityCacheConfigurations{{TypeName: "Product", CacheName: "default", TTL: 30 * time.Second}}` inline; `CachingOptions{EnableL2Cache: true, EnableCacheAnalytics: true}`. +- **Query (inline):** `query { topProducts { name reviews { body } } }`. +- **What is cached:** each `Product` entity fetched from `reviews` under key `{"__typename":"Product","key":{"upc":"..."}}`, projected to provided fields, with `TTL: 30 * time.Second`. +- **Assertions:** + - Request 1: `assert.Equal` on the full response body. + `assert.Equal` on the full `CacheAnalyticsSnapshot` — `L2Reads` all `CacheKeyMiss` (// first request, cache empty), `L2Writes` for each Product key with exact `ByteSize`, `TTL: 30 * time.Second`, `CacheLevel: CacheLevelL2`, `Source: CacheSourceQuery` (// written after subgraph fetch on miss). + - Request 2: same response body; full snapshot with `L2Reads` all `CacheKeyHit` carrying exact `ByteSize` (// populated by request 1), no `L2Writes` (// all served from cache). + +### Case 2 — multiple entity types share one cache + +- **Setup (inline):** `products` with `RootFieldCaching` for `Query.topProducts`; `reviews` with `EntityCaching {TypeName:"Product", CacheName:"default", TTL:30s}`; `accounts` with `EntityCaching {TypeName:"User", CacheName:"default", TTL:30s}` — same `CacheName: "default"`. +- **Query (inline):** `query { topProducts { name reviews { body author { username } } } }`. +- **What is cached:** `Product` and `User` entities plus the root field, all in the one `default` cache. +- **Assertions:** full `CacheAnalyticsSnapshot` over both requests, one `L2Reads`/`L2Writes` entry **per line** with a `// why` comment per line (e.g. `// User 1234 deduplicated in batch`), exact `ByteSize` and `TTL` literals. + Verifies a shared `CacheName` keys distinct entity types into distinct entries within the same instance. + +### Case 3 — opt-in: no config = no L2 + +- **Setup (inline):** caching enabled (`EnableL2Cache: true`) but `EntityCaching` empty for `reviews`. +- **Query (inline):** `query { topProducts { name reviews { body } } }`. +- **What is cached:** nothing in L2. +- **Assertions:** full `[]CacheLogEntry` is empty (no `get`, no `set`) for the Product key; full snapshot has empty `L2Reads`/`L2Writes`. + Run twice and assert both subgraph fetches happen (exact tracker count, e.g. `assert.Equal(t, 2, productCalls)`). + +### Case 4 — header-aware keys (`IncludeSubgraphHeaderPrefix`) + +- **Setup (inline):** `reviews` with `{TypeName:"Product", CacheName:"default", TTL:30s, IncludeSubgraphHeaderPrefix: true}`; mock header source returns a deterministic, cloned `http.Header` (use `.Clone()`). +- **What is cached:** the Product entry under a header-hash-prefixed key. +- **Assertions:** full `[]CacheLogEntry` with the exact prefixed key string inline (e.g. `11945571715631340836:{"__typename":"Product","key":{"upc":"top-1"}}`). + A second request with a different header value produces a different prefixed key (assert both exact keys, separate entries) — proving header isolation. + +### Case 5 — negative caching lifecycle (resolve-package unit, mirrors `negative_cache_test.go`) + +- **Setup:** entity fetch with `Caching: FetchCacheConfiguration{Enabled: true, CacheName: "default", TTL: 30 * time.Second, NegativeCacheTTL: 10 * time.Second, CacheKeyTemplate: ...}`; subgraph returns `{"data":{"_entities":[null]}}`. +- **What is cached:** a `null` sentinel under the entity key, written with `NegativeCacheTTL`, not `TTL`. +- **Assertions:** + - First execution: `assert.Equal(t, "null", string(cache.GetValue(key)))`. + `assert.Equal` on the full `[]CacheLogEntry`: a `get` miss then a `set` with `Items: [{Key: key, TTL: 10 * time.Second}]` (// negative sentinel uses NegativeCacheTTL, not TTL). + - Second execution (sentinel still live): subgraph mock `Times(1)` — not called again; `get` returns `Hit: true`. + - `NegativeCacheTTL: 0` variant: subgraph mock `Times(2)` — not cached, refetched. + - Eviction-then-real-data variant: after `cache.Delete(key)`, second request stores real data under `TTL: 30 * time.Second`; assert the full stored value JSON inline and the full two-entry log (`get` miss, `set` 30s). + +### Case 6 — shadow mode never serves + +- **Setup (inline):** `reviews` with `{TypeName:"Product", CacheName:"default", TTL:30s, ShadowMode: true}`. +- **What is cached:** L2 is read and written normally, but cached data is never served. +- **Assertions:** across two requests, the subgraph is fetched **both** times (exact tracker count, `assert.Equal(t, 2, productCalls)`); full snapshot shows a populated `ShadowComparisons` slice (assert the full `ShadowComparisonEvent` value inline) and `L2Reads`/`L2Writes` present. + Response body identical to the non-shadow case. + +--- + +## 8. Acceptance criteria (reviewer checklist) + +- [ ] `plan.EntityCacheConfiguration` exposes exactly: `TypeName`, `CacheName`, `TTL`, `IncludeSubgraphHeaderPrefix`, `EnablePartialCacheLoad`, `HashAnalyticsKeys`, `ShadowMode`, `NegativeCacheTTL`. + No undocumented fields; any "serve-stale window" addition is recorded in the ADR first. +- [ ] Supplied per subgraph via `SubgraphCachingConfig.EntityCaching` and registered through `WithSubgraphEntityCachingConfigs`; surfaced to the planner through `FederationMetaData.EntityCacheConfig(typeName)`. +- [ ] Opt-in: absence of a config means the entity type is never read from or written to L2; presence with `EnableL2Cache: true` turns L2 on. +- [ ] `FindByTypeName` returns the first match; duplicate `TypeName` within one subgraph is treated as invalid input (de-duplicated upstream). +- [ ] Multiple entity types may share one `CacheName`, keying into distinct entries in the same instance (Case 2). +- [ ] An unregistered `CacheName` skips L2 gracefully (subgraph fetch), no panic. +- [ ] Entity cache key is alias-independent, built from `@key` fields only, in the shape `{"__typename":T,"key":{...}}` with numeric keys coerced to strings. +- [ ] Key transform pipeline applied identically on read/write/delete: `GlobalCacheKeyPrefix → header-hash prefix (when IncludeSubgraphHeaderPrefix) → L2CacheKeyInterceptor`. +- [ ] L2 stored value is projected to `ProvidesData` (`structuralCopyNormalized`, no passthrough), excludes `@requires`-derived fields, and is denormalized on read. +- [ ] All cache key rendering, L2 Get/Set, parsing, merging, and population run on the main thread; goroutines do HTTP only. +- [ ] `TTL` governs positive entries; `NegativeCacheTTL > 0` caches null results as sentinels under `NegativeCacheTTL` (not `TTL`); `NegativeCacheTTL == 0` disables negative caching. +- [ ] `ShadowMode` reads and writes L2 but never serves cached data and records `ShadowComparisons` (Case 6). +- [ ] `EnablePartialCacheLoad` controls batch all-or-nothing vs missing-only fetch behavior. +- [ ] L1 behavior is independent of this config and gated by `UseL1Cache` (post-process optimizer) plus `EnableL1Cache`. +- [ ] All E2E/unit tests follow the mandatory assertion style of §7 (exact `assert.Equal` on full values, inline literals, vertical multi-key literals, snapshot `// why` comments, cache-log clear+assert discipline, self-contained subtests). diff --git a/docs/entity-caching/directives/key.md b/docs/entity-caching/directives/key.md new file mode 100644 index 0000000000..e664a7a937 --- /dev/null +++ b/docs/entity-caching/directives/key.md @@ -0,0 +1,408 @@ +# Directive Specification: `@key` + +> Part of the entity-caching re-implementation document set. +> Cross-references: +> [adr/0002-key.md](../adr/0002-key.md) (decision record for this directive), +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) (the integration seam, L1/L2 model, StructuralCopy invariants, cache-key model), +> [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) (the directive taxonomy and PR mapping this spec stays consistent with). +> +> Re-implementation PR: **gqtools PR 3 — cache-key templates** (logical name `PR-CACHE-KEYS`). + +--- + +## 0. Who this section is for + +This document specifies how the entity-caching layer consumes the federation `@key` directive. +It assumes you have never seen this feature before. +`@key` is **not introduced** by entity caching — it is an existing federation primitive. +Entity caching *reads* it to decide cache identity, and the re-implementation must re-consume it correctly. +The one truly new directive in this feature is `@requestScoped`, +specified separately in [request-scoped.md](./request-scoped.md). + +--- + +## 1. Purpose & responsibility + +`@key` declares the set of fields that uniquely identifies an entity within the federated graph. +For entity caching it is load-bearing in two distinct ways. +First, the `@key` field set is the **sole source of cache-key identity** for both L1 (per-request) and L2 (cross-request) entity caches, +so two requests asking for the same `User` by the same `id` collide on exactly the same cache entry, +regardless of what else either query selects. +Second, the entity L1 projection runs in **passthrough** mode that explicitly *retains* `@key` fields even when the current query did not select them and they are absent from the fetch's `ProvidesData` shape, +because a later, wider entity fetch needs the key present in the L1 entry to merge correctly. +The caching layer **consumes** `@key`; it never redefines it. + +--- + +## 2. SDL / configuration definition + +### 2.1 The federation SDL directive (unchanged, consumed only) + +```graphql +directive @key(fields: _FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +``` + +It appears in subgraph SDL on entity types, for example: + +```graphql +type User @key(fields: "id") { id: ID! } +type Product @key(fields: "upc") { upc: String! } +``` + +The directive itself is owned by the federation composition pipeline. +Entity caching does not parse SDL — it receives the already-resolved key fields as Go data structures described next. + +### 2.2 The plan-time data shape: `KeyField` + +The planner pre-extracts the `@key` field set into a recursive `KeyField` tree (defined in `node_object.go`). +This is the engine-internal representation of `@key` that every downstream cache concern reads: + +```go +type KeyField struct { + Name string + Children []KeyField // non-nil for nested object key fields +} +``` + +Mapping from SDL to `KeyField`: + +- `@key(fields: "id")` → `[]KeyField{{Name: "id"}}` +- `@key(fields: "sku upc")` (composite key) → `[]KeyField{{Name: "sku"}, {Name: "upc"}}` +- `@key(fields: "id address { city }")` (nested key) → `[]KeyField{{Name: "id"}, {Name: "address", Children: []KeyField{{Name: "city"}}}}` + +`KeyField` carries **only** key fields — `__typename` is excluded by construction (it is added back at key-render time, see §5). + +### 2.3 The cache-key template: `EntityQueryCacheKeyTemplate` + +For an entity (`_entities`) fetch the planner attaches an `EntityQueryCacheKeyTemplate` (defined in `caching.go`). +Tiny signature: + +```go +type EntityQueryCacheKeyTemplate struct { + Keys *ResolvableObjectVariable // @key fields ONLY (no @requires, no selected fields) + TypeName string // plan-time fallback when __typename missing from data +} + +func (*EntityQueryCacheKeyTemplate) IsEntityFetch() bool { return true } +``` + +`Keys` is an `Object`-tree variable whose fields are exactly the `@key` fields. +The comment on the field is the contract: *"Keys contains only `@key` fields (without `@requires` fields). Used for both L1 and L2 cache keys to ensure stable entity identity."* +`KeyFields()` on the template converts the embedded `Object` tree back into a `[]KeyField` for analytics, skipping `__typename`. + +### 2.4 Where the template fits on a fetch + +Each cacheable fetch carries a `FetchCacheConfiguration` (see §6 and [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §11). +For entity fetches its `CacheKeyTemplate` is an `EntityQueryCacheKeyTemplate`. +The configuration also surfaces the key fields directly via a `KeyFields []KeyField` field used by analytics. + +--- + +## 3. Composition rules & validation + +These rules are enforced by the **federation composition** pipeline, not by the caching layer. +Entity caching depends on them holding. + +- **Repeatable.** + A type may declare multiple `@key` directives (multiple alternative identifying field sets). + Each declared key set can independently seed cache identity for the entity-fetch path that resolves by that key. +- **`fields` is a mandatory `_FieldSet`.** + Composition requires the argument and validates that every named field exists on the type and that nested selections are valid. +- **Resolvability.** + An entity must be *resolvable* (the default, or `resolvable: true`) to participate in entity fetches. + A `@key(fields: "...", resolvable: false)` type (for example `type Product @key(fields: "upc", resolvable: false)` in the accounts test subgraph) declares identity but is **not** independently fetchable as an `_entities` target from that subgraph; + the caching layer will not build an entity-fetch cache key against a non-resolvable key in that subgraph. +- **Key stability across subgraphs.** + The same entity must use a consistent identity across subgraphs so that an entry written by one subgraph's fetch and read by another keys identically. + This is a federation composition guarantee the cache relies on; the cache does not re-validate it. + +The caching layer adds **no new composition validation** for `@key`. +It only reads the composed key fields. + +--- + +## 4. Runtime semantics + +This section maps `@key` consumption onto the planner and the four-phase parallel resolve flow described in [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §5. +The governing rule from the architecture spec applies throughout: **the main thread parses, merges, and runs all cache logic; goroutines do subgraph HTTP only.** +Cache-key rendering, L1 reads/writes, and L2 reads/writes all run on the main thread on the per-request arena. + +### 4.1 Plan time (compile-time, once per operation) + +- The planner's key-fields visitor walks each entity type's `@key` selection set and produces the `KeyField` tree and the `EntityQueryCacheKeyTemplate.Keys` `Object`. +- The template is attached to every entity (`_entities`) fetch's `FetchCacheConfiguration.CacheKeyTemplate`. +- The planner does **not** touch any cache. + It produces the template, the key fields, and the provides-shape only. + L1 remains off (`UseL1Cache = false`) until the post-process L1 optimizer flips it (see [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §6). + +### 4.2 Resolve time — where `@key` acts in each phase + +- **Phase 1 — prepare + L1 check (main thread).** + `prepareCacheKeys` invokes `CacheKeyTemplate.RenderCacheKeys(arena, ctx, items, prefix)`. + For an `EntityQueryCacheKeyTemplate` this reads each item's `__typename` (falling back to the template's plan-time `TypeName` when absent) and extracts **only** the template's `@key` fields from the item data, + producing one `CacheKey` per item. + L1 is then probed with that key. + On a complete entity hit the fetch is marked `cacheSkipFetch = true` and the stored value is copied out via a denormalizing passthrough StructuralCopy (§5.3). +- **Phase 2-L2 — bulk L2 lookup (main thread).** + The same rendered entity keys (after the key transform pipeline of §5.4) are grouped by cache instance and issued as one bulk `Get` per instance. + Returned bytes are parsed verbatim onto the Loader arena and distributed back to each `CacheKey.FromCache`; a per-fetch decision sets `cacheSkipFetch` when L2 hits cover all items. +- **Phase 2-HTTP — parallel HTTP (goroutines).** + Only fetches not already satisfied run here. Goroutines return `[]byte`; they never render keys or touch the cache. +- **Phase 4 — merge + populate (main thread).** + After merging the fetched (or cached) value into the response tree, `populateL1Cache` writes the entity into L1 keyed by its `@key`-derived key, and `updateL2Cache` writes the projected value into L2 under the same key shape. + +### 4.3 Ordering & threading constraints rooted in `@key` + +- **Key fields must be present in the data being keyed.** + Entity-key rendering reads `@key` fields from response item data. + An item whose `@key` fields are absent produces an **empty key object**, and that item is **skipped** for caching (see §5.2) — never given a degenerate key that would collide across all entities of the type. +- **`@key` rendering is alias-independent** (§5.1), so it is safe to render before or after alias normalization. +- **All key rendering allocates on the per-request arena on the main thread.** + No goroutine renders a key. + +--- + +## 5. Cache key & data shape + +### 5.1 The entity key shape + +`EntityQueryCacheKeyTemplate.RenderCacheKeys` produces, per item, a deterministic JSON key: + +```json +{"__typename":"User","key":{"id":"123"}} +``` + +Construction rules (each verified by `cache_key_test.go`): + +- `__typename` comes from the item's `__typename`, falling back to the template's plan-time `TypeName`. +- The `key` object contains **only** the `@key` fields named by the template, in template order. +- **Composite keys** render all key fields under `key`, for example `{"__typename":"Product","key":{"sku":"ABC123","upc":"DEF456"}}`. +- **Nested key fields** render as nested objects, for example a `store.id` mapping renders `{"key":{"store":{"id":"123"}}}`. +- **Array key fields** render the array verbatim, for example `{"key":{"tags":["electronics","sale"]}}`. +- **Number coercion.** + Numeric key values are coerced to strings (`CoerceToString`) so that an integer `id` (`1`) and a string `id` (`"1"`) collide on the **same** entry. + This applies to flat scalars, scalars inside composite keys, and scalars inside nested key objects — the contract is uniform. +- **Alias independence.** + The key is computed from the `@key` field *schema names*, never from response aliases, so the same entity yields the same key no matter how a query aliases its fields. + +### 5.2 The empty-key skip rule + +If, after extraction, the `key` object is empty (the `@key` fields were not selected and are absent from the data), +the item is **omitted** from the returned cache keys entirely. +Caching such an item would produce `{"__typename":"User","key":{}}`, which collides for every `User`, +causing incorrect cross-entity cache sharing. +This is a hard correctness rule, not an optimization. + +### 5.3 The projected/stored data shape — passthrough vs projection + +`@key` interacts directly with the L1 vs L2 projection switch (`Transform.Passthrough`, see [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3.4): + +- **L1 (passthrough = true).** + L1 writes use `structuralCopyNormalizedPassthrough`: rename aliases to schema names but **keep all source fields**, including `@key` fields that are not in `ProvidesData` and fields accumulated by sibling fetches. + This is the reason entity L1 uses passthrough rather than strict projection — **the `@key` fields must survive into the L1 entry** so a later, wider entity fetch can merge against an entry that still carries its identity. + L1 reads use `structuralCopyDenormalizedPassthrough`: restore aliases while preserving every accumulated field. +- **L2 (passthrough = false).** + L2 writes use the non-passthrough `structuralCopyNormalized` path (rendered to bytes via `MarshalToWithTransform`), projecting to `ProvidesData` fields only. + Because `ProvidesData` for an entity fetch must include the entity's `@key` fields (they identify the row), the projected L2 payload still round-trips its identity. + +### 5.4 The key transform pipeline + +The rendered key is transformed identically on read, write, and delete: + +```text +GlobalCacheKeyPrefix → subgraph header-hash prefix → L2CacheKeyInterceptor +``` + +The `prefix` argument to `RenderCacheKeys` carries the subgraph header-hash prefix when `IncludeSubgraphHeaderPrefix = true`; it is prepended as `prefix:{key}`. +Anyone performing manual invalidation must reproduce this exact pipeline or they target the wrong entry. + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation (StructuralCopy / arena).** + `@key`-keyed L1 entries obey the StructuralCopy invariant of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3 exactly: + every L1 **write** StructuralCopies into the cache (passthrough, keeping `@key`), + every L1 **read** StructuralCopies out before merging into the response tree, + and every **merge-into-existing-L1-entry** uses working-copy-and-swap. + The cache-key template itself is the engine-internal `CacheKeyTemplate` seam ([01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §7.5); the router never implements it. +- **`@provides` (`ProvidesData`).** + `ProvidesData` defines the projected shape for L2 and the widening check; `@key` defines identity within that shape. + For correctness `ProvidesData` on an entity fetch must include the `@key` fields. + See [provides.md](./provides.md). +- **`@requires` (exclusion).** + `@key` is the only field set that seeds the cache key. + `@requires` fields are **never** part of the key and **never** written into the cached entity shape, because they are request-derived, not entity-owned. + See [requires.md](./requires.md). +- **Root-field caching / `EntityKeyMapping`.** + A root-field fetch can be mapped onto **entity-key shape** via `EntityKeyMappings` so a root query (`user(id: "1")`) and an `_entities` fetch for `User{id:"1"}` share one L2 entry. + The derived key is rendered in the *same* `{"__typename":...,"key":{...}}` shape with the same number coercion, so identity matches the entity template byte-for-byte. + See [root-field-cache-config.md](./root-field-cache-config.md). +- **`@requestScoped`.** + Independent of `@key`; it keys on a directive-supplied string, not on entity identity. + See [request-scoped.md](./request-scoped.md). + +Dependency ordering ([02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) §3): `@key` is consumed by everything caching-related and must land first among the directive specs, right after the foundation. + +--- + +## 7. End-to-end test plan + +Tests live in two layers. +**Unit** key-rendering tests live under `v2/pkg/engine/resolve/` (extend `cache_key_test.go`). +**E2E** tests live under `execution/engine/` and run against the `accounts` / `products` / `reviews` federation services. +Before writing any test, re-read the relevant package `CLAUDE.md`. +All assertions follow the universal rules: `assert.Equal` on full values, **never** `Contains` / `GreaterOrEqual` / fuzzy comparisons, inline literal queries and keys, vertical multi-key struct literals, and every `ClearLog()` followed by `GetLog()` + full assertions. + +### 7.1 Unit — single `@key` field (identity) + +- **Template:** `EntityQueryCacheKeyTemplate{Keys: <Object with __typename + id>}`. +- **Data item:** `{"__typename":"User","id":"123"}`. +- **What is cached:** the rendered entity key for this `User`. +- **Assertion (exact, full value):** + +```go +expected := []*CacheKey{ + { + Item: data, + Keys: []string{`{"__typename":"User","key":{"id":"123"}}`}, // identity = @key field only + }, +} +assert.Equal(t, expected, cacheKeys) +``` + +### 7.2 Unit — composite `@key` + +- **Template fields:** `__typename`, `sku`, `upc`. +- **Data item:** `{"__typename":"Product","sku":"ABC123","upc":"DEF456","name":"Trilby"}`. +- **What is cached:** key includes **both** `@key` fields, drops the non-key `name`. +- **Assertion:** + +```go +assert.Equal(t, []string{`{"__typename":"Product","key":{"sku":"ABC123","upc":"DEF456"}}`}, cacheKeys[0].Keys) +``` + +### 7.3 Unit — number coercion (integer vs string identity collision) + +- **Two renders:** one item with integer `id` (`1`), one with string `id` (`"1"`). +- **What is cached:** both must produce the **same** key so they share one entry. +- **Assertions (one per line, with why):** + +```go +assert.Equal(t, []string{`{"__typename":"User","key":{"id":"1"}}`}, keysFromInteger[0].Keys) // integer coerced to string +assert.Equal(t, []string{`{"__typename":"User","key":{"id":"1"}}`}, keysFromString[0].Keys) // string stays string +assert.Equal(t, keysFromInteger[0].Keys, keysFromString[0].Keys) // collide on one entry +``` + +### 7.4 Unit — empty key is skipped + +- **Data item:** `{"__typename":"User","name":"Me"}` (no `@key` field selected). +- **What is cached:** nothing — the item is omitted from the returned keys. +- **Assertion (exact count):** + +```go +assert.Equal(t, 0, len(cacheKeys)) // @key field absent → item skipped, never keyed as {"key":{}} +``` + +### 7.5 Unit — subgraph header-hash prefix + +- **Same `User{id:"123"}` template, `prefix = "h1"`.** +- **What is cached:** key carries the prefix exactly once. +- **Assertion:** + +```go +assert.Equal(t, []string{`h1:{"__typename":"User","key":{"id":"123"}}`}, cacheKeys[0].Keys) // prefix isolates per subgraph header hash +``` + +### 7.6 E2E — entity L2 hit/miss across two requests (`accounts` / `reviews`) + +- **Setup (inline in the subtest):** a single in-memory `LoaderCache` with a cache log, gateway over `accounts` + `reviews`, `EnableL2Cache: true`. +- **Query (inline):** + +```graphql +{ me { id reviews { body product { upc } } } } +``` + +- **What is cached:** the `User` entity keyed `{"__typename":"User","key":{"id":"1234"}}` and the `Product` entity keyed `{"__typename":"Product","key":{"upc":"..."}}`, written by request 1. +- **Request 1** (cold): assert the cache log shows `get` (all miss) then `set` for the entity keys; clear log; assert; then re-clear. +- **Request 2** (warm): assert `get` returns hits for the same entity keys and the subgraph entity fetch is skipped. +- **Assertion style:** assert the **full** response JSON with `assert.Equal` on both requests (identical bytes), and assert the **full** cache log vertically, one key per line, with a trailing comment per event: + +```go +wantLog := []CacheLogEntry{ + { + Operation: "get", + Keys: []string{ + `{"__typename":"User","key":{"id":"1234"}}`, // request 1: cold, L2 empty + }, + Hits: []bool{false}, + }, + { + Operation: "set", + Keys: []string{ + `{"__typename":"User","key":{"id":"1234"}}`, // request 1 populates the User entity + }, + }, +} +assert.Equal(t, wantLog, defaultCache.GetLog()) +``` + +(The exact key set, including `Product` entries and any header-hash prefix, must be enumerated fully and inline once the concrete fixture is wired.) + +### 7.7 E2E — alias independence + +- **Two queries** selecting the same `User` once plain and once aliased: + +```graphql +{ me { id username } } +``` + +```graphql +{ me { ident: id handle: username } } +``` + +- **What is cached:** **one** L2 entry, because the key is derived from the `@key` *schema* name `id`, not the alias `ident`. +- **Assertion:** after request 1 (plain) populates, request 2 (aliased) must be a cache **hit** on the identical key: + +```go +assert.Equal(t, []string{`{"__typename":"User","key":{"id":"1234"}}`}, hitKeys) // alias did not change identity +``` + +### 7.8 E2E — L1 dedup within one request (same entity twice) + +- **Query** that resolves the same `User` along two fetch paths within a single request (for example `me` and a `reviews.author` that resolves to the same user id). +- **What is cached:** L1 holds `User{id:"1234"}` after the first fetch; the second entity fetch is skipped (`cacheSkipFetch`). +- **Assertion:** assert the **full** response JSON with `assert.Equal`, and assert the subgraph was called for the `User` entity **exactly once** (exact integer, no `GreaterOrEqual`): + +```go +assert.Equal(t, 1, accountsEntityCalls) // L1 deduped the second User fetch within the request +``` + +--- + +## 8. Acceptance criteria + +A reviewer can verify the `@key` consumption is correct against this checklist. + +- [ ] The planner extracts `@key` fields into a `KeyField` tree that excludes `__typename` and preserves nesting (flat, composite, nested-object, array forms all represented). +- [ ] Entity fetches receive an `EntityQueryCacheKeyTemplate` whose `Keys` contains **only** `@key` fields — no `@requires` fields, no arbitrary selected fields. +- [ ] `RenderCacheKeys` produces the exact shape `{"__typename":<T>,"key":{<key fields>}}`, byte-identical across requests for the same entity. +- [ ] `__typename` falls back to the template's plan-time `TypeName` when absent from item data. +- [ ] Numeric key values are coerced to strings at flat, composite, and nested levels, so integer and string identities collide on one entry. +- [ ] Items whose `@key` fields are absent produce an empty key object and are **skipped** (never keyed as `{"key":{}}`). +- [ ] Keys are alias-independent — aliasing a key field does not change the rendered key. +- [ ] The subgraph header-hash prefix (and the full transform pipeline `GlobalCacheKeyPrefix → header-hash → L2CacheKeyInterceptor`) is applied identically on read, write, and delete. +- [ ] Both L1 and L2 key entities on `@key` fields **only**; query selection does not widen the key. +- [ ] Entity L1 uses **passthrough** projection so `@key` fields survive into the L1 entry even when not in `ProvidesData`; the StructuralCopy write/read/merge invariants of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3 hold. +- [ ] Non-resolvable `@key` types do not become independent entity-fetch cache targets in the declaring subgraph. +- [ ] L1 deduplicates repeated same-entity fetches within one request; L2 deduplicates the same entity across requests (verified by exact subgraph call counts and full-value response assertions). +- [ ] All tests use `assert.Equal` on full values, inline literal keys/queries, vertical multi-key log literals, and clear-then-assert cache logs. + +--- + +## 9. Cross-links + +- Decision record: [adr/0002-key.md](../adr/0002-key.md). +- Architecture seam, L1/L2 model, StructuralCopy invariants, cache-key model: [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). +- Directive taxonomy and PR mapping: [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). +- Sibling directive specs: [provides.md](./provides.md), [requires.md](./requires.md), [request-scoped.md](./request-scoped.md), [root-field-cache-config.md](./root-field-cache-config.md). diff --git a/docs/entity-caching/directives/mutation-cache-config.md b/docs/entity-caching/directives/mutation-cache-config.md new file mode 100644 index 0000000000..8cdddac973 --- /dev/null +++ b/docs/entity-caching/directives/mutation-cache-config.md @@ -0,0 +1,429 @@ +# Directive Specification: Mutation Cache Config + +> Part of the entity-caching re-implementation document set. +> Cross-links: +> [adr/0008-mutation-cache-config.md](../adr/0008-mutation-cache-config.md) (rationale), +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) (integration seam, L1/L2 model, copy invariants, cache-key model), +> [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) (directive taxonomy + PR mapping). +> Re-implementation PR: **gqtools PR 13 / PR-CACHE-INVALIDATION**. +> Depends on: `@key` (entity identity / cache keys), +> `@provides` (`ProvidesData` projection), +> the entity cache config and root-field cache config concepts, +> and the foundation StructuralCopy / arena invariants. + +--- + +## 0. Scope note: this is a configuration concept, not a wire directive + +"Mutation cache config" is **not** a GraphQL schema directive. +There is no SDL syntax a subgraph author writes. +It is a pair of Go configuration structs that the router synthesises from composition output and per-subgraph config, +then hands to the engine. +The two structs are surfaced to the router as `plan.MutationFieldCacheConfiguration` and `plan.MutationCacheInvalidationConfiguration`, +collapsed by the planner into a single per-fetch runtime struct, `MutationEntityImpactConfig`, +which the resolver consumes. +Throughout this document, "the mutation config" means that pair plus its runtime projection. + +--- + +## 1. Purpose & responsibility + +Mutations are the write side of caching, and their job is to keep the cache **correct after a change**, never to serve stale data. +The mutation cache config gives per-mutation control over two distinct behaviours. +First, **opt-in L2 population**: by default, entity fetches triggered by a mutation are forbidden from writing to L2, +so a mutation cannot accidentally repopulate a cache with data that may already be obsolete; +a single flag re-enables those writes (with an optional TTL override) for mutations whose returned entities are known-good. +Second, **invalidation on success**: after a mutation returns, the L2 entries for the entities it touched can be deleted (or, in the single-subgraph case, directly overwritten from the mutation payload), +so the next query reads fresh data. +A hard, non-negotiable rule underpins both: **mutations always skip L2 *reads*** — a mutation never serves a cached response, it always hits the subgraph for fresh truth. + +--- + +## 2. Configuration definition (Go config shapes) + +### 2.1 Router-facing config (what the router supplies, per subgraph) + +Supplied through `SubgraphCachingConfig` as two collections. + +L2 write control, per mutation field: + +```go +// plan.MutationFieldCacheConfiguration +type MutationFieldCacheConfiguration struct { + FieldName string // e.g. "addReview" + EnableEntityL2CachePopulation bool // false (default) = entity fetches under this mutation skip L2 writes + TTL time.Duration // 0 = use entity default TTL; otherwise overrides it for these writes +} +``` + +Invalidation control, per mutation field: + +```go +// plan.MutationCacheInvalidationConfiguration +type MutationCacheInvalidationConfiguration struct { + FieldName string // e.g. "updateUser" + EntityTypeName string // optional — inferred from the mutation return type when omitted +} +``` + +### 2.2 Runtime config (what the resolver sees, per fetch) + +The planner attaches one `MutationEntityImpactConfig` to the fetch's `FetchCacheConfiguration.MutationEntityImpactConfig` when a mutation field is configured for population and/or invalidation. +This is the only mutation shape the resolver code reads: + +```go +// resolve.MutationEntityImpactConfig (engine-internal) +type MutationEntityImpactConfig struct { + EntityTypeName string // "User" + KeyFields []KeyField // [{Name:"id"}] — the @key field set, supports composite + nested + CacheName string // named L2 cache instance + IncludeSubgraphHeaderPrefix bool // prefix the invalidation/populate key with the subgraph header hash + InvalidateCache bool // delete the L2 entry after the mutation + PopulateCache bool // write the L2 entry directly from the mutation payload + PopulateTTL time.Duration // TTL for the PopulateCache write (0 = backend default) +} +``` + +Two additional booleans live directly on `FetchCacheConfiguration` and drive the **propagated** L2-write gate (distinct from the direct populate path above): + +```go +// fields on resolve.FetchCacheConfiguration +EnableMutationL2CachePopulation bool // mutation root fetch: allow follow-up entity fetches to write L2 +MutationCacheTTLOverride time.Duration // TTL applied to those propagated writes (0 = entity default) +``` + +`InvalidateCache` and `PopulateCache` are mutually exclusive in practice — composition annotates a single mutation field with one or the other, never both. + +--- + +## 3. Composition rules & validation + +There is **no schema syntax**, so there is nothing for the federation composer to parse or validate at the SDL level. +The rules are entirely structural, enforced where the router builds the config and where the resolver consumes it. + +- **`FieldName` is mandatory.** + Both router-facing structs key on the mutation root field name. + A config with an empty `FieldName` can never be matched against a fetch's root field and silently does nothing. +- **`EntityTypeName` may be omitted** on the invalidation config. + When absent, it is inferred from the mutation's GraphQL return type. + Once resolved, the runtime `MutationEntityImpactConfig.EntityTypeName` is always populated. +- **`KeyFields` must be the entity's `@key` set**, not arbitrary selected fields. + Mutation keys are built from `@key` only, exactly like entity L1/L2 keys, so a deletion targets the same entry a query would read. + See [directives/key.md](key.md). +- **Default is conservative.** + With neither flag set, a mutation populates nothing and invalidates nothing; + it merely fetches fresh and skips L2 reads. + Re-implementers must preserve this default — silence is the safe state. + +--- + +## 4. Runtime semantics + +### 4.1 Where it acts in plan + +At plan time, when the planner walks a mutation operation and finds a configured mutation field, it: + +- sets `EnableMutationL2CachePopulation` and `MutationCacheTTLOverride` on the **root mutation fetch's** `FetchCacheConfiguration`, and +- attaches a `MutationEntityImpactConfig` (carrying `InvalidateCache` and/or `PopulateCache`, plus key fields and cache name) to the fetch whose response yields the entity. + +The planner does **not** touch any cache. +It only annotates fetches, consistent with §1 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). + +### 4.2 The L2-read skip (always, no opt-out) + +Before any fetch dispatch, the resolver checks the operation type. +For `OperationType == Mutation`, **L2 read is suppressed**: no `Get` is issued for the mutation root fetch. +This is unconditional and not configurable. +It is why every test in §7 asserts the cache log contains **no `get` entry** for a mutation. + +### 4.3 The L2-write gate (two paths) + +There are two ways a mutation can end up writing to L2, and they are deliberately separate. + +**Path A — propagated write (multi-subgraph mutation).** +A mutation often returns an entity stub (`{__typename, id}`) and a follow-up `_entities` fetch hydrates the remaining fields. +That follow-up fetch is a normal cacheable entity fetch. +The gate works by propagation: + +1. The root mutation fetch carries `EnableMutationL2CachePopulation` and `MutationCacheTTLOverride`. +2. When the resolver processes the mutation root fetch, it records these onto the Loader (`enableMutationL2CachePopulation` and the TTL override). +3. The follow-up entity fetch inherits those flags during `resolveSingle` propagation. +4. In `updateL2Cache`, the entity fetch writes to L2 **only if** the propagated flag is true and per-request `EnableL2Cache` is on. + - If `EnableMutationL2CachePopulation == false`: **no L2 write at all** (the entity fetch behaves as if uncached for writes). + - TTL for the write is `MutationCacheTTLOverride` when non-zero, else the entity's default TTL from its entity cache config. + +**Path B — direct populate (single-subgraph mutation).** +A single-subgraph mutation that returns the full entity has no follow-up fetch to inherit anything. +For these, composition sets `PopulateCache = true` (with `PopulateTTL`) on the `MutationEntityImpactConfig`. +After the mutation merges, `detectMutationEntityImpact` writes the entity payload **directly** to L2 under the entity cache key: + +- Gated on per-request `EnableL2Cache`; a disabled L2 means no write. +- The stored payload is the entity projected through `ProvidesData` (schema field names, provided fields only), not the raw response object. +- TTL is `PopulateTTL` (0 → backend default). + +### 4.4 Invalidation on success + +After the mutation response is merged into the response tree (Phase 4 of the parallel flow, or the equivalent point in `resolveSingle`), the resolver calls `detectMutationEntityImpact(result, info, responseData)`. +Step by step: + +1. **Guard conditions** (any failure returns `nil`, a no-op): + - the operation is not a mutation, + - `info` is nil, + - no `MutationEntityImpactConfig` is attached, + - there is no `caches` map, + - `ProvidesData` is nil, + - the response payload at the root field is not an object (or array of objects). +2. **Locate the entity object.** + `navigateProvidesDataToField(providesData, rootFieldName)` descends the mutation `ProvidesData` (`{updateUsername: {id, username}}`) to the inner entity `*Object`. + This is alias-aware: it uses the `ProvidesData` field shape, which carries the query's aliases. +3. **Build the entity key.** + `buildEntityKeyValue(entityData, keyFields)` extracts `@key` fields from the response into a key value; + `buildMutationEntityCacheKey(cfg, entityData, info)` renders the canonical key string and applies the full key transform pipeline (see §5). +4. **Act per flag:** + - `InvalidateCache == true` → issue `cache.Delete([key])`, and collect the key into the returned `deletedKeys` set. + - `PopulateCache == true` → write the entity payload to L2 (the Path B write of §4.3). + - Neither set, analytics off → **touch nothing** (the cache log stays empty). +5. **Arrays.** + When the mutation returns a list (`{deleteUsers: [{id},{id}]}`), every object item produces its own key, and all of them are invalidated/recorded. + Non-object items (null, scalars) in the list are skipped. +6. **Returned value.** + `detectMutationEntityImpact` returns the set of deleted keys (`map[string]struct{}`), so the surrounding flow can dedupe extension-driven deletes against mutation deletes (see §6). + +### 4.5 No mutation-time cache reads (even with analytics on) + +A subtle but load-bearing rule: even when analytics is enabled, mutation impact detection **must not read from L2**. +The `MutationEvent` it records always has `HadCachedValue = false`, `IsStale = false`, `CachedHash = 0`, and `CachedBytes = 0`, +because the resolver deliberately skips the cached-vs-fresh comparison rather than paying a read. +Only `FreshHash` / `FreshBytes` (derived from the mutation response) are populated. +The cache log for an analytics-enabled invalidation shows exactly one `delete` and no `get`. + +### 4.6 Ordering & threading constraints + +- All mutation cache work runs on the **main thread**, after merge, consistent with the seam in §5/§7 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). + No goroutine performs deletes, populates, or key construction. +- Invalidation runs **after** the response is merged, so the key is built from final, post-merge entity data. +- The delete-before-set dedupe (§6) runs against the keys `updateL2Cache` is about to write, so ordering between invalidation and L2 write is coordinated on the main thread. + +--- + +## 5. Cache key & data shape + +### 5.1 Key shape + +Mutation invalidation/populate keys are **entity keys**, identical in shape to those a query uses: + +```text +{"__typename":"User","key":{"id":"1234"}} +``` + +Built from `@key` fields only (`KeyFields` on the config), via `buildEntityKeyValue` + `buildMutationEntityCacheKey`. +This identity-stability is the whole point: the key a mutation deletes is byte-for-byte the key a prior query wrote. + +### 5.2 Key transform pipeline (must match reads/writes exactly) + +`buildMutationEntityCacheKey` applies the same pipeline as every other cache operation, in the same order (see §4 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md)): + +```text +GlobalCacheKeyPrefix → subgraph header-hash prefix → L2CacheKeyInterceptor +``` + +- With `IncludeSubgraphHeaderPrefix = true` and a header hash of `99887766` for subgraph `accounts`, the key becomes `99887766:{"__typename":"User","key":{"id":"1234"}}`. +- An `L2CacheKeyInterceptor` returning `"tenant-42:" + key` yields `tenant-42:{"__typename":"User","key":{"id":"1234"}}`. + +If a mutation built its key through a different pipeline than the original write, it would delete the wrong entry and leave stale data — so re-implementers must route mutation keys through the identical transform path. + +### 5.3 Data shape for populate writes + +The Path B populate write stores the **`ProvidesData` projection** of the entity, not the raw mutation payload. +That means schema field names and provided fields only — `structuralCopyNormalized` semantics, `Passthrough = false` (projection, not passthrough), per §3.4 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). +A mutation returning `{updateUsername:{id:"u-pop", username:"PopMe"}}` stores exactly `{"id":"u-pop","username":"PopMe"}` under the entity key. +For propagated writes (Path A), the follow-up entity fetch's normal L2 write rules apply (projection through its own `ProvidesData`), with only the TTL altered. + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation (StructuralCopy / arena).** + Populate writes serialise the projected entity to heap bytes (`MarshalToWithTransform`) before handing to the backend — the heap boundary of §3.4 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). + Deletes carry only key strings, no Value copies, so they are copy-free. +- **`@key`.** + Mutation keys are derived from `@key` fields exactly as entity keys are. + Composite keys (`{id, orgId}`) and nested keys (`{key:{subId}}`) are supported by `buildEntityKeyValue`. + See [directives/key.md](key.md). +- **`@provides`.** + `navigateProvidesDataToField` and the populate projection both consume the fetch's `ProvidesData *Object`, the alias-aware shape introduced by `@provides`. + A nil `ProvidesData` short-circuits the whole impact path. + See [directives/provides.md](provides.md). +- **Entity cache config.** + The default TTL used by Path A propagated writes (when `MutationCacheTTLOverride == 0`) comes from the entity's cache config. + See [directives/entity-cache-config.md](entity-cache-config.md). +- **Root-field cache config.** + Mutations may also invalidate root-field-mapped entries; the key still goes through the same pipeline. + See [directives/root-field-cache-config.md](root-field-cache-config.md). +- **Extension-based invalidation (sibling mechanism).** + Subgraphs can also return `extensions.cacheInvalidation.keys`. + This shares the dedupe logic: if a key being invalidated is **the same key `updateL2Cache` is about to write**, the delete is **skipped** (redundant). + The dedupe is per-key, not all-or-nothing, and operates on the post-transform key, so it holds under header prefixes and interceptors. + Mutation `detectMutationEntityImpact` returns its deleted-keys set precisely so the two invalidation sources can coordinate. +- **Seam compliance.** + The entire feature rides the two-boolean merge contract (`cacheSkipFetch`, `cacheMustBeUpdated`) and the additive `MutationEntityImpactConfig` on the per-fetch result, per §7 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). + `mergeResult` keeps its shape; mutation logic slots in as a post-merge call. + +--- + +## 7. End-to-end test plan + +These are the behaviours a reviewer must see proven. +Unit-level cases (under `v2/pkg/engine/resolve/`) belong in `mutation_cache_test.go` and `extensions_cache_invalidation_test.go`; +E2E cases (under `execution/engine/`) use the federation services `accounts` / `products` / `reviews` and must follow the self-contained-subtest rule. + +**Assertion style is mandatory for every case below:** +- `assert.Equal` on the **full** value (response string, full struct, full cache log) — never `Contains`, `GreaterOrEqual`, `Greater`, or any fuzzy comparison. +- Inline every literal (query, cache key, TTL, expected JSON) at the assertion site. +- Cache-log struct literals: **one item per line**, vertical, with a trailing comment per entry explaining *why* it occurred. +- Every `ClearLog()` must be followed by `GetLog()` + full assertions before the next clear or end of test. +- `MutationEvent` slices: assert the **entire** slice with every field of every event populated inline. + +### Case 1 — mutation always skips L2 reads (AC-MUT-01) + +- **Query:** `mutation { updateUser(id:"u1", name:"Alice") { __typename id } }` followed by a hydrating `_entities` fetch returning `{name:"Alice"}`. +- **What is cached:** nothing read; the follow-up entity write depends on the population flag (see Case 2/4). +- **Assertion:** the cache log contains **no `get`** entry; assert the full log slice, e.g. for the disabled-population variant: + ```go + assert.Equal(t, []CacheLogEntry{}, cache.GetLog()) // mutation skips L2 reads; population disabled → no writes either + ``` + and the merged response: `assert.Equal(t, `{"data":{"updateUser":{"__typename":"User","id":"u1","name":"Carol"}}}`, out)`. + +### Case 2 — propagated L2 write uses TTL override + +- **Query:** mutation as in Case 1, with `EnableMutationL2CachePopulation = true`, `MutationCacheTTLOverride = 60s`, entity default `300s`. +- **What is cached:** the hydrated entity, written by the follow-up fetch under `{"__typename":"User","key":{"id":"u1"}}`. +- **Assertion (full log, vertical, commented):** + ```go + assert.Equal(t, []CacheLogEntry{ + { + Operation: "set", + Items: []CacheLogItem{ + {Key: `{"__typename":"User","key":{"id":"u1"}}`, TTL: 60 * time.Second}, // mutation TTL override (60s) beats entity default (300s); no prior get + }, + }, + }, cache.GetLog()) + ``` + +### Case 3 — propagated L2 write falls back to entity default TTL when override is 0 + +- **Query:** as Case 2 but `MutationCacheTTLOverride = 0`. +- **Assertion:** identical log shape, but `TTL: 300 * time.Second` with a comment noting the entity-default fallback. + +### Case 4 — population disabled writes nothing + +- **Query:** as Case 2 but `EnableMutationL2CachePopulation = false` (override irrelevant). +- **Assertion:** `assert.Equal(t, []CacheLogEntry{}, cache.GetLog())` with a comment: mutation skips both reads and writes when population is off. + +### Case 5 — invalidate deletes the entity entry and returns the deleted key + +- **Setup:** pre-populate `{"__typename":"User","key":{"id":"1234"}}` → `{"id":"1234","username":"OldMe"}`, then `ClearLog()`. +- **Query:** `mutation { updateUsername(id:"1234", username:"NewMe") { id username } }`, config `InvalidateCache = true`. +- **Assertions:** + ```go + assert.Equal(t, map[string]struct{}{`{"__typename":"User","key":{"id":"1234"}}`: {}}, deletedKeys) + entries, _ := cache.Get(ctx, []string{`{"__typename":"User","key":{"id":"1234"}}`}) + assert.Nil(t, entries[0]) // entry deleted by mutation invalidation + ``` + +### Case 6 — direct populate write (single-subgraph mutation, `PopulateCache`) + +- **Query:** `mutation { updateUsername(id:"u-pop", username:"PopMe") { id username } }`, config `PopulateCache = true`, `PopulateTTL = 60s`, `EnableL2Cache = true`. +- **Assertion:** the projected payload is written under the entity key: + ```go + entries, _ := cache.Get(ctx, []string{`{"__typename":"User","key":{"id":"u-pop"}}`}) + assert.Equal(t, `{"id":"u-pop","username":"PopMe"}`, string(entries[0].Value)) // ProvidesData projection, not raw payload + ``` + Variant with `EnableL2Cache = false` must leave `entries[0]` nil. + +### Case 7 — array mutation invalidates every entity in the list + +- **Setup:** pre-populate `User:1` and `User:2`. +- **Query:** `mutation { deleteUsers { id username } }` returning `[{id:"1"},{id:"2"}]`, `InvalidateCache = true`. +- **Assertions:** + ```go + assert.Equal(t, map[string]struct{}{ + `{"__typename":"User","key":{"id":"1"}}`: {}, + `{"__typename":"User","key":{"id":"2"}}`: {}, + }, deletedKeys) + ``` + Non-object items (`null`, `"invalid"`) in the list are skipped — assert only the valid keys appear. + +### Case 8 — composite & nested keys, and key transforms + +- **Composite:** `KeyFields = [{id},{orgId}]` → key `{"__typename":"User","key":{"id":"1","orgId":"acme"}}` (assert the exact string). +- **Header prefix:** `IncludeSubgraphHeaderPrefix = true`, hash `99887766` → `99887766:{"__typename":"User","key":{"id":"1234"}}`. +- **Interceptor:** returning `"tenant-42:" + key` → `tenant-42:{"__typename":"User","key":{"id":"1234"}}`. +- Assert each rendered key string exactly via `buildMutationEntityCacheKey`. + +### Case 9 — analytics records a mutation event without reading the cache + +- **Setup:** pre-populate `User:1234` (stale and fresh variants), `ClearLog()`, analytics on. +- **Assertion (full event + full log):** + ```go + stats := ctx.GetCacheStats() + assert.Equal(t, []MutationEvent{ + { + MutationRootField: "updateUsername", + EntityType: "User", + EntityCacheKey: `{"__typename":"User","key":{"id":"1234"}}`, // display key, no prefix + HadCachedValue: false, // mutation impact never issues an L2 get + IsStale: false, // no cached-vs-fresh comparison performed + CachedHash: 0, // no cached value read + CachedBytes: 0, + // FreshHash / FreshBytes are non-zero (derived from the mutation payload) + }, + }, stats.MutationEvents) + assert.Equal(t, []CacheLogEntry{ + {Operation: "delete", Items: []CacheLogItem{{Key: `{"__typename":"User","key":{"id":"1234"}}`}}}, // exactly one delete, no get + }, cache.GetLog()) + ``` + (Where `FreshHash`/`FreshBytes` are non-deterministic, assert them as non-zero via a separate `assert.NotEqual(t, uint64(0), ...)` line, never folded into a `Contains`.) + +### Case 10 — extension/mutation delete dedupe against `updateL2Cache` + +- **Same-key skip:** an entity fetched and invalidated in the same response (`User:1`) → the delete is **skipped** because `updateL2Cache` will set the same key. + Assert `cache.GetLog()` contains no `delete`. +- **Different-key delete:** invalidate `User:1` (skipped) and `User:2` (different) → exactly one delete for `User:2`. + Assert the full delete-key slice equals `[]string{`{"__typename":"User","key":{"id":"2"}}`}`. +- **Holds under transforms:** repeat the same-key skip with header prefix and with an interceptor — the dedupe must still skip, because both the invalidation key and the set key pass through the identical transform pipeline. +- **Interceptor metadata:** assert the interceptor is invoked with the exact `L2CacheKeyInterceptorInfo{SubgraphName:"accounts", CacheName:"default"}` for both the L2 set and the invalidation key construction. + +### Case 11 — E2E mutation invalidation (federation services) + +- **Service:** `accounts` (`User` entity, `@key(fields:"id")`), `reviews` (`addReview` mutation). +- **Flow (self-contained subtest, inline setup, inline queries via `QueryStringWithHeaders`):** + 1. Query `{ me { id username } }` → populates L2 for `User:1`. + 2. Mutation `mutation { updateUsername(username:"Renamed") { id username } }` configured with `InvalidateCache = true`. + 3. Query `{ me { id username } }` again → L2 miss for `User:1` (it was invalidated), subgraph hit, fresh value. +- **Assertions:** full response strings for all three operations (`assert.Equal`), plus the full cache log per phase with `ClearLog()` + `GetLog()` between phases and a `why` comment on every entry. + +--- + +## 8. Acceptance criteria + +A reviewer can check off the following: + +- [ ] Mutations **never** issue an L2 `Get` — verified by an empty/`get`-free cache log on every mutation path (AC-MUT-01). +- [ ] With `EnableEntityL2CachePopulation = false` (default), no follow-up entity fetch writes to L2 during a mutation. +- [ ] With `EnableEntityL2CachePopulation = true`, the follow-up entity fetch writes to L2 under the correct entity key. +- [ ] `MutationCacheTTLOverride` (non-zero) is used for propagated writes; a zero override falls back to the entity's default TTL. +- [ ] `PopulateCache = true` writes the **`ProvidesData`-projected** entity payload directly to L2 under the entity key, gated on per-request `EnableL2Cache`. +- [ ] `PopulateCache = true` with `EnableL2Cache = false` writes nothing. +- [ ] `InvalidateCache = true` deletes the L2 entry and returns the deleted key set; `InvalidateCache = false` deletes nothing. +- [ ] Array mutation responses invalidate/populate **every** object item; non-object items are skipped. +- [ ] Composite and nested `@key` fields produce correct, exact key strings. +- [ ] The key transform pipeline (`GlobalCacheKeyPrefix → header-hash prefix → L2CacheKeyInterceptor`) is applied identically to mutation keys as to read/write keys. +- [ ] Mutation impact detection **never reads** the cache, even with analytics enabled: `MutationEvent.HadCachedValue == false`, `CachedHash == 0`, `CachedBytes == 0`. +- [ ] A `MutationEvent` is recorded for each impacted entity when analytics is on; none is recorded when the delete is deduped away. +- [ ] Delete-before-set dedupe skips a delete when `updateL2Cache` is about to write the same post-transform key; it deletes different keys; it holds under header prefix and interceptor. +- [ ] All guard conditions (non-mutation op, nil info, no impact config, no caches map, nil `ProvidesData`, non-object payload) return a no-op `nil` and touch nothing. +- [ ] The default state (neither flag set, analytics off) touches the cache **zero** times. +- [ ] All cache work runs on the main thread, after merge; no goroutine performs deletes, populates, or key construction. +- [ ] Existing `loader` / `resolvable` flow is unchanged in shape — mutation logic is a post-merge call honoring the two-boolean seam (per [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §10). +- [ ] Tests use `assert.Equal` on full values, inline literals, vertical multi-item cache-log literals with per-entry `why` comments, and `ClearLog()` always paired with `GetLog()` assertions. diff --git a/docs/entity-caching/directives/provides.md b/docs/entity-caching/directives/provides.md new file mode 100644 index 0000000000..b22069f2e5 --- /dev/null +++ b/docs/entity-caching/directives/provides.md @@ -0,0 +1,290 @@ +# Directive Specification: `@provides` + +> Part of the entity-caching re-implementation document set. +> See [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) for the full directive table, +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) for the integration seam and the L1/L2 model, +> and [adr/0004-provides.md](../adr/0004-provides.md) for the decision record behind this contract. +> +> Re-implementation PR: **gqtools PR 5 / PR-CACHE-PROJECTION** (see [03-PR-PLAN-graphql-go-tools.md](../03-PR-PLAN-graphql-go-tools.md)). +> +> Reader assumption: you have never seen this feature. +> This document explains what `@provides` means, what the caching layer reads from it, and how to verify the behavior. + +--- + +## 1. Purpose & responsibility + +`@provides` is a standard GraphQL Federation directive on a field definition. +It declares that, when a subgraph returns a particular field, it can *also* return some extra fields of the referenced entity inline, without a separate entity fetch. +The classic example is `Review.author: User! @provides(fields: "username")`: the reviews subgraph normally only knows a `User`'s `id`, but on the `author` edge it promises to also return `username` inline. +The entity-caching layer never *defines* or *enforces* federation resolution from `@provides`; that is the planner's job. +What the caching layer does is **consume the shape that `@provides` (together with the query selection at a fetch) produces** — the per-fetch `ProvidesData *Object` — and use it for three things: to **project** the value stored in L2 down to exactly the fields a fetch owns, to run the **field-widening check** that stops a narrow cached value from satisfying a wider query, and to **re-apply aliases** (denormalize) when a cached value is read back into the active response tree. +In one sentence: `@provides` is the source of the *cache shape*, and `ProvidesData` is the data structure that carries that shape from planner to resolver. + +--- + +## 2. SDL / configuration definition + +### 2.1 The wire directive (federation SDL) + +```graphql +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +``` + +It appears on the field that returns the entity, naming a `_FieldSet` over the *returned* type: + +```graphql +type Review { + body: String! + author: User! @provides(fields: "username") # reviews can return User.username inline here +} +``` + +`@provides` is *not* a caching configuration struct. +The caching layer never reads the directive text directly — it reads the data shape the planner derives from the directive plus the operation's selection set. + +### 2.2 The data shape the caching layer actually consumes: `ProvidesData *Object` + +Every fetch carries a `FetchInfo` whose `ProvidesData` field is the alias-aware description of the exact shape the fetch yields at its location in the response. +It is a `*resolve.Object` — the same node type used by the response plan tree — so it is naturally recursive (objects within objects, arrays of objects). +Tiny signature, set by the planner visitor: + +```go +// v2/pkg/engine/plan/visitor.go (configureFetchObject) +singleFetch.Info.ProvidesData = providesData // = v.caching.plannerObjects[fetchID] +resolve.ComputeHasAliases(providesData) // sets Object.HasAliases recursively +``` + +Two flags on the `*Object` are load-bearing for caching: + +- `HasAliases` — true when any field in the (sub)tree is aliased (output name differs from schema name). + Computed once at plan time via `ComputeHasAliases`. + When false, the resolver takes a fast path (plain `StructuralCopy`, no Transform). +- `Fields []*Field` — each field carries `Name` (response key / alias), `OriginalName` (schema name, nil when not aliased), `Value Node` (nested shape), and `CacheArgs` (field arguments that contribute an xxhash suffix to the cache field name). + +`ProvidesData` is `nil` when the planner runs with `DisableFetchProvidesData = true` (test/programmatic construction). +Production planners always populate it. +The resolver treats a `nil` `ProvidesData` as "no projection, no widening check" — see §4.5. + +**Critical placement rule (carried over verbatim):** for a nested *entity* fetch, `ProvidesData` must contain the **entity** fields (`id`, `username`), NOT the parent edge field (`author`). +The planner rewrites `FieldName` / `FieldPath` so the object describes the entity at the fetch boundary, not the field that pointed at it. + +--- + +## 3. Composition rules & validation + +`@provides` is validated by federation composition, *upstream* of everything in this repository. +The caching layer adds **no** new composition rules for it. +The rules the re-implementation must assume are already enforced: + +- `@provides` is allowed only on `FIELD_DEFINITION`. +- `fields` is a **mandatory** `_FieldSet` parsed against the *returned* type of the field (here, `User`), not the enclosing type (`Review`). +- The named fields must exist on the returned entity type and the subgraph must actually be able to resolve them inline; otherwise composition fails. +- `@provides` fields are typically `@external` on the returned type from the providing subgraph's perspective (the field is *owned* elsewhere, *provided* here) — for example `User.username` is `@external` in the reviews subgraph but `@provides`d on `Review.author`. + +The caching layer's only contract with composition is read-only: it consumes the *planned* selection shape, and trusts that the planner already honored `@provides` when it decided whether a fetch is needed at all. + +--- + +## 4. Runtime semantics + +This section walks where `ProvidesData` is read in the planner and across the four-phase resolve flow described in [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §5. +The governing thread rule still holds: **all of this runs on the main thread**; goroutines do subgraph HTTP only and never touch `ProvidesData`, the arena, or any cache. + +### 4.1 Plan time — building `ProvidesData` + +The planner builds a `*Object` for each fetch (`plannerObjects[fetchID]`) describing the selection shape at that fetch's location, honoring `@provides` (so a `@provides`d field becomes part of the parent fetch's shape rather than forcing a child entity fetch). +At `configureFetchObject` it assigns this object to `FetchInfo.ProvidesData` and calls `ComputeHasAliases`. +For a nested entity fetch the object is rewritten so it describes the *entity* shape (`{id, username}`), not the edge (`author`). +No cache is touched at plan time. + +### 4.2 Phase 1 / 2 — L1 read and bulk L2 read (main thread) + +When a cached value is a *candidate* for satisfying a fetch, the resolver validates it against `ProvidesData` before serving it. + +- **L1 read** (`tryL1CacheLoad`): a stored entity value is read back with `structuralCopyDenormalizedPassthrough(stored, ProvidesData)` — restores aliases for the current query *and keeps all accumulated fields*. + The hit is only honored if the value satisfies the widening check (§4.4). +- **L2 read** (`bulkL2Lookup` → `applyEntityFetchL2Results` / `applyRootFetchL2Results`): a parsed L2 entry is validated against `ProvidesData` via `resolveMultiCandidateCacheValue` → `validateItemHasRequiredData`. + If it covers all required fields, the value is denormalized with `structuralCopyDenormalized(FromCache, ProvidesData)` (projected read) and the fetch is marked `cacheSkipFetch`. + +### 4.3 Phase 4 — cache writes (main thread) + +After a fetch returns and is merged into the response tree, both caches are populated, and `ProvidesData` drives the shape of what is stored: + +- **L1 write** (`populateL1Cache`): `structuralCopyNormalizedPassthrough(value, ProvidesData)`. + Renames aliases → schema names but **keeps all source fields** (`Transform.Passthrough = true`), including `@key` fields not listed in `ProvidesData` and fields contributed by sibling fetches. + L1 entries grow over a request — passthrough is what lets them accumulate. +- **L2 write** (`updateL2Cache`): non-passthrough projection. + Renames *and* drops everything not in `ProvidesData`, then serializes with `MarshalToWithTransform`. + L2 entries are minimal and self-contained so they round-trip cleanly across requests. + +### 4.4 The field-widening check + +`validateItemHasRequiredData(item, ProvidesData)` walks every field in the `*Object` and requires the cached value to contain each one (by `cacheFieldName`, i.e. schema name + arg suffix), recursing into nested objects and arrays. +A field that is **missing** fails the check even if it is nullable — present-but-null is acceptable, absent is not. +This is the guard that stops a narrow root query (`{ user { id name } }`) from poisoning the cache for a wider entity fetch (`{ user { id name email } }`): the narrow cached value lacks `email`, fails the check, and the wider fetch proceeds to the subgraph instead of serving stale-shape data. + +### 4.5 Alias / normalization handling + +`ProvidesData` is alias-aware end to end: + +- **Normalize** (write side, `buildNormalizeTransform`): `InputKey = alias`, `OutputKey = cacheFieldName` (schema name + optional arg-hash suffix). + Cache storage always uses schema field names, so the same entity produces the same stored shape regardless of how a query aliased it. +- **Denormalize** (read side, `buildDenormalizeTransform`): the inverse — `InputKey = cacheFieldName`, `OutputKey = alias` — re-applies the *current* query's aliases when serving a cached value into the response tree. +- **`__typename`** is force-added as an identity entry when the selection set omits it, so polymorphic type identity survives projection. +- **Fast path**: when `ProvidesData.HasAliases == false`, all four `structuralCopy*` helpers skip the Transform and fall back to plain `StructuralCopy` (containers cloned, leaves aliased) — no rename, full passthrough. + +### 4.6 Ordering / threading constraints + +- Transforms derived from `ProvidesData` are **ephemeral**: built inline on the Loader's reusable `transformEntries` / `transforms` / `transformMetas` slabs and consumed by `StructuralCopyWithTransform` / `MarshalToWithTransform` in the same call. + They depend on per-request state (`Variables`, `RemapVariables` feeding `CacheArgs` arg-hash suffixes) and must **never** be cached on the `*Object`, the plan tree, the `Resolver`, or anything that outlives a request. +- `ProvidesData` itself is a shared planner `*Object` and is read-only at resolve time. + All reads happen on the main thread; goroutines never see it. + +--- + +## 5. Cache key & data shape + +`@provides` / `ProvidesData` affects the **stored shape**, not the **cache key**. + +- **Cache key**: keys are derived from `@key` fields only (see [key.md](key.md)), never from `@provides` fields or arbitrary selected fields. + A `User` keyed by `id` produces `{"__typename":"User","key":{"id":"123"}}` whether or not `username` was provided. + This keeps entity identity stable across queries that select different field subsets. +- **Stored shape — the passthrough vs projection switch** (the single `Transform.Passthrough` flag from [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3.4): + + | Tier | Helper | `Passthrough` | Result | + |------|--------|---------------|--------| + | **L1 write** | `structuralCopyNormalizedPassthrough` | `true` | rename aliases → schema names, **keep** all fields incl. `@key` and sibling-contributed fields; entry accumulates over the request | + | **L1 read** | `structuralCopyDenormalizedPassthrough` | `true` | restore aliases, **keep** all accumulated fields | + | **L2 write** | `structuralCopyNormalized` (then `MarshalToWithTransform`) | `false` | rename **and project** to exactly `ProvidesData` fields, drop the rest; minimal, self-contained | + | **L2 read** | `structuralCopyDenormalized` | `false` | restore aliases, projected to `ProvidesData` | + +- **Interaction with `@requires`** (exclusion): `@requires` fields are request-derived inputs, not entity-owned, and must **never** appear in `ProvidesData` and therefore never in the projected/stored shape. + See [requires.md](requires.md) — `@requires` is precisely "the fields excluded from the shape that `@provides` defines". + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation** ([01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3, [adr/0001-foundation.md](../adr/0001-foundation.md)): every projection and denormalize uses the `StructuralCopy` / `StructuralCopyWithTransform` primitives and the `Transform { Entries, Forced, Passthrough }` shape. + `ProvidesData` is the input that builds those Transforms. + The copy budget (Architecture §3.3, resolve `CLAUDE.md` "Copy Budget") counts exactly one copy per cache write and one per cache read driven by `ProvidesData`; the re-implementation must not add or drop copies here. +- **`@key`** ([key.md](key.md)): supplies the cache key; L1 passthrough deliberately *retains* `@key` fields even when they are not in `ProvidesData`, because a later wider entity fetch needs the key present to merge. + `@provides` and `@key` are complementary: `@key` is identity, `@provides` is shape. + `@provides` therefore **depends on** `@key` being correct first (see the dependency chain in [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) §3). +- **`@requires`** ([requires.md](requires.md)): the exclusion rule above; specified *after* `@provides` because it is defined as "removed from the `ProvidesData` shape". +- **`@requestScoped`** ([request-scoped.md](request-scoped.md)): reuses `ProvidesData` for its widening check (`validateItemHasRequiredData`) and the same normalize/denormalize copy pipeline. + `populateRequestScopedFieldsProvidesData` locates each request-scoped field's sub-`Object` in the planner tree and attaches it as that field's `ProvidesData`. + So `@requestScoped` builds directly on this directive's machinery. +- **Mutation / subscription / config concepts**: all rely on `ProvidesData` to project what they write to L2 (`structuralCopyProjected` for shadow comparison and mutation analytics navigates `ProvidesData` to the entity sub-object via `navigateProvidesDataToField`). + +--- + +## 7. End-to-end test plan + +These cases run against the federation test services in `execution/federationtesting/` (`accounts`, `products`, `reviews`). +The schemas already contain a real `@provides`: in **reviews**, `Review.author: User! @provides(fields: "username")`, with a sibling `Review.authorWithoutProvides: User!` that forces an entity fetch to **accounts** for `username`. +`User @key(fields: "id")` lives in both subgraphs; `username` is `@external` in reviews and owned by accounts. + +> Test-style requirements (from the repo and `execution/engine/CLAUDE.md`), mandatory for every case below: +> - **`assert.Equal` on full values only** — never `Contains`, `GreaterOrEqual`, `Greater`, or any fuzzy/substring comparison. +> - **Inline literals** — GraphQL queries, expected JSON responses, and cache keys appear inline at the assertion/setup site, never in shared `const`/`var` blocks or shared helpers. +> - **Self-contained subtests** — each `t.Run` sets up its own cache instances, gateway options, context, and URLs inline; duplication across subtests is preferred over shared helpers. +> - **Cache-log discipline** — every `cache.ClearLog()` is followed by `cache.GetLog()` + full assertions before the next clear or end of test. +> - **Vertical multi-key literals** — cache-log `Keys`/`Hits` and snapshot event lists wrap one item per line, each with a trailing comment explaining *why* that event occurred. + +### Case 1 — `@provides` satisfies the query inline (no entity fetch, nothing entity-cached for User) + +- **Query** (inline): `query { topReviews { body author { username } } }` +- **Expectation**: because reviews `@provides`d `username` on `author`, the planner does **not** emit a `User` entity fetch to accounts. +- **What is cached**: the reviews root-field fetch may be L2-cached (its `ProvidesData` includes `author { username }` inline), but there is **no** `User` entity L2 entry — accounts is never called. +- **Assertions**: + - `assert.Equal` on the full response JSON, e.g. ``assert.Equal(t, `{"data":{"topReviews":[{"body":"A highly effective form of birth control.","author":{"username":"Me"}}]}}`, string(out))`` (exact value confirmed against fixtures before committing). + - `assert.Equal(t, 0, accountsCalls)` — accounts never queried because `@provides` covered `username`. + - Cache log: assert the reviews-fetch `get`/`set` keys exactly, one key per line, each with a why-comment (`// reviews root field, first request → miss then set`). + +### Case 2 — `authorWithoutProvides` forces an entity fetch and a `User` cache entry + +- **Query** (inline): `query { topReviews { body authorWithoutProvides { username } } }` +- **Expectation**: no `@provides`, so the gateway resolves `User.username` via an entity fetch to accounts. +- **What is cached**: a `User` entity entry projected to `ProvidesData = {id, username}`. +- **Assertions**: + - Full-response `assert.Equal` on the JSON. + - `assert.Equal(t, 1, accountsCalls)` on the first request — entity fetch happened. + - Cache log (vertical, one key per line): + ```go + wantSet := []resolve.CacheLogEntry{ + { + Operation: "set", + Keys: []string{ + `{"__typename":"User","key":{"id":"1234"}}`, // entity User fetched from accounts, projected to {id,username} + }, + }, + } + ``` + (substitute the real key bytes once verified). + - Second identical request: `assert.Equal(t, 0, accountsCalls)` (served from L2) and a `get` log entry with `Hits: []bool{true}`. + +### Case 3 — projection: L2 stores only `ProvidesData` fields, not the whole entity + +- **Setup**: query selects `{ user(id:"1") { id username } }` against accounts, where `User` also has `nickname`, `realName`, etc. +- **What is cached**: the L2 entry must contain **only** `{id, username}` (plus forced `__typename`), proving non-passthrough projection. +- **Assertions**: + - Read the stored bytes from the cache log `set` and `assert.Equal` on the **exact** stored JSON, e.g. ``assert.Equal(t, `{"__typename":"User","id":"1234","username":"Me"}`, storedValue)``. + - Negative assertion is implicit in the full-equal: if `nickname`/`realName` leaked in, the exact-equal fails. + +### Case 4 — widening guard: a narrow cached entry does NOT satisfy a wider query + +- **Setup**: Request A caches `User{id:"1"}` with shape `{id, username}`. + Request B asks `{ user(id:"1") { id username realName } }`. +- **Expectation**: the cached value lacks `realName`, `validateItemHasRequiredData` fails, Request B re-fetches from the subgraph rather than serving a value missing `realName`. +- **What is cached**: after Request B, the entry is rewritten with the wider shape `{id, username, realName}`. +- **Assertions**: + - Request B: `assert.Equal(t, 1, accountsCalls)` — widening miss forced a fetch. + - Cache log for Request B: `get` with `Hits: []bool{false}` (widening miss, NOT a key miss), then `set` rewriting the wider shape; assert both, one key per line with why-comments. + - Full-response `assert.Equal` includes `realName`. + +### Case 5 — alias-aware projection round-trip + +- **Query** (inline): `query { user(id:"1") { uid: id name: username } }` +- **Expectation**: stored shape is normalized to schema names (`{id, username}`); the served response re-applies the query aliases (`uid`, `name`). +- **Assertions**: + - `assert.Equal` on the stored bytes uses **schema names**: ``assert.Equal(t, `{"__typename":"User","id":"1234","username":"Me"}`, storedValue)``. + - `assert.Equal` on the response uses **aliases**: ``assert.Equal(t, `{"data":{"user":{"uid":"1234","name":"Me"}}}`, string(out))``. + - A second request with *different* aliases (`{ user(id:"1") { x: id y: username } }`) is an L2 **hit** on the same key (key is alias-independent): cache-log `get` `Hits: []bool{true}`, `assert.Equal(t, 0, accountsCalls)`. + +### Case 6 — `nil ProvidesData` short-circuits projection (defense-in-depth) + +- **Setup**: a resolve-package unit test constructs a fetch with `DisableFetchProvidesData` / `ProvidesData == nil`. +- **Expectation**: cache write/read fall back to plain `StructuralCopy`; root-field L1 promotion is silently skipped (never stores aliased response-shape values). +- **Assertions**: + - `assert.Equal` that the promoted/stored value is absent (no L1 entry created) — assert the L1 map state explicitly, not via a fuzzy check. + +--- + +## 8. Acceptance criteria + +A reviewer can check each item against the implementation and the tests: + +- [ ] `@provides` is consumed read-only; the caching layer adds no composition rules for it. +- [ ] Each fetch's `FetchInfo.ProvidesData` is a `*resolve.Object` describing the **entity** shape at the fetch boundary (entity fields, not the parent edge field), with `HasAliases` computed at plan time. +- [ ] `ProvidesData == nil` is handled everywhere as "no projection, no widening, skip root-field L1 promotion" without panics. +- [ ] **Cache key is `@key`-only** — `@provides` fields never widen or alter the key; verified by Case 5's alias-independence and Case 3's projection. +- [ ] **L2 write projects** (non-passthrough): stored bytes contain exactly `ProvidesData` fields + forced `__typename`, nothing else (Case 3, exact-equal on stored bytes). +- [ ] **L1 write passes through** (`Passthrough = true`): `@key` fields and sibling-contributed fields are retained; entries accumulate over a request. +- [ ] **Widening check** (`validateItemHasRequiredData`) rejects a value missing any `ProvidesData` field (absent fails even if nullable; present-null passes) — Case 4. +- [ ] **Alias round-trip**: stored shape uses schema names; served value re-applies the current query's aliases; same entity under different aliases hits the same key — Case 5. +- [ ] **`@requires` exclusion**: `@requires` fields never appear in `ProvidesData` or the stored shape (cross-checked in [requires.md](requires.md)). +- [ ] Transforms built from `ProvidesData` are ephemeral (built on the reusable slabs, consumed in-call) and never cached beyond a request. +- [ ] All cache reads/writes driven by `ProvidesData` run on the main thread; goroutines never touch it. +- [ ] Copy budget unchanged: one `StructuralCopy`(+Transform) per cache write and one per cache read attributable to `ProvidesData`; adversarial mutation tests still pass. +- [ ] Every test uses `assert.Equal` on full values, inline literals, vertical multi-key cache-log literals with why-comments, and clear-then-assert log discipline. + +--- + +## Cross-links + +- Decision record: [adr/0004-provides.md](../adr/0004-provides.md) +- Architecture and integration seam: [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) +- Directive inventory and dependency ordering: [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) +- Sibling directive specs: [key.md](key.md), [requires.md](requires.md), [request-scoped.md](request-scoped.md) diff --git a/docs/entity-caching/directives/request-scoped.md b/docs/entity-caching/directives/request-scoped.md new file mode 100644 index 0000000000..ad3f9d9194 --- /dev/null +++ b/docs/entity-caching/directives/request-scoped.md @@ -0,0 +1,305 @@ +# Directive Specification: `@requestScoped` + +> Part of the entity-caching re-implementation document set. +> Cross-references: +> [adr/0005-request-scoped.md](../adr/0005-request-scoped.md) (decision record), +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) (foundation seam, L1/L2 model, StructuralCopy invariants), +> [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) (directive taxonomy + PR mapping). +> +> Re-implementation PR: **PR-REQUEST-SCOPED** (gqtools PR 17-20). +> Assume the reader has no prior knowledge of the feature. + +--- + +## 1. Purpose & responsibility + +`@requestScoped` is the **only new wire directive** this feature introduces. +It marks a field whose resolved value is identical for the entire request *within one subgraph*, +for example a `currentViewer` derived from the request's auth context. +When two or more fields in the same subgraph carry `@requestScoped(key: "X")`, +the engine maintains a tiny per-request **coordinate L1** cache so that whichever annotated field resolves first populates the value, +and every later field with the same key injects that value and **skips its own subgraph fetch**. +The model is purely symmetric: there is no provider/receiver distinction, +every annotated field is simultaneously a reader (it may inject from the coordinate L1) and a writer (it always exports to the coordinate L1 after resolving). +The coordinate L1 is a separate mechanism from entity L1/L2, +but it rides on the same `EnableL1Cache` per-request flag and the same StructuralCopy isolation primitives, +so it is treated as part of the L1 layer, not a third cache tier. + +--- + +## 2. SDL / configuration definition + +### Wire directive (subgraph SDL, composition-side) + +```graphql +directive @requestScoped(key: String!) on FIELD_DEFINITION +``` + +- Applies only to `FIELD_DEFINITION`. +- `key` is a mandatory non-null `String`. +- Repeated usage across fields with the same `key` in the same subgraph is the intended pattern (that is how two fields coordinate). + +### Planner-side metadata (one row per annotated field) + +Composition output is surfaced to the planner as a flat list on the subgraph's federation metadata. +The struct carries no shape, only identity: + +```go +type RequestScopedField struct { + FieldName string // e.g. "currentViewer" + TypeName string // enclosing type, e.g. "Query" or "Personalized" + L1Key string // "{subgraphName}.{key}", e.g. "accounts.viewer" +} +``` + +The `L1Key` is constructed once at composition/config time as `"{subgraphName}.{key}"`, +so it is alias-independent and subgraph-scoped by construction. +Lookups: +- `RequestScopedFieldsForType(typeName)` returns all annotated fields on a type (used by entity fetches). +- `RequestScopedExportsForField(typeName, fieldName)` returns the `L1Key`(s) for a single coordinate (used by root fetches and by the widening visitor). + +### Resolver-side carrier (per fetch) + +The planner emits one of these per participating field onto the fetch's cache configuration. +This is the entire on-the-wire surface between planner and resolver: + +```go +type RequestScopedField struct { + FieldName string // response key at the fetch location (alias if present, else schema name) + FieldPath []string // path in the response data, response-keyed, e.g. ["currentViewer"] + L1Key string // coordinate L1 key "{subgraphName}.{key}" + ProvidesData *Object // alias-aware value shape this fetch expects at FieldPath; nil ⇒ field not selected here +} +``` + +`RequestScopedFields []RequestScopedField` lives on `FetchCacheConfiguration` alongside the entity/root cache fields. +It is preserved through every caching-enabled planner path (plain fetch, entity-cache-enabled fetch, L2-enabled root fetch). + +--- + +## 3. Composition rules & validation + +Composition is the source of truth for the directive. +It enforces two rules: + +1. **`key` is mandatory.** + The directive definition declares `key: String!`, + so a `@requestScoped` with no `key` (or a null `key`) is a composition error. + +2. **A lone reader is a warning, not an error.** + When a `key` value appears on exactly **one** field within a subgraph, + composition emits a *warning*. + A single annotated field can never coordinate with a second field, + so the directive is meaningless in that subgraph (it does no harm, but it does nothing). + This is a warning rather than a hard error because the schema is still valid and composable. + +There is no schema-level constraint that the two fields share a return type; +type compatibility is checked at plan time (see §4, widening), not at composition time. + +--- + +## 4. Runtime semantics + +The directive acts at two stages: **plan** (annotate) and **resolve** (act). + +### 4.1 Plan stage + +1. **Datasource `ConfigureFetch` emits one `RequestScopedField` per annotated field** (symmetric — no reader/writer split): + - For **root-field fetches**, it iterates the query's root fields, + looks up `RequestScopedExportsForField(typeName, fieldName)`, + and emits a carrier whose `FieldName`/`FieldPath` use the field's response key (alias if present). + - For **entity fetches**, it iterates `RequestScopedFieldsForType(entityType)`, + plus — via `InterfaceObjects` config — the interface types the concrete entity implements + (so a directive declared on interface `Personalized` is found for concrete entity `Article`). + Entries are de-duplicated by `(FieldName, L1Key)`. + The response path is rewritten to the outer query's alias when one was recorded (`requestScopedResponseKeys`). + - At this stage `ProvidesData` is still `nil`. + +2. **The planner populates `ProvidesData`** in `configureFetchCaching` via `populateRequestScopedFieldsProvidesData`: + - For each carrier, it locates the matching sub-`Object` in the planner's response tree (`plannerObjects[fetchID]`) by response key. + - It sets `ProvidesData` to that alias-aware `*Object` and runs `ComputeHasAliases` on it. + - **Carriers whose field is not present in this fetch's selection set are dropped** (their `ProvidesData` would be nil). + This is the critical fail-closed filter: a fetch must never be skipped on the strength of a hint describing a field it did not actually select. + +3. **Selection widening (`propagateRequestScopedWidening`)** is a separate node-selection visitor pass. + When several fields share the same `(L1Key, subgraph)` group, + it computes the *union* of their selection sets (including hidden `@requires` dependencies) and widens each participant's fetch to that union, + so the first fetch produces a value rich enough to satisfy every later reader. + Field/argument conflicts within the union are resolved by assigning deterministic **synthetic aliases** so the upstream subgraph query stays valid, + and the response-side keys are remapped back for the client. + Widening only proceeds when all participants in a group share the same return type; otherwise the group is skipped. + +### 4.2 Resolve stage — where it acts in the 4-phase flow + +The coordinate L1 is a plain `map[string]*astjson.Value` (`requestScopedL1`) on the Loader, +allocated per request, **main-thread only**, never touched by a goroutine. +Two operations run against it: `tryRequestScopedInjection` (read) and `exportRequestScopedFields` (write). +Both are gated on `ctx.ExecutionOptions.Caching.EnableL1Cache`. + +Parallel path (`resolveParallel`): + +- **Phase 1 — entity L1 check (main thread).** Unchanged. +- **Phase 1.5 — `@requestScoped` injection (main thread).** + For each fetch not already skipped by Phase 1, call `tryRequestScopedInjection`. + On success: set `res.fetchSkipped = true`, set `ensureFetchTrace(f).LoadSkipped = true`, + and set `res.cacheTraceRequestScopedHits = res.cacheTraceEntityCount`. + A skipped fetch is excluded from the bulk L2 lookup and the HTTP goroutines. +- **Phase 2-L2 — bulk L2 lookup (main thread).** Excludes fetches with `fetchSkipped`. +- **Phase 2-HTTP — parallel HTTP (goroutines).** Excludes fetches with `fetchSkipped`. HTTP only. +- **Phase 3 — merge analytics (main thread).** +- **Phase 3.5 — retry injection (main thread).** + Re-run `tryRequestScopedInjection` for fetches whose hint became satisfiable only after a sibling fetch produced the value. + Same success bookkeeping as Phase 1.5. +- **Phase 4 — merge results + populate caches (main thread).** + After `mergeResult`, call `exportRequestScopedFields` for each fetch to populate the coordinate L1 from the freshly resolved data. + +Single path (`resolveSingle`): the same `tryRequestScopedInjection` (before fetch) and `exportRequestScopedFields` (after merge) calls apply, with `LoadSkipped`/hit-counter bookkeeping at all single-fetch variants. + +### 4.3 Injection contract (`tryRequestScopedInjection`) + +Collect-then-inject; never partial-inject: + +1. Return `false` immediately if the fetch has no `RequestScopedFields`, or if `EnableL1Cache` is off. +2. For every hint, build a pending injection. A hint fails the whole call (returns `false`, items untouched) if: + - the `L1Key` is absent from `requestScopedL1`, or the stored value is nil; + - `hint.ProvidesData == nil` (fail-closed — this fetch did not select the field); + - the **field-widening check fails**: `validateItemHasRequiredData(cachedValue, hint.ProvidesData)` reports the cached value is missing any field the query needs. +3. Each pending value is materialized via `structuralCopyDenormalized(cachedValue, hint.ProvidesData)` — a StructuralCopy onto `l.jsonArena` that re-applies the query's aliases. This produces a value independent of the cached entry, so the response tree may mutate it freely. +4. Only if **all** hints succeed, inject. For a single item, `Set` the materialized value directly; for multiple items, give each item its own additional `StructuralCopy` so items never alias each other. +5. Set `res.fetchSkipped = true` and return `true`. + +The widening check is the load-bearing safety property: +a narrow root query (`{id, name}`) must not poison the coordinate L1 such that a wider entity fetch (`{id, name, email}`) wrongly skips. +Widening (§4.1.3) is what *prevents* that by widening the first fetch up front; +the runtime check is the backstop that refuses to skip if the cached value is nonetheless too narrow. + +### 4.4 Export contract (`exportRequestScopedFields`) + +1. Return immediately if the fetch has no `RequestScopedFields`, or if `EnableL1Cache` is off. +2. Source list is the fetch's `items`; for root fetches with empty items, fall back to `l.resolvable.data`. +3. For each annotated field, read `item.Get(field.FieldPath...)`. Skip nil or JSON-null values (only the first non-null entity is sampled, since the value is request-identical). +4. Normalize the value for storage via `structuralCopyNormalized(value, field.ProvidesData)` — StructuralCopy onto `l.jsonArena` that renames aliases to schema names and appends arg-hash suffixes for arg-variant sub-fields. This is the **copy-on-export** that keeps the stored value independent of the response tree. +5. Store under `field.L1Key`: + - if no entry exists, store the normalized value directly; + - if an entry exists, use **working-copy-and-swap**: `StructuralCopy` the existing entry into a working copy, `MergeValues(working, normalized)`, store the working copy on success, and on merge failure keep the existing entry intact (drop the working copy). Never mutate a live entry in place. + +--- + +## 5. Cache key & data shape + +- **Coordinate key.** The key is `"{subgraphName}.{key}"`, fixed at composition time. It is *not* derived from `@key` fields, response data, or aliases — it is a pure coordinate. There is no per-request hashing, no header prefix, and no `EntityKeyMappings`; this is intentionally simpler than entity/root cache keys (see §4 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md)). + +- **Stored shape (normalized, projected).** The export path uses `structuralCopyNormalized` (the non-passthrough projection): the stored value is in **schema field names** with arg-hash suffixes for arg-variant fields, projected to the `ProvidesData` shape. `__typename` is preserved. Nullable nested objects that are present-but-null survive normalization so later validation can satisfy a selection that includes them. + +- **Read shape (denormalized, alias-restored).** The injection path uses `structuralCopyDenormalized` to re-apply the *current* query's aliases when writing the value onto response items. The same schema-named L1 entry can therefore serve two queries that alias the field differently. + +- **Passthrough vs projection.** Unlike entity L1 (which uses the *passthrough* variant to accumulate all fields across fetches), the coordinate L1 uses **projection** on both sides — it stores exactly the `ProvidesData` shape. The accumulation that does happen is via the working-copy-and-swap `MergeValues` in export, which lets a later, wider participant enrich the entry written by an earlier, narrower one. + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation (astjson StructuralCopy + arena).** The coordinate L1 depends entirely on the StructuralCopy isolation discipline of §3 of [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md): copy-on-export, copy-on-inject, working-copy-and-swap on merge. All values live on `l.jsonArena` for the request lifetime; there are no heap↔arena crossings, so no `DeepCopy` is needed. + +- **`@provides` / `ProvidesData`.** The widening check and the normalize/denormalize transforms reuse the exact `*Object` machinery built for `@provides` ([directives/provides.md](provides.md)). `RequestScopedField.ProvidesData` is the same alias-aware `*Object` type. This is the only directive dependency: `@requestScoped` cannot ship before `@provides` `ProvidesData` exists. + +- **`@requires`.** Widening folds in hidden `@requires` selection sets so a widened first fetch still carries the inputs a later participant's `@requires` chain needs (see the requires-chain e2e case in §7). + +- **`@key`.** Used only indirectly: entity fetches that participate still build their normal entity representations from `@key`; the coordinate L1 itself is keyed on the directive `key`, not on `@key`. + +- **L2 / mutation / subscription specs.** Independent. The coordinate L1 never reads or writes L2, never participates in mutation invalidation, and never fires on subscription events. It is a side-branch in the dependency ordering ([02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) §3). + +- **Per-request gating.** Disabling L1 via `X-WG-Disable-Entity-Cache` or `X-WG-Disable-Entity-Cache-L1` headers also disables the coordinate L1, because both `tryRequestScopedInjection` and `exportRequestScopedFields` check the shared `EnableL1Cache` flag. + +--- + +## 7. End-to-end test plan + +All assertions follow the universal + E2E rules: +`assert.Equal` on full values only, no `Contains`/`GreaterOrEqual`/`Greater`, +inline literal queries and responses, +vertical one-item-per-line struct literals for any multi-key/multi-event list, +and every `ClearLog()` immediately followed by `GetLog()` + full assertion. +The `execution/engine/request_scoped_widening_e2e_test.go` recorder pattern +(asserting the exact upstream request stream per subgraph) is the sanctioned exception +for these tests; new tests outside that file follow the inline `federationtesting` + `QueryStringWithHeaders` flow. + +Federation services `accounts` / `products` / `reviews` are used where an entity is needed; +the widening cases use purpose-built `viewer` / `articles` subgraphs because the stock services do not declare `@requestScoped`. + +### Case 1 — Root fetch widens, entity fetch is skipped (happy path) + +- **Subgraphs.** `viewer` declares `Query.currentViewer @requestScoped(key: "viewer")` and `Article.currentViewer @requestScoped(key: "viewer")`. `articles` owns `Query.article`. +- **Query.** + ```graphql + query { + currentViewer { id name } + article { id title currentViewer { id name email } } + } + ``` +- **What is coordinated.** `key: "viewer"` ⇒ coordinate L1 entry `viewer.viewer`. Widening lifts the root `currentViewer` fetch to `{id name email}` (the union). The article's `currentViewer` entity hop is satisfied by injection and skipped. +- **Assertions.** + - Full client response with `assert.Equal` (compacted), keeping the narrow root shape and the wider article shape: + `{"data":{"currentViewer":{"id":"v1","name":"Alice"},"article":{"id":"a1","title":"T1","currentViewer":{"id":"v1","name":"Alice","email":"alice@example.com"}}}}`. + - Exact upstream request stream: `viewer` receives exactly `{currentViewer {id name email}}`; `articles` receives exactly `{article {id title __typename}}`. No viewer entity (`_entities`) request is sent. + +### Case 2 — `@requires` chain widens the base fetch, entity hop skipped, third subgraph still fed + +- **Subgraphs.** `viewer` base (owns `currentViewer.name`), `articles`, and `handles` which owns `Viewer.handle @requires(fields: "name")`. +- **What is coordinated.** Widening folds the hidden `@requires(name)` dependency into the widened root viewer fetch (aliased `name`, `__typename`, `id`). The requestScoped entity hop is skipped; the `handles` entity fetch still runs and receives representations carrying the widened `name`. +- **Assertions.** + - `viewer` receives exactly `{currentViewer {viewerName: name __typename id}}` (one request). + - `handles` receives exactly the `_entities` query with `Variables` equal (inline, compacted) to `{"representations":[{"__typename":"Viewer","id":"v1","name":"Alice"}]}`. + - Full client response asserted with `assert.Equal`. + +### Case 3 — Field / argument conflicts widen through synthetic aliases + +- **Query.** Two participants select the same `key: "viewer"` field with conflicting arguments or field shapes. +- **What is coordinated.** Widening assigns deterministic synthetic aliases so the single widened upstream query is valid, then remaps response keys back per branch. +- **Assertions.** + - Exact upstream query string (with synthetic aliases) asserted with `assert.Equal`. + - Full client response asserted with `assert.Equal`, each branch keeping its own requested shape. + +### Case 4 — Field-widening backstop blocks an unsafe skip (unit, `resolve` package) + +- **Setup.** Pre-seed `requestScopedL1["viewer.Personalized.currentViewer"]` with a narrow value `{"id":"1","name":"Alice"}`; a hint whose `ProvidesData` requires `{id, name, email}`. +- **Assertions.** `tryRequestScopedInjection` returns `false` and the item is byte-for-byte unchanged: `assert.Equal(t, `{"id":"99"}`, string(items[0].MarshalTo(nil)))`. A symmetric subtest with a wide-enough cached value returns `true` and asserts the full injected item. + +### Case 5 — Fail-closed on nil `ProvidesData` (unit) + +- **Setup.** L1 has the value, but the hint's `ProvidesData` is nil (the field is not selected by this fetch). +- **Assertion.** `tryRequestScopedInjection` returns `false`; items untouched (`assert.Equal` on the full item). + +### Case 6 — Export normalization + copy independence (unit) + +- **Setup.** Resolve a fetch whose `@requestScoped` field is aliased and has aliased sub-fields; export to L1. +- **Assertions.** + - The stored L1 value is in schema names (alias stripped): `assert.Equal` on the full marshaled L1 entry. + - Mutating the source response value afterward does not change the stored L1 value (copy-on-export): `assert.Equal` on the full L1 entry before and after a source mutation. + +### Case 7 — Analytics / trace folding (e2e or resolve) + +- **Setup.** Run Case 1 with cache analytics enabled; read `ctx.GetCacheStats()`. +- **Assertions.** Full `CacheAnalyticsSnapshot` with `assert.Equal` (normalized). The skipped entity fetch must appear as an **L1 hit**, not a stale L1 miss — `cacheTraceRequestScopedHits` is folded into `L1Hit`/`L1Miss` at trace-build time. Each snapshot event line carries a trailing comment explaining why it occurred. If the test uses a cache log, every `ClearLog()` is followed by `GetLog()` + a full vertical assertion before the next clear. + +--- + +## 8. Acceptance criteria + +A reviewer can verify the implementation against this checklist (mirrors AC-RS-01..07 in the acceptance-criteria doc): + +- [ ] **Directive definition.** `directive @requestScoped(key: String!) on FIELD_DEFINITION`; `key` is mandatory; composition rejects a missing `key`. +- [ ] **Composition warning.** A `key` declared on exactly one field in a subgraph produces a warning, not an error, and does not break composition. +- [ ] **Symmetric emission.** Datasource `ConfigureFetch` emits one `RequestScopedField` per annotated field for both root and entity fetches; entity fetches also resolve interface-object types via `InterfaceObjects`; entries are de-duplicated by `(FieldName, L1Key)`. +- [ ] **`ProvidesData` population + drop.** The planner sets `ProvidesData` from the response tree by response key, and **drops** carriers whose field is not selected by the fetch (nil `ProvidesData`). +- [ ] **L1 key shape.** Coordinate L1 key is exactly `"{subgraphName}.{key}"`, alias-independent, no header prefix, no per-request hash. +- [ ] **Thread + arena discipline.** `requestScopedL1` is read/written only on the main thread (Phase 1.5, Phase 3.5, `resolveSingle`); no goroutine touches it; all values live on `l.jsonArena`. +- [ ] **Gating.** Both inject and export return early when `EnableL1Cache` is false (including via the disable-L1 header). +- [ ] **Field-widening backstop.** Injection runs `validateItemHasRequiredData(cachedValue, hint.ProvidesData)` and refuses to skip when the cached value is too narrow. +- [ ] **Fail-closed.** Injection returns false when any hint's `ProvidesData` is nil. +- [ ] **Collect-then-inject.** A failure on any hint leaves all items untouched; never partial-inject. +- [ ] **Copy-on-inject / copy-on-export.** Inject uses `structuralCopyDenormalized`; export uses `structuralCopyNormalized`; multi-item injection copies per item. +- [ ] **Working-copy-and-swap on merge.** Export into an existing entry copies, merges into the copy, and stores the copy on success or keeps the live entry on merge failure — never mutates in place. +- [ ] **Selection widening.** `propagateRequestScopedWidening` widens grouped participants to the union of their selections (including hidden `@requires`), resolves conflicts with deterministic synthetic aliases, and only proceeds when all participants share a return type. +- [ ] **Trace/analytics.** On a successful skip, `LoadSkipped` is set at every call site and `cacheTraceRequestScopedHits` is folded into L1 hit counters; the snapshot shows the skip as an L1 hit. +- [ ] **End-to-end.** The widening e2e cases (root widening, requires chain, argument/field conflicts) pass with exact upstream-request-stream assertions and full-response `assert.Equal`. diff --git a/docs/entity-caching/directives/requires.md b/docs/entity-caching/directives/requires.md new file mode 100644 index 0000000000..b4124e2842 --- /dev/null +++ b/docs/entity-caching/directives/requires.md @@ -0,0 +1,299 @@ +# Directive Specification: `@requires` + +> Part of the entity-caching re-implementation document set. +> Re-implementation PR: **gqtools PR 5 / PR-CACHE-PROJECTION**. +> Cross-links: [adr/0003-requires.md](../adr/0003-requires.md), [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md), [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). +> Sibling directive specs: [provides.md](provides.md) (defines the shape `@requires` is excluded from), [key.md](key.md) (entity identity). + +This spec assumes no prior knowledge of the feature. +It describes the contract for `@requires` *as the caching layer consumes it* — the caching layer never redefines the directive, it only reads it and reacts to it. +The single most important fact in this document: +caching's relationship to `@requires` is **exclusionary**. +The directive does not turn caching on or add fields to a cached entity. +It marks fields that must be *kept out* of every cached shape. + +--- + +## 1. Purpose & responsibility + +`@requires(fields: _FieldSet!)` is a federation directive that declares the external fields a subgraph resolver needs as **input** before it can resolve its own field. +Those input fields are owned by, and resolved from, a *different* subgraph, then fed back into the requiring subgraph's fetch as variables. +Because the required values are supplied per request and are not owned by the entity the resolver belongs to, they are **request-derived, not entity-owned**. +The caching layer's sole responsibility toward `@requires` is therefore negative: +when it builds the projected shape that gets written into L1 or L2, it must **exclude** any field that arrived only because it was required as an input. +A `@requires` value must never be persisted as part of the cached entity, because a later request that supplies different required inputs would otherwise read stale, mismatched data attributed to the wrong inputs. + +--- + +## 2. SDL / configuration definition + +`@requires` is a wire directive that lives in subgraph SDL. +The caching layer does not introduce it; it is consumed exactly as composition emits it. + +```graphql +directive @requires(fields: _FieldSet!) on FIELD_DEFINITION +``` + +Concrete fixture from the `reviews` subgraph (`execution/federationtesting/reviews/graph/schema.graphqls`): + +```graphql +type User @key(fields: "id") { + id: ID! + username: String! @external + coReviewers: [User!]! @requires(fields: "username") + sameUserReviewers: [User!]! @requires(fields: "username") +} +``` + +Here `username` is `@external` (owned by `accounts`). +`coReviewers` and `sameUserReviewers` are resolved by `reviews`, but only after the gateway resolves `username` from `accounts` and feeds it back in. + +Inside the planner, the parsed directive is carried on `FederationMetaData` as a field-configuration list, not as a bespoke caching struct: + +```go +type FederationMetaData struct { + Keys FederationFieldConfigurations + Requires FederationFieldConfigurations // ← parsed @requires field sets + Provides FederationFieldConfigurations + // ... +} +``` + +Lookups go through: + +```go +func (d *FederationMetaData) RequiredFieldsByRequires(typeName, fieldName string) (cfg FederationFieldConfiguration, exists bool) +``` + +There is **no caching-specific configuration object** for `@requires`. +The caching layer never reads `Requires` directly to *enable* anything. +The exclusion is achieved indirectly: the projected cache shape is driven entirely by `ProvidesData` (see [provides.md](provides.md)), and required-only fields simply never appear in `ProvidesData`, so they are never copied into the cache. + +--- + +## 3. Composition rules & validation + +These rules are enforced by composition, *before* the caching layer ever runs. +The caching layer assumes a well-composed schema and does not re-validate them. + +- `fields` is a **mandatory** `_FieldSet`. + It is parsed into a selection set against the field's enclosing type. +- Every field referenced in the `_FieldSet` must be marked `@external` on the same type in the same subgraph. + `@requires` declares a dependency on values the requiring subgraph does **not** own; `@external` is what marks those borrowed fields. +- The referenced external fields must be resolvable from another subgraph (typically via that subgraph's `@key` resolution), or composition fails — the gateway has to have a place to fetch them from. +- `@requires` applies to `FIELD_DEFINITION` only. + It annotates the field whose resolver needs the inputs, not a type. + +Planner-side consequence (not a caching rule, but the caching layer depends on it): +a field with `@requires` forces the planner's required-fields visitor to emit a prior fetch for the required field set, producing **sequential** execution — the required field must be resolved before the requiring field's fetch is dispatched. +This sequencing is why `@requires` fields commonly appear in `Sequence` fetch-tree nodes, which is the exact shape the L1 read path benefits from (a prior fetch can populate L1 for a later one). + +--- + +## 4. Runtime semantics + +`@requires` has no run-time hook of its own. +It acts purely by **shaping what the planner records**, and that recorded shape is then honored by the resolver's existing cache-write path. +Walk it through the two stages where caching attaches (see [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §1). + +### 4.1 Plan time (compile-time, once per operation) + +1. The planner's **required-fields visitor** reads `Requires` from `FederationMetaData` for each selected field. + For a requiring field it injects the required field set as a prior selection so the gateway resolves those inputs first, and it threads them into the requiring fetch's input template as variables. +2. The planner's **provides-fields visitor** builds the `FetchInfo.ProvidesData *Object` — the alias-aware description of the exact entity shape this fetch yields (see [provides.md](provides.md)). + **Required-only fields are deliberately not part of `ProvidesData`.** + They are inputs, not outputs of the entity; the fetch does not *provide* them, it *consumes* them. +3. The fetch's `FetchCacheConfiguration` (key template, TTL, cache name) is attached using `@key` fields only. + `@requires` fields never enter the cache key (see §5). + +### 4.2 Resolve time (run-time, once per request) — the 4-phase parallel flow + +The resolver (`loader.go`, see resolve [CLAUDE.md](../../entity-caching-v2/v2/pkg/engine/resolve/CLAUDE.md)) runs the fetch tree. +`@requires` participates only by virtue of the shape recorded at plan time; there is no `@requires`-specific branch in the loader. + +- **Phase 1 — prepare + L1 check (main thread).** + `prepareCacheKeys` renders keys from `@key` fields only. + A `@requires` value present on an item is never part of the key, so two requests with different required inputs collide on the same entity key — correct, because the *entity* is the same; only the request-derived input differs. +- **Phase 2-L2 — bulk L2 lookup (main thread).** + When the cached entity is read back, it was projected to `ProvidesData` at write time, so it never contained the required-only field. + The denormalized read therefore cannot reintroduce a stale `@requires` value into the response tree. +- **Phase 2-HTTP — parallel HTTP (goroutines).** + The requiring subgraph receives the required values as inputs (variables) in its request body, not from cache. + This is by design: the requiring fetch always sees the *current* request's required inputs. +- **Phase 4 — merge + populate caches (main thread).** + `populateL1Cache` writes via `structuralCopyNormalizedPassthrough` and `updateL2Cache` writes via the non-passthrough projecting transform. + Both are driven by `ProvidesData`. + Because the required-only field is absent from `ProvidesData`: + - **L2 projection (`Passthrough = false`)** drops the required field outright — it is not in the listed fields, so projection excludes it. + This is the load-bearing exclusion. + - **L1 passthrough (`Passthrough = true`)** keeps unlisted source fields verbatim, so it does *not* by itself strip a required-only field that happens to be sitting on the response item. + The exclusion at L1 relies on the merged item not carrying the required input as a persisted entity field in the first place, and on `@key`-only keying so a `@requires`-shaped result cannot be served back to a different-input request. + Re-implementers must verify with the adversarial test in §7 that a required input value never round-trips through L1 to a sibling fetch. + +### 4.3 Alias / normalization handling + +Alias handling is inherited from the `ProvidesData` pipeline (see [provides.md](provides.md)); `@requires` adds nothing. +Because required-only fields are excluded from `ProvidesData`, they have no entry in the normalize/denormalize `Transform` and are simply never renamed, projected, or restored. + +### 4.4 Ordering / threading constraints + +- All cache reads, writes, projections, and the `ProvidesData`-driven transforms run on the **main thread**. + Goroutines do subgraph HTTP only and never touch the projected shape. +- `@requires` reinforces **sequential ordering**: the required field's fetch must complete before the requiring fetch dispatches. + This is a planner property; the caching layer does not enforce or relax it. + +--- + +## 5. Cache key & data shape + +**Effect on the cache key: none, deliberately.** +Both L1 and L2 key entities on `@key` fields only (see [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §2, §4 and [key.md](key.md)). +`@requires` fields are **never** included in the key. +Including them would fragment the cache by request-supplied input and break entity identity — the same `User:1234` would land in different entries depending on which required input a given request happened to supply. +Canonical key shape is unchanged by `@requires`: + +```json +{"__typename":"User","key":{"id":"1234"}} +``` + +**Effect on the stored data shape: exclusion via projection.** + +| Tier | Transform mode | What happens to a `@requires`-only field | +|------|----------------|------------------------------------------| +| **L2** | `structuralCopyNormalized`, `Passthrough = false` (project) | Dropped. Only `ProvidesData`-listed fields survive; the required input is not listed, so it is excluded from the bytes written to the backend. | +| **L1** | `structuralCopyNormalizedPassthrough`, `Passthrough = true` (keep + rename) | Not added by the cache. L1 accumulates entity-owned fields across fetches; it must not accumulate a required input as if it were an entity field. The `@key`-only key plus exclusion from `ProvidesData` keep required inputs out of the served L1 shape. | + +The rule restated: **passthrough vs projection both converge on excluding `@requires`** — L2 by projecting it away, L1 by never treating it as an entity-owned field eligible for cross-fetch reuse. + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation ([01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) §3, [adr/0001-foundation.md](../adr/0001-foundation.md)).** + `@requires` exclusion is implemented entirely through the `Transform.Passthrough` switch and the `ProvidesData`-driven `StructuralCopyWithTransform` copy primitives. + It introduces no new primitive and no new copy site; it is a property of *which fields appear in `ProvidesData`*, which the transform builders consume. +- **`@provides` ([provides.md](provides.md)) — hard dependency.** + `@requires` is the dual of `@provides`: `@provides` defines the inclusive `ProvidesData` shape, and `@requires` is expressed as the *absence* from that shape. + This is why both are rebuilt in the same PR (**PR-CACHE-PROJECTION**) and why `@provides` must be specified first (see dependency ordering in [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md) §3). +- **`@key` ([key.md](key.md)) — dependency.** + Entity identity comes from `@key`; `@requires` must never widen the key. + The interplay matters most for the L1 widening check (see [provides.md](provides.md) / `validateItemHasRequiredData`): a fetch's required fields are inputs and must not be confused with the provided fields the widening check validates. +- **`@external`.** + Every `@requires` field references `@external` fields. + The caching layer does not consume `@external` directly, but the composition guarantee (required ⇒ external) is what assures the required value originated in another subgraph and is therefore request-derived. +- **`@requestScoped` ([request-scoped.md](request-scoped.md)) — orthogonal.** + `@requestScoped` coordinate L1 also uses `validateItemHasRequiredData` for its widening check, but its `ProvidesData` is the *provided* shape, never the required inputs. + There is no overlap in field handling. + +--- + +## 7. End-to-end test plan + +All tests follow the mandatory conventions: `assert.Equal` on full values only (never `Contains` / `GreaterOrEqual` / fuzzy), inline literal queries and expected JSON at the assertion site, vertical one-item-per-line for multi-key cache-log / snapshot literals, and every `ClearLog()` paired with a `GetLog()` + full assertion before the next clear. +E2E cases live under `execution/engine/` and **must be self-contained per `t.Run`** (inline setup, no shared `newXxxEnv` helpers) — re-read [execution/engine/CLAUDE.md](../../entity-caching-v2/execution/engine/CLAUDE.md) before writing. +The `reviews` subgraph already ships the fixtures these cases need (`coReviewers`, `sameUserReviewers`, both `@requires(fields: "username")`; `CacheEntity.nested @requires(fields: "a")`). + +### Case A — `@requires` value is excluded from the L2 cached shape (unit, `v2/pkg/engine/resolve/`) + +- **Setup.** Plan an entity fetch whose `ProvidesData` is `{id, name}` and whose input item carries an extra request-derived `requiredInput` field that is NOT in `ProvidesData`. + Enable L2, use a logging `LoaderCache`. +- **Query / fetch.** A `User` entity fetch keyed on `id`, item `{"__typename":"User","id":"1234","name":"Me","requiredInput":"from-accounts"}`. +- **What is cached.** Exactly the projected shape, with the required input dropped. +- **Assertion.** After resolve, assert the L2 `Set` payload bytes exactly: + + ```go + wantLog := []CacheLogEntry{ + { + Operation: "set", + Keys: []string{ + `{"__typename":"User","key":{"id":"1234"}}`, // key uses @key only — no requiredInput + }, + Values: []string{ + `{"id":"1234","name":"Me"}`, // projected: requiredInput excluded from cached bytes + }, + }, + } + assert.Equal(t, wantLog, defaultCache.GetLog()) + ``` + +### Case B — `@requires` field never enters the cache key (unit, `v2/pkg/engine/resolve/cache_key_test.go`) + +- **Setup.** Render the entity cache key for a `User` item that carries both `id` (`@key`) and `requiredInput` (`@requires`). +- **Assertion.** The rendered key contains only the `@key` field — exact match: + + ```go + assert.Equal(t, `{"__typename":"User","key":{"id":"1234"}}`, renderedKey) + ``` + + Two items differing only in `requiredInput` must render the **same** key: + + ```go + assert.Equal(t, keyForInputA, keyForInputB) // request-derived input must not fragment identity + ``` + +### Case C — different required inputs hit the same entity entry, response stays request-correct (E2E, `execution/engine/`) + +- **Setup (inline in the subtest).** Federation gateway over `accounts` + `reviews`, L2 enabled, a logging cache, two sequential requests differing only in the value `accounts` resolves for `username`. +- **Query (inline).** + + ```graphql + { me { coReviewers { id } } } + ``` + + `coReviewers @requires(fields: "username")` forces `accounts` (resolve `username`) → `reviews` (resolve `coReviewers` with that username as input). +- **What is cached.** The `User` entity entry keyed on `id`, projected to entity-owned fields; `username` (the required input) is NOT persisted. +- **Assertions.** + - Request 1 full response asserted exactly with `assert.Equal` on the response string. + - Cache log after request 1: a single `set` for the `User` key whose value bytes exclude `username` — full `assert.Equal` on the `[]CacheLogEntry`, one key per line, with a trailing comment per entry. + - `ClearLog()` then request 2: assert the `coReviewers` resolver still received the *current* request's `username` as input (the requiring fetch must not be served the prior request's required input from cache) and the response is request-correct — full `assert.Equal` on the response string and on the cache log. + +### Case D — L1 does not leak a required input across sibling fetches in one request (adversarial unit, `v2/pkg/engine/resolve/`) + +- **Setup.** One request, two fetches for the same `User` entity in a `Sequence`: fetch 1 provides `{id, name}` and carries a transient `requiredInput`; fetch 2 reads the same `User` from L1. +- **Assertion.** After fetch 2's L1 read, assert the merged response item for fetch 2 equals exactly the provided shape with no `requiredInput`: + + ```go + assert.Equal(t, `{"id":"1234","name":"Me"}`, string(out)) // requiredInput must not round-trip via L1 + ``` + + This is the mutation-isolation guard analogue for `@requires`: it proves the exclusion holds on the L1 read path, not only on L2 write. + +### Case E — deep sequential chain via `CacheEntity.nested @requires(fields: "a")` (E2E, `execution/engine/`) + +- **Setup (inline).** Gateway over `accounts` + `reviews`, L1 enabled, count subgraph HTTP calls. +- **Query (inline).** + + ```graphql + { cacheEntity(id: "1") { nested { nested { id } } } } + ``` + + `nested @requires(fields: "a")` chains sequential entity fetches; each level resolves `a` from `accounts` first. +- **What is cached.** Each `CacheEntity` is L1-cached by `id`; the required `a` is excluded from the cached shape. +- **Assertion.** Exact HTTP call count (`assert.Equal`, never `GreaterOrEqual`) showing repeated identical entities are served from L1, plus the full response string asserted with `assert.Equal`. + +--- + +## 8. Acceptance criteria + +A reviewer can verify the re-implementation of `@requires` consumption by checking each item. +These mirror and extend the canonical criteria in [ENTITY_CACHING_ACCEPTANCE_CRITERIA.md](../../entity-caching-v2/docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md) (notably AC-L1-03). + +- [ ] **No key contribution.** `@requires` fields never appear in any L1 or L2 cache key; keys are built from `@key` fields only (AC-L1-03). Two items differing only in a required input render byte-identical keys. +- [ ] **L2 projection excludes required fields.** The L2 `Set` payload contains only `ProvidesData`-listed (entity-owned) fields; required-only inputs are absent from the stored bytes (Case A). +- [ ] **L1 does not serve required inputs.** A required input present on a response item does not round-trip through L1 to a sibling fetch in the same request (Case D). +- [ ] **Request-correct inputs.** A requiring fetch always receives the *current* request's required inputs (as variables), never a value reconstructed from cache (Case C). +- [ ] **No `@requires` caching config.** The re-implementation adds no caching-specific struct for `@requires`; exclusion is achieved solely by absence from `ProvidesData`. +- [ ] **No new copy site.** `@requires` introduces no new StructuralCopy and does not change the Copy Budget; it is a `ProvidesData`-shape property consumed by existing transforms. +- [ ] **Sequential ordering respected.** The required field's fetch completes before the requiring fetch dispatches; cache logic does not relax this. +- [ ] **Tests use exact assertions.** Every test asserts full values with `assert.Equal`, inline literals, vertical multi-key literals, and clear-then-assert cache-log discipline. No `Contains` / `GreaterOrEqual` / fuzzy comparisons. +- [ ] **Acceptance criteria doc updated.** Any new or changed `@requires` test is linked from [ENTITY_CACHING_ACCEPTANCE_CRITERIA.md](../../entity-caching-v2/docs/entity-caching/ENTITY_CACHING_ACCEPTANCE_CRITERIA.md) with path, line number, and test name. + +--- + +## Cross-references + +- Rationale and decision record: [adr/0003-requires.md](../adr/0003-requires.md). +- Architecture, seam, copy invariants, cache-key model: [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md). +- Directive taxonomy and PR mapping: [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). +- The shape `@requires` is excluded from: [provides.md](provides.md). +- Entity identity / cache-key seed: [key.md](key.md). diff --git a/docs/entity-caching/directives/root-field-cache-config.md b/docs/entity-caching/directives/root-field-cache-config.md new file mode 100644 index 0000000000..d1de580cbc --- /dev/null +++ b/docs/entity-caching/directives/root-field-cache-config.md @@ -0,0 +1,335 @@ +# Directive Specification: Root-field caching config + +> Part of the entity-caching re-implementation document set. +> Cross-links: [adr/0007-root-field-cache-config.md](../adr/0007-root-field-cache-config.md), +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md), +> [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). +> Re-implementation PR: gqtools PR 4 + PR 8 / PR-CACHE-CONFIG. + +This document specifies the **Root-field caching config**. +It is written for a reader with no prior knowledge of the feature. +Read [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md) first for the L1/L2 model, +the StructuralCopy invariants, +and the cache-key model that this spec builds on. + +--- + +## 1. Purpose & responsibility + +The root-field caching config caches the **whole response of a root field** — for example `Query.topProducts` or `Query.product` — in L2 so that a second identical request can be served from the external cache without calling the subgraph at all. +Its distinctive capability is `EntityKeyMapping`: +a declarative binding that rewrites the root-field cache key into **entity key shape** so that a root query such as `product(upc: "top-1")` and a nested `_entities` fetch for `Product{upc:"top-1"}` resolve to the **same** L2 cache entry and therefore share data. +When the mapped argument is a **list** marked `ArgumentIsEntityKey`, the config additionally unlocks three batch optimisations: +one cache key per list element, +an empty-list (or null) short-circuit that returns an empty response without ever calling the resolver, +and a partial-fetch mode that fetches only the missing entities and serves the rest from cache. +The config is a Go configuration concept supplied per subgraph, **not** a GraphQL wire directive, +and it is consumed by the planner caching state and the resolver root-field cache path. + +--- + +## 2. Configuration definition + +There is **no SDL syntax**. +The config is a Go struct attached per subgraph via `SubgraphCachingConfig.RootFieldCaching` and consumed by the planner. + +The router-facing plan-level shape (in `v2/pkg/engine/plan/federation_metadata.go`): + +```go +type RootFieldCacheConfiguration struct { + TypeName string // "Query" (the root type containing the field) + FieldName string // "topProducts", "product", "products" + CacheName string // names a registered LoaderCache instance + TTL time.Duration // entry lifetime + IncludeSubgraphHeaderPrefix bool // header-hash prefix on the key + EntityKeyMappings []EntityKeyMapping // optional: derive entity-shaped keys + ShadowMode bool // read/write L2 but never serve cached data + PartialBatchLoad bool // batch list mode: fetch only missing IDs +} + +type EntityKeyMapping struct { + EntityTypeName string // entity type the root field returns, e.g. "Product" + FieldMappings []FieldMapping // one mapping per @key field +} + +type FieldMapping struct { + EntityKeyField string // the @key field name on the entity, e.g. "upc" + ArgumentPath []string // path into ctx.Variables for the argument value + ArgumentIsEntityKey bool // list element ↔ entity 1:1 correspondence (batch) +} +``` + +Lookup helpers travel with the collection type: +`RootFieldCacheConfigurations.FindByTypeAndField(typeName, fieldName)` returns `nil` when caching is not configured for a root field (caching is opt-in). + +`ArgumentPath` notes the re-implementation must honor: +- It uses the same `[]string` format as `ContextVariable.Path`: + object keys are `["id"]` or `["input","userId"]`, + an array index is a decimal string segment `["ids","0"]`. +- It is subject to `ctx.RemapVariables` on its first segment only (top-level variable names are remapped, nested input-object field names are not). + A path `["a","ids"]` with `RemapVariables["a"] == "input"` must become `["input","ids"]`, not be left unchanged. +- It is the **variable** path, not the schema argument name. + A historical bug used the schema argument name (`"upc"`) instead of the remapped variable path, which broke cache sharing — the re-implementation must resolve through `RemapVariables`. + +The internal resolver-side mirror of these structs lives in `v2/pkg/engine/resolve/caching.go` as `EntityKeyMappingConfig` / `EntityFieldMappingConfig`, carried on the `RootQueryCacheKeyTemplate` (a `CacheKeyTemplate` implementation, see §5). +The plan→resolve translation is a flat field copy; no behavior is added at the boundary. + +--- + +## 3. Composition rules & validation + +There are **no composition or SDL validation rules**, because this is not a wire directive. +The config is synthesised by the router from per-subgraph configuration and handed to the engine. +The invariants the engine *relies on* (and that the router-side config builder must therefore guarantee) are: + +- **At most one `ArgumentIsEntityKey` mapping per root field.** + The resolver's `batchEntityKeyMapping()` returns the first list-keyed mapping and assumes uniqueness; + supplying two would silently use only the first. +- **`FieldMapping.ArgumentPath` must be resolvable from request variables.** + If a mapping's argument is missing or `null`, that mapping renders no key and is skipped; + with multiple mappings the remaining ones still produce keys, + with a single mapping the request is simply not cached for that key. +- **`EntityKeyField` must be an actual `@key` field of `EntityTypeName`** so the derived key collides with the entity fetch's key (this is the whole point of sharing). + See [directives/key.md](key.md) for the entity-identity contract. +- **`ArgumentIsEntityKey` requires a list argument** to engage batch mode; + a scalar value with the flag set falls back to the single-entity-key path. + +`ShadowMode` on a root field is currently implemented for **entity fetches only** — set it on the root config for forward compatibility, but do not rely on root-field shadow comparison behavior beyond read/write-without-serve. + +--- + +## 4. Runtime semantics + +### 4.1 Plan time (annotate, do not touch the cache) + +The planner reads `RootFieldCacheConfiguration` for the resolved root field and attaches a `FetchCacheConfiguration` to the root `SingleFetch`: +`Enabled` (L2 on), `CacheName`, `TTL`, `IncludeSubgraphHeaderPrefix`, `ShadowMode`, `EnablePartialCacheLoad` (from `PartialBatchLoad`), +and a `CacheKeyTemplate` of concrete type `RootQueryCacheKeyTemplate` carrying the `RootFields` (coordinate + args + the response key, i.e. alias-or-field-name) and the `EntityKeyMappings`. +The planner leaves `UseL1Cache = false`; +the L1-optimizer post-process pass owns that decision (see [01-ARCHITECTURE-SPEC.md §6](../01-ARCHITECTURE-SPEC.md)), +and root-field fetches generally do not read L1 (a root field has no prior entity data to key on, per [01-ARCHITECTURE-SPEC.md §2](../01-ARCHITECTURE-SPEC.md)). + +### 4.2 Resolve time — single fetch path (`resolveSingle`) + +For a root `SingleFetch` the loader runs, in order: + +1. **Empty-list / null short-circuit (pre-cache).** + If the template has a batch entity-key argument path (`ArgumentIsEntityKey` + list) and the argument resolves to `null` or `[]`, + the loader returns an empty response via `mergeBatchEmptyResponse` **without** calling the resolver or any cache. + This is a fetch-level optimisation gated by the config, not a cache read. +2. **`prepareCacheKeys` → L1 → L2 (`tryCacheLoad`).** + Root fields skip L1 reads in practice; L2 is consulted via `tryL2CacheLoad`, + which renders keys from `RootQueryCacheKeyTemplate.RenderCacheKeys` and issues a `Get`. +3. **HTTP** if not satisfied by cache. +4. **`mergeResult`** honoring the two-boolean contract (`cacheSkipFetch`, `cacheMustBeUpdated`) from [01-ARCHITECTURE-SPEC.md §7.2](../01-ARCHITECTURE-SPEC.md), then `updateL2Cache`. + +### 4.3 Resolve time — parallel path (`resolveParallel`) + +The root-field config slots into the existing four-phase machinery with no rewrite: + +- **Phase 1** generates L1/L2 keys via `prepareCacheKeys`. +- **Phase 2-L2** (`bulkL2Lookup`) groups L2-eligible root fetches by cache instance, issues one bulk `Get`, parses verbatim on the Loader arena, and runs `applyRootFetchL2Results` to decide `cacheSkipFetch` per fetch. +- **Phase 2-HTTP** runs only fetches L2 did not cover. +- **Phase 4** merges and writes L2 via `updateL2Cache`. + +All cache work is main-thread; goroutines do HTTP only. + +### 4.4 Batch key rendering + +`RootQueryCacheKeyTemplate.RenderCacheKeys` branches: +- **`EntityKeyMappings` present, batch list argument** → `tryRenderBatchEntityKeys` produces **one `CacheKey` per list element**, each with `BatchIndex` set to its position in the original argument list, and each rendered in entity key shape `{"__typename":"Product","key":{"upc":"top-1"}}`. +- **`EntityKeyMappings` present, scalar/derived** → `renderDerivedEntityKey` renders one entity-shaped key per mapping from the request arguments; missing/null arguments yield an empty key (skip caching for that mapping). +- **No `EntityKeyMappings`** → `renderField` renders the root-field-shape key `{"__typename":"Query","field":"topProducts","args":{...}}` (args present only when the field has arguments). + +### 4.5 Partial batch fetch + +When `PartialBatchLoad` (→ `EnablePartialCacheLoad`) is true and some batch keys hit while others miss: +- Cached entities are spliced into the response array at their `BatchIndex` via `mergeBatchCacheHit` / `mergeBatchPartialResponse`, + each StructuralCopied before `SetArrayItem` to preserve cache isolation (see [01-ARCHITECTURE-SPEC.md §3](../01-ARCHITECTURE-SPEC.md) and the Copy Budget). +- Only the **missing** indices are sent to the subgraph. + `cloneVariablesWithBatchIndices` clones `ctx.Variables` and rewrites the batch argument array to contain only the missing elements, so the subgraph receives a filtered list. +- Fresh results are interleaved back at their original positions. + +When `PartialBatchLoad` is false (default), any miss in the batch refetches the **whole** list (all-or-nothing). + +### 4.6 Alias & normalization handling + +The cache key is **alias-independent** by construction: +keys are derived from `@key` fields and arguments, never from response aliases (see [01-ARCHITECTURE-SPEC.md §4](../01-ARCHITECTURE-SPEC.md)). +The **merge path** (where cached data is spliced into the response tree) *is* alias-aware: +`EntityMergePath` uses the root field's `ResponseKey` (the alias if present, else the schema field name). +For `u: user(id: $id)` the merge path is `["u"]`, not `["user"]`; +an explicit `PostProcessing.MergePath` overrides the derived key. +For batch responses the array's response key is likewise captured as the alias (`p` for `p: products(...)`, not `products`). + +### 4.7 Smart cache-key backfill (EntityKeyMappings, write side) + +When `EntityKeyMappings` produce multiple L2 keys on read and some miss, `updateL2Cache` makes **per-key** write decisions (no blanket rewrite): +- **Requested key** (rendered from request arguments): written on backfill / refresh, and on a skip-fetch path only when `fromCacheNeedsWriteback`. +- **Rendered key** (rendered from the final entity data): on the fetch path always written (subgraph is source of truth); on skip-fetch only for genuinely new keys. +This means if a request asked for `email:a@` but the entity actually has `email:b@`, the engine writes the `b@`-derived key and correctly skips the unproven `a@` key. +`WriteReason` (`refresh` / `backfill` / `derived`) is set on the resulting L2 write events. + +### 4.8 Ordering / threading + +The empty-list short-circuit runs before any cache or resolver call. +All key rendering, L1/L2 reads, merging, and L2 writes run on the **main thread**; +goroutines run subgraph HTTP only. +Per-request `Transform`s (used for normalization/denormalization of the merged entity shape) are ephemeral and must never be cached across requests (see [01-ARCHITECTURE-SPEC.md §3](../01-ARCHITECTURE-SPEC.md) and the resolve-package `CLAUDE.md`). + +--- + +## 5. Cache key & data shape + +**Key shapes produced** (all subject to the §4 key transform pipeline: `GlobalCacheKeyPrefix → subgraph header-hash prefix → L2CacheKeyInterceptor`): + +| Config | Rendered key | Notes | +|---|---|---| +| No `EntityKeyMappings` | `{"__typename":"Query","field":"topProducts","args":{"first":5}}` | `args` object omitted when the field has none; args are deterministic | +| `EntityKeyMappings` (scalar) | `{"__typename":"Product","key":{"upc":"top-1"}}` | Same shape an `_entities` fetch produces → entries are shared | +| `EntityKeyMappings` (list, `ArgumentIsEntityKey`) | one `{"__typename":"Product","key":{"upc":"top-1"}}` per element | Each `CacheKey` carries its `BatchIndex` | + +Number coercion in keys: `@key` values that arrive as numbers are coerced to strings (`setNestedKey`), so `id: 1` and `id: "1"` collide on the same entry (consistent with [01-ARCHITECTURE-SPEC.md §4](../01-ARCHITECTURE-SPEC.md)). + +**Stored shape:** +- **With `EntityKeyMappings`** the stored value is the **entity shape**, projected (L2 projection, `Passthrough = false`) to the provided fields so it round-trips and is interchangeable with an `_entities` fetch's L2 entry. +- **Without `EntityKeyMappings`** the stored value is the **root-field response** value for that field. +- L2 always projects (not passthrough); L1 (when used) would passthrough — see [01-ARCHITECTURE-SPEC.md §3.4](../01-ARCHITECTURE-SPEC.md). + L2 writes serialize to heap bytes via `MarshalTo` before handing to the backend (the heap boundary). + +**Root-field L1 promotion (optional):** +when a root-field fetch returns entities that carry `RootFieldL1EntityCacheKeyTemplates`, the loader promotes them into the per-request L1 under their entity keys so a later entity fetch in the same request can short-circuit. +Promotion derives the entity-shaped sub-`Object` from the fetch's `ProvidesData` and is **silently skipped** when `ProvidesData` is nil (defense-in-depth against test-constructed fetches). + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation seam** ([01-ARCHITECTURE-SPEC.md §7](../01-ARCHITECTURE-SPEC.md)): + this config rides entirely on the existing seam — it sets fields on `FetchCacheConfiguration`, supplies a `CacheKeyTemplate` (`RootQueryCacheKeyTemplate`), and flows through the same `cacheSkipFetch` / `cacheMustBeUpdated` two-boolean merge contract. + No new interface is introduced. + Copy isolation for every cached-into-response splice obeys the §3 StructuralCopy invariants and the Copy Budget. +- **`@key`** ([directives/key.md](key.md)): + hard dependency. + `EntityKeyMapping.EntityKeyField` must be a real `@key` field, and the derived key must be byte-identical to the entity fetch's key for sharing to work. +- **Entity caching config** ([directives/entity-cache-config.md](entity-cache-config.md)): + the two share L2 entries when `EntityKeyMappings` is set, so they should generally point at the **same `CacheName`** and use compatible TTLs. + Entity config comes first in the dependency order (root-field config reuses entity cache keys), per [02-DIRECTIVE-INVENTORY.md §3](../02-DIRECTIVE-INVENTORY.md). +- **`@provides`** ([directives/provides.md](provides.md)): + the fetch's `ProvidesData *Object` drives the L2 projection of the stored entity shape and the widening check on read, and is required for root-field L1 promotion. +- **`@requires`** ([directives/requires.md](requires.md)): + `@requires` fields are request-derived and must never be written into the cached shape — the projection excludes them. +- **Mutation / subscription configs** ([directives/mutation-cache-config.md](mutation-cache-config.md), [directives/subscription-cache-config.md](subscription-cache-config.md)): + downstream — they invalidate or populate the same entity-shaped entries this config can create via `EntityKeyMappings`. + +--- + +## 7. End-to-end test plan + +All tests use **exact** assertions: `assert.Equal` on the full value, never `Contains` / `GreaterOrEqual` / fuzzy comparisons. +Inline every query, cache key, and expected JSON at the assertion site. +Multi-key / multi-event struct literals are formatted **one item per line**. +Every `ClearLog()` is followed by `GetLog()` + full assertions before the next clear or end of test. +E2E subtests under `execution/engine/` are **self-contained** (inline setup, no shared helpers) per [execution/engine/CLAUDE.md](../../entity-caching-v2/execution/engine/CLAUDE.md). +The federation services are `accounts`, `products`, `reviews`; +the products subgraph exposes `topProducts(first: Int = 5): [Product]`, `product(upc: String!): Product`, `products(upcs: [String!]!): [Product]`, with `type Product @key(fields: "upc")`. + +### Case 1 — root-field response cache, miss then hit (no EntityKeyMappings) + +- **Config:** `RootFieldCacheConfiguration{TypeName:"Query", FieldName:"topProducts", CacheName:"default", TTL:1*time.Minute}`. +- **Query (both requests):** `query { topProducts(first: 2) { upc name price } }`. +- **What is cached:** the whole `topProducts` response under key `{"__typename":"Query","field":"topProducts","args":{"first":2}}`. +- **Assertions:** + - Request 1 and Request 2 produce byte-identical responses: + `assert.Equal(t, `{"data":{"topProducts":[{"upc":"top-1","name":"Trilby","price":11},{"upc":"top-2","name":"Fedora","price":22}]}}`, out1)` and `assert.Equal(t, out1, out2)`. + - After Request 1, assert the cache log with one `get` (miss) then one `set`, keys inline, then `ClearLog()`. + - After Request 2, assert the cache log is a single `get` with `Hits: []bool{true}` keyed by the inline root-field key, then assert the products subgraph was called **exactly once** total (`assert.Equal(t, 1, productsCalls)`). + +### Case 2 — EntityKeyMappings: root field and entity fetch share one L2 entry + +- **Config:** `RootFieldCacheConfiguration{TypeName:"Query", FieldName:"product", CacheName:"default", TTL:1*time.Minute, EntityKeyMappings: []EntityKeyMapping{{EntityTypeName:"Product", FieldMappings: []FieldMapping{{EntityKeyField:"upc", ArgumentPath:[]string{"upc"}}}}}}`, plus an `EntityCacheConfiguration` for `Product` on the same `CacheName`. +- **Query 1 (root):** `query { product(upc: "top-1") { upc name } }`. +- **Query 2 (entity path through reviews):** a query whose plan resolves `Product{upc:"top-1"}` via an `_entities` fetch. +- **What is cached:** one entry under `{"__typename":"Product","key":{"upc":"top-1"}}`. +- **Assertions:** + - `assert.Equal(t, `{"data":{"product":{"upc":"top-1","name":"Trilby"}}}`, out1)`. + - `assert.Equal(t, []byte(`{"upc":"top-1","name":"Trilby"}`), cache.GetValue(`{"__typename":"Product","key":{"upc":"top-1"}}`))` — entity-shaped, byte-exact. + - Query 2's entity fetch hits the **same** key: assert its cache log `get` has `Hits: []bool{true}` for the inline `{"__typename":"Product","key":{"upc":"top-1"}}` key, and assert the products subgraph entity resolver was **not** called. + +### Case 3 — batch list keys, all-miss then all-hit + +- **Config:** `RootFieldCacheConfiguration{TypeName:"Query", FieldName:"products", CacheName:"default", TTL:1*time.Minute, EntityKeyMappings: []EntityKeyMapping{{EntityTypeName:"Product", FieldMappings: []FieldMapping{{EntityKeyField:"upc", ArgumentPath:[]string{"upcs"}, ArgumentIsEntityKey:true}}}}}`. +- **Query (both):** `query($upcs:[String!]!){ products(upcs:$upcs){ upc name price } }` with variables `{"upcs":["top-1","top-2","top-3"]}`. +- **What is cached:** three entries, one per element, in entity shape. +- **Assertions:** + - `assert.Equal(t, `{"data":{"products":[{"upc":"top-1","name":"Trilby","price":11},{"upc":"top-2","name":"Fedora","price":22},{"upc":"top-3","name":"Boater","price":33}]}}`, out)` for both requests, and `assert.Equal(t, out1, out2)`. + - Request 1 cache log, vertical literals: + ```go + log := cache.GetLog() + assert.Equal(t, "get", log[0].Operation) + assert.Equal(t, []CacheLogItem{ + {Key: `{"__typename":"Product","key":{"upc":"top-1"}}`, Hit: false}, // first request, all miss + {Key: `{"__typename":"Product","key":{"upc":"top-2"}}`, Hit: false}, + {Key: `{"__typename":"Product","key":{"upc":"top-3"}}`, Hit: false}, + }, log[0].Items) + assert.Equal(t, "set", log[1].Operation) + // assert the three set keys inline here as well + cache.ClearLog() + ``` + - After populating, assert each stored entity value byte-exact: + `assert.Equal(t, `{"upc":"top-1","name":"Trilby","price":11}`, string(cache.GetValue(`{"__typename":"Product","key":{"upc":"top-1"}}`)))` (and likewise for `top-2`, `top-3`). + - Request 2 cache log: a single `get` with `Hit: true` for all three inline keys; assert the products subgraph was called exactly once (only on Request 1). + +### Case 4 — partial batch fetch (`PartialBatchLoad: true`) + +- **Config:** as Case 3 but `PartialBatchLoad: true`; pre-seed the cache with only `top-1` and `top-3`. +- **Query:** `products(upcs:["top-1","top-2","top-3"])`. +- **What happens:** only `top-2` is fetched from the subgraph; `top-1` and `top-3` are served from cache and spliced at their original positions. +- **Assertions:** + - `assert.Equal(t, `{"data":{"products":[{"upc":"top-1","name":"Trilby","price":11},{"upc":"top-2","name":"Fedora","price":22},{"upc":"top-3","name":"Boater","price":33}]}}`, out)`. + - Assert the subgraph received the **filtered** list (only `top-2`) — e.g. capture the subgraph request and `assert.Equal(t, []string{"top-2"}, sentUpcs)`. + - Cache log: one `get` (hits for `top-1`,`top-3`, miss for `top-2`, vertical literal) then one `set` for `top-2` only. + +### Case 5 — empty-list / null short-circuit + +- **Config:** as Case 3 (`ArgumentIsEntityKey: true`). +- **Queries:** `products(upcs: [])` and `products(upcs: $upcs)` with `{"upcs":null}`. +- **What happens:** the loader returns an empty response without calling the resolver or the cache. +- **Assertions:** + - `assert.Equal(t, `{"data":{"products":[]}}`, out)` (or the schema-correct empty/null shape — assert the exact bytes). + - Assert the products subgraph was called **exactly zero** times: `assert.Equal(t, 0, productsCalls)`. + - Assert the cache log is **empty** (`assert.Equal(t, []CacheLogEntry{}, cache.GetLog())`) — no `get`, no `set`. + +### Case 6 — aliased root field merge path (unit, `resolve` package) + +- **Setup:** `RootQueryCacheKeyTemplate` with a single root field whose `ResponseKey` is the alias. +- **Assertions:** + - `assert.Equal(t, []string{"u"}, template.EntityMergePath(pp))` for `u: user(id: $id)` (alias wins). + - `assert.Equal(t, []string{"user"}, template.EntityMergePath(pp))` when no alias is present. + - `assert.Equal(t, []string{"data","user"}, template.EntityMergePath(pp))` when an explicit `MergePath` is configured (explicit wins over derived). + +### Case 7 — variable remap on ArgumentPath (unit, `resolve` package) + +- **Assertions** for `resolveArgumentVariablePath`: + - `assert.Equal(t, []string{"input"}, got)` for single-segment remap `["id"]` with `RemapVariables["id"]=="input"`. + - `assert.Equal(t, []string{"input","ids"}, got)` for multi-segment remap on the first segment only. + - `assert.Equal(t, []string{"a","ids"}, got)` when the first segment is not remapped (pass-through). + +--- + +## 8. Acceptance criteria + +A reviewer can verify the re-implementation against this checklist: + +- [ ] `RootFieldCacheConfiguration` and its `EntityKeyMapping` / `FieldMapping` sub-structs exist with the fields in §2 and `FindByTypeAndField` returns `nil` when unconfigured (caching opt-in). +- [ ] A root field with caching enabled and **no** `EntityKeyMappings` caches its whole response under `{"__typename":"<Type>","field":"<field>","args":{...}}` (args omitted when none); a second identical request hits L2 and skips the subgraph (Case 1). +- [ ] With `EntityKeyMappings`, the L2 key is rendered in **entity shape** and is byte-identical to the `_entities` fetch key, so root query and entity fetch **share** the entry (Case 2). +- [ ] `ArgumentIsEntityKey` + list argument renders **one cache key per element** with `BatchIndex` set; all-miss then all-hit behaves as in Case 3. +- [ ] `PartialBatchLoad: true` fetches **only** missing elements (filtered variable list via `cloneVariablesWithBatchIndices`) and splices cached entities at their original positions; `false` is all-or-nothing (Case 4). +- [ ] Empty-list `[]` or `null` batch argument short-circuits to an empty response with **zero** subgraph calls and **zero** cache operations (Case 5). +- [ ] The merge path uses the response **alias** when present, the schema name otherwise, and an explicit `MergePath` overrides both (Case 6). +- [ ] `ArgumentPath` resolves through `ctx.RemapVariables` on its first segment only, and number `@key` values are coerced to strings so `1` and `"1"` collide (Case 7 + §5). +- [ ] Per-key smart backfill: the rendered (entity-derived) key is written and the unproven requested key is skipped on value mismatch; `WriteReason` is set on `EntityKeyMappings` writes (§4.7). +- [ ] All cache work runs on the main thread; goroutines do HTTP only; every cached-into-response splice StructuralCopies first (Copy Budget honored, no cache/response aliasing). +- [ ] Stored shape is L2-projected (no `@requires`, no aliases) and round-trips identically with the entity-fetch entry; L2 writes go through `MarshalTo` to heap bytes. +- [ ] Tests follow the §7 assertion discipline: full-value `assert.Equal`, inline literals, vertical multi-key literals, `ClearLog()` always paired with a verified `GetLog()`. diff --git a/docs/entity-caching/directives/subscription-cache-config.md b/docs/entity-caching/directives/subscription-cache-config.md new file mode 100644 index 0000000000..0bba678a93 --- /dev/null +++ b/docs/entity-caching/directives/subscription-cache-config.md @@ -0,0 +1,348 @@ +# Directive Specification: Subscription Population Config + +> Part of the entity-caching re-implementation document set. +> Cross-links: [adr/0009-subscription-cache-config.md](../adr/0009-subscription-cache-config.md), +> [01-ARCHITECTURE-SPEC.md](../01-ARCHITECTURE-SPEC.md), +> [02-DIRECTIVE-INVENTORY.md](../02-DIRECTIVE-INVENTORY.md). +> +> Re-implementation PR: gqtools PR 15 / PR-CACHE-SUBSCRIPTION. + +--- + +## 1. Purpose & responsibility + +Subscriptions emit fresh entity data on every event, +and without cache integration every subscriber that triggers a child entity fetch round-trips to the entity-owning subgraph for each event. +The subscription population config is a **configuration concept, not a wire directive**. +It binds a subscription root field to one of two per-event behaviors against the L2 cache. +On each trigger event, the resolver either **populates** L2 with the entity data carried by the event, +so later queries and subscription events can resolve that entity from cache, +or it **invalidates** the L2 entry, +deleting it so downstream readers do not serve a value the subscription has just superseded. +Population happens when the event carries entity fields beyond `@key`. +Invalidation happens when the event carries only `@key` fields and `EnableInvalidationOnKeyOnly` is set. +The cache operation runs exactly once per trigger event regardless of how many subscribers share the trigger, +and it completes before subscriber fanout so a child entity fetch on the same event sees the just-written value. + +--- + +## 2. SDL / configuration definition + +There is **no schema syntax**. +The concept arrives as a Go configuration struct on the federation metadata produced for a subgraph. + +Planner-side config (one entry per `(subgraph, entity type, subscription field)`): + +```go +type SubscriptionEntityPopulationConfiguration struct { + TypeName string // entity type, e.g. "Product" + FieldName string // subscription root field, e.g. "updateProductPrice" + CacheName string // which LoaderCache instance + TTL time.Duration // write TTL (populate mode only) + IncludeSubgraphHeaderPrefix bool // prepend header-hash key segment + EnableInvalidationOnKeyOnly bool // key-only event => delete instead of no-op +} + +type SubscriptionEntityPopulationConfigurations []SubscriptionEntityPopulationConfiguration +``` + +The lookup helper, consumed at plan time, matches on **both** fields: + +```go +func (c SubscriptionEntityPopulationConfigurations) FindByTypeAndFieldName(typeName, fieldName string) *SubscriptionEntityPopulationConfiguration +``` + +The planner translates a matched config into the resolver-facing struct carried on the subscription plan node: + +```go +type SubscriptionEntityCachePopulation struct { + Mode SubscriptionCacheMode // Populate | Invalidate + CacheKeyTemplate *EntityQueryCacheKeyTemplate // renders entity-shape keys + CacheName string + TTL time.Duration + IncludeSubgraphHeaderPrefix bool + DataSourceName string // subgraph name, for header-hash lookup + SubscriptionFieldName string // navigate into this response field before treating items as entities + EntityTypeName string // for __typename filtering / injection in cache keys +} +``` + +`SubscriptionEntityCachePopulation` is attached to the plan as `GraphQLSubscription.EntityCachePopulation`. +When it is `nil`, or its `CacheKeyTemplate` is `nil`, the subscription has no cache integration and nothing in this spec applies. + +The two callback hooks live on `ResolverOptions`: + +```go +OnSubscriptionCacheWrite func(CacheWriteEvent) +OnSubscriptionCacheInvalidate func(entityType string, keys []string) +``` + +--- + +## 3. Composition rules & validation + +There is no SDL and therefore no composition-time schema validation of new directive syntax. +The single mandatory-shape invariant lives in the config struct and its lookup: + +- **Both `TypeName` AND `FieldName` must be set.** + `FindByTypeAndFieldName` matches on `c[i].TypeName == typeName && c[i].FieldName == fieldName`. + An empty `FieldName` therefore makes the lookup silently fail, + the planner never attaches a `SubscriptionEntityCachePopulation`, + and populate/invalidate becomes a silent no-op. + This is the load-bearing rule the router integration must honor: + the router must set `FieldName` on **both** the populate path and the invalidate path. + +- **`FieldName` disambiguates entries that share an entity type.** + A single subgraph may emit two subscriptions on the same entity type with different field names and different TTLs. + Because the lookup keys on both `TypeName` and `FieldName`, + each subscription selects its own config independently, + for example `updateProductPrice` selecting a 30s-TTL entry while `updatedPrice` selects a 60s-TTL entry. + +- **Mode is derived at runtime, not configured directly.** + Populate vs Invalidate is not a configured enum on the planner struct; + it is decided from the event payload (fields beyond `@key`) combined with `EnableInvalidationOnKeyOnly`. + The planner sets `SubscriptionEntityCachePopulation.Mode` accordingly. + +--- + +## 4. Runtime semantics + +### 4.1 Where it acts + +This config acts **outside** the four-phase parallel resolver of [01-ARCHITECTURE-SPEC.md §5](../01-ARCHITECTURE-SPEC.md). +The four-phase machinery applies to a single query/mutation resolve pass. +Subscription population is a **trigger-level** operation: +it runs once per upstream trigger event, before the per-subscriber resolve passes that fan the event out. +Each per-subscriber resolve pass that runs a child entity fetch (a nested `_entities` fetch) still goes through the normal four-phase L1/L2 read/write path, +and benefits from whatever the trigger-level operation just populated or invalidated. + +### 4.2 Step-by-step on each trigger event + +1. The trigger emits an event with raw bytes `data`. +2. If `EntityCachePopulation` is `nil` or its `CacheKeyTemplate` is `nil`, skip — no cache integration. +3. If the request context captured at subscription creation has `ExecutionOptions.Caching.EnableL2Cache == false`, skip. +4. If `CacheName` is not present in `Resolver.options.Caches`, return without any cache operation and without error — defensive guard against misconfiguration; subscription delivery must not be blocked. +5. Parse `data` onto an arena-allocated value (never mutate the wire bytes handed to subscribers). +6. If `SubscriptionFieldName` is set, navigate into that response field before treating items as entities. +7. For each entity item: + - If the item has a `__typename` and it does **not** equal `EntityTypeName`, skip the item (union/interface member of a different concrete type — see §4.4). + - If the item lacks `__typename`, inject `EntityTypeName` as `__typename` on the parsed copy so the key template produces a correctly typed key. + - Render the cache key via the key pipeline of §4.3. +8. Determine the mode and perform the operation **once for the whole payload**: + - **Populate** (`SubscriptionCacheModePopulate`): `Set` one L2 entry per item — key from the template, value the JSON-encoded item (only the fields present in the response), TTL = config TTL. + - **Invalidate** (`SubscriptionCacheModeInvalidate`): `Delete` the L2 entry per item; no `Set`. +9. Backend `Set`/`Delete` errors must **not** propagate to the event flow; deliver the event regardless of cache health. +10. After the cache operation completes, fan the same `data` out to all N subscribers. + +### 4.3 Cache key construction (must match the standard flow) + +The pipeline is identical to the query-side `prepareCacheKeys` / `processExtensionsCacheInvalidation` pipeline of [01-ARCHITECTURE-SPEC.md §4](../01-ARCHITECTURE-SPEC.md): + +```text +GlobalCacheKeyPrefix → subgraph header-hash prefix → template-rendered key → L2CacheKeyInterceptor +``` + +- Steps 1–3 (global prefix, header-hash prefix when `IncludeSubgraphHeaderPrefix == true` and a `SubgraphHeadersBuilder` is set, then the template-rendered key) are concatenated into the prefix passed to `RenderCacheKeys`. +- Step 4 (`L2CacheKeyInterceptor`) is applied to the rendered keys afterwards. +- The header-hash prefix is looked up by `DataSourceName`. + +### 4.4 Alias and abstract-type handling + +- **Root-field alias.** + The subscription may alias its root field, for example `priceUpdate: updateProductPrice(...)`. + This does not break entity population: + the key is computed from `@key` fields on the entity, never from the response alias, + consistent with the alias-independence rule of [01-ARCHITECTURE-SPEC.md §4](../01-ARCHITECTURE-SPEC.md). +- **Union / interface return types.** + When the subscription returns a union or interface, + the planner resolves the configured concrete `TypeName` (for example `Product`) and attaches that config. + At runtime, `EntityTypeName` filtering (§4.2 step 7) keeps only items whose `__typename` matches the configured type, + and skips items of other concrete types — so an unconfigured member (for example `DigitalProduct`) is never cached. +- **`__typename` injection** is done on the parsed arena copy only, never on the bytes handed to subscribers. + +### 4.5 Ordering and threading constraints + +- **Once per trigger event, not once per subscriber.** + N subscribers sharing a trigger cause exactly ONE cache operation per affected entity, then a single fanout. +- **Populate completes before fanout.** + A child entity fetch issued during the same event's fanout must observe the just-populated L2 entry. + The implementation may achieve this synchronously, or async-then-wait, or via event-loop coordination — any approach that guarantees the ordering is acceptable. +- **Key strings must outlive the trigger arena.** + Keys rendered onto the trigger's arena must be cloned (e.g. `strings.Clone`) before being handed to the backend, + because the backend may retain them past the trigger's arena release — load-bearing for in-process arena-backed caches. + +### 4.6 Observability callbacks + +- **`OnSubscriptionCacheWrite`** (populate): invoked once per cached entry after a successful `Set`, with a `CacheWriteEvent` carrying `CacheKey` (final, post-interceptor), `EntityType` = `EntityTypeName`, `ByteSize` = bytes written, `DataSource` = `DataSourceName`, `CacheLevel` = `CacheLevelL2`, `TTL`, and `Source` = `CacheSourceSubscription`. +- **`OnSubscriptionCacheInvalidate`** (invalidate): invoked once with `(entityType, keys)` where `entityType` = `EntityTypeName` and `keys` is the slice of finalized (post-interceptor) deleted keys. + +--- + +## 5. Cache key & data shape + +- **Key shape** is the **entity** shape of [01-ARCHITECTURE-SPEC.md §4](../01-ARCHITECTURE-SPEC.md), + rendered by `EntityQueryCacheKeyTemplate` from `@key` fields only: + `{"__typename":"Product","key":{"upc":"top-4"}}`. + Numbers in keys coerce to strings; the key is alias-independent. + +- **Stored value shape (populate).** + The value is the JSON-encoded entity item containing **only the fields present in the response event**. + A subscription selecting `{upc, name, price}` writes exactly those fields plus `__typename`, + never an unselected field such as `inStock`. + This is **projection**, consistent with the L2 `Passthrough = false` rule of [01-ARCHITECTURE-SPEC.md §3.4](../01-ARCHITECTURE-SPEC.md): + L2 entries are minimal and self-contained so they round-trip across requests. + Because the value comes straight from the event payload, the projection is effectively "what the event carried". + +- **Invalidate shape.** + No value is stored; the rendered key is deleted. + +- **Key prefixing** follows §4.3. + With a header prefix configured, a stored key looks like `11111:{"__typename":"Product","key":{"upc":"top-4"}}`. + +--- + +## 6. Interaction with the foundation seam and other directives + +- **Foundation seam ([01-ARCHITECTURE-SPEC.md §7](../01-ARCHITECTURE-SPEC.md)).** + This config uses only the router-facing `LoaderCache` interface (`Set`, `Delete`) and the engine-internal `CacheKeyTemplate` (`EntityQueryCacheKeyTemplate.RenderCacheKeys`). + It does **not** touch the four-phase hooks (`prepareCacheKeys`, `bulkL2Lookup`, `mergeResult`, the two-boolean merge contract); + it is a sibling trigger-level path. + The arena/StructuralCopy isolation discipline of [§3](../01-ARCHITECTURE-SPEC.md) still applies: + parse onto the arena, never mutate wire bytes, clone keys before they cross the arena boundary. + +- **`@key`** ([directives/key.md](key.md)) is the identity source — the key template renders from `@key` fields, + and a key-only event (only `@key` selected) is what triggers invalidate mode. + +- **`@provides`** ([directives/provides.md](provides.md)) can make a child entity fetch unnecessary: + when reviews are resolved with `author { username }` via `@provides`, + no `User` entity fetch occurs and there are no cache operations at all for that child. + +- **Entity cache config** ([directives/entity-cache-config.md](entity-cache-config.md)) governs the **child** entity fetches that run during per-subscriber fanout. + Subscription population (root entity) and entity caching (child entities) compose: + a subscription can populate `Product` at the root while child `User` fetches independently read/write their own L2 entries. + +- **Root-field cache config** ([directives/root-field-cache-config.md](root-field-cache-config.md)) does **not** apply to subscription root fields. + Even if a `Subscription.<field>` root-field cache entry is configured, it must be ignored — subscriptions are never cached as root fields. + +- **Mutation cache config** ([directives/mutation-cache-config.md](mutation-cache-config.md)) is the sibling write/invalidate concern for mutations; the two share the L2 write/delete primitives but operate on different operation types. + +--- + +## 7. End-to-end test plan + +All tests live under `execution/engine/` and **must** follow [execution/engine/CLAUDE.md](../../entity-caching-v2/execution/engine/CLAUDE.md): +self-contained subtests, inline config and inline GraphQL, no shared helpers, full `assert.Equal` snapshots, vertical multi-item cache-log literals, and a `GetLog()` assertion after every `ClearLog()`. +Use the `products`, `accounts`, `reviews` federation services. +Assertion style is **mandatory**: `assert.Equal` on full values only — never `Contains`, `GreaterOrEqual`, `Greater`, or any fuzzy comparison. + +### Case 1 — Populate: event carries fields beyond `@key` + +- Config: inline `SubgraphCachingConfigs` for `products` with `SubscriptionEntityPopulation: {{TypeName: "Product", FieldName: "updateProductPrice", CacheName: "default", TTL: 30 * time.Second}}`. +- Query (inline): + `subscription UpdatePrice($upc: String!) { updateProductPrice(upc: $upc) { upc name price } }`, vars `{"upc":"top-4"}`, 1 event. +- Cached: one `Product` L2 entry under `{"__typename":"Product","key":{"upc":"top-4"}}`. +- Assertions: + - Full event message: + `assert.Equal(t, `{"id":"1","type":"data","payload":{"data":{"updateProductPrice":{"upc":"top-4","name":"Bowler","price":1}}}}`, messages[0])`. + - After `ClearLog()`, the full cache log (vertical, one item per line): + one `Set` entry, key `{"__typename":"Product","key":{"upc":"top-4"}}`, `TTL: 30 * time.Second`. + - Direct `Get` of the stored value, exact: + `assert.Equal(t, `{"upc":"top-4","name":"Bowler","price":1,"__typename":"Product"}`, string(entries[0].Value))`. + +### Case 2 — Projection: only selected fields are stored + +- Same config as Case 1. +- Query selects `{upc, name, price}` but NOT `inStock`. +- Assertion: stored value is exactly `{"upc":"top-4","name":"Bowler","price":1,"__typename":"Product"}` — `inStock` absent. + Full cache-log `Set` assertion as in Case 1. + +### Case 3 — List populate: multiple entities cached + +- Config for `products` with `FieldName: "updatedPrices"`, 30s TTL. +- Query (inline): `subscription { updatedPrices { upc name price reviews { body authorWithoutProvides { username } } } }`, 1 event. +- Cached: three `Product` L2 entries (`top-1`, `top-2`, `top-3`). +- Assertions: full event message; full cache log with one `Set` entry containing three items (vertical, one key per line, each `TTL: 30 * time.Second`); then exact `Get` value per product, e.g. `{"upc":"top-1","name":"Trilby","price":1,"__typename":"Product"}`. + +### Case 4 — Invalidate: key-only event with `EnableInvalidationOnKeyOnly` + +- Config for `products` with `EnableInvalidationOnKeyOnly: true`, plus `accounts` entity caching for `User`. +- Pre-populate L2 with `{"__typename":"Product","key":{"upc":"top-4"}}` → `{"upc":"top-4","name":"Bowler","price":64,"__typename":"Product"}`; assert the seed log (`set`). +- `ClearLog()`. Query (inline) selecting only `upc` plus `reviews { body authorWithoutProvides { username } }`, 1 event. +- Assertions: + - Full event message. + - Full cache log (vertical): one `Delete` of the Product key, then a `Get` (miss) for both `User` keys, then a `Set` of both `User` keys at 30s. + - `Get` of the Product key returns `nil` (deleted). + - `Get` of both `User` keys returns exact values, e.g. `{"__typename":"User","id":"5678","username":"User 5678"}`. + +### Case 5 — Key-only event WITHOUT the invalidation flag is a no-op + +- Same as Case 4 but `EnableInvalidationOnKeyOnly: false`. +- Assertions: no `Delete` in the cache log (only the child `User` `Get`/`Set`); the Product entry is unchanged — exact `Get` value still `{"upc":"top-4","name":"Bowler","price":64,"__typename":"Product"}`. + +### Case 6 — Not configured: no cache operations from subscription + +- `SubscriptionEntityPopulation` absent. +- Assertions: after `ClearLog()`, `GetLog()` equals the empty log (`sortCacheLogEntries([]CacheLogEntry(nil))`); a follow-up `Query` for the same product misses and calls the subgraph once (assert subgraph call count `== 1`). + +### Case 7 — Header prefix + +- Config with `IncludeSubgraphHeaderPrefix: true`; install a `SubgraphHeadersBuilder` whose `products` hash is `11111`. +- Assertions: full cache log shows a `Set` under `11111:{"__typename":"Product","key":{"upc":"top-4"}}`; direct `Get` with the prefixed key returns the exact value. + +### Case 8 — Once-per-trigger dedup (multiple subscribers) + +- Config populate for `Product`. +- Start 2 (and a separate subtest with 3) subscriptions on the same query/vars (shared trigger); warm up, drain, `ClearLog()`, emit one measured event. +- Assertions: both/all clients receive the identical event message; the cache log contains exactly ONE `Set` (not 2 / not 3); exact stored value asserted. + Mirror the dedup subtest for invalidate mode (exactly one `Delete`). + +### Case 9 — Union / interface return types + +- Configure the concrete `TypeName: "Product"` with the union field (`updateProductPriceUnion`) and, in a sibling subtest, the interface field (`updateProductPriceInterface`). +- Assertions: full event message; full cache log with one `Product` `Set`; exact stored value `{"__typename":"Product","upc":"top-4","name":"Bowler","price":1}`. +- Unconfigured-member subtests: configure `Product` but subscribe to a field that returns `DigitalProduct` — assert empty cache log and that both `Product` and `DigitalProduct` keys are `nil` in L2. + +### Case 10 — Root alias + +- Config populate for `Product`. +- Query aliases the root: `priceUpdate: updateProductPrice(...)`. +- Assertions: full event message under the alias; full cache log with one `Product` `Set` keyed by `@key` (alias-independent); exact stored value. + +### Case 11 — Field-name disambiguation (different TTLs) + +- One config block with two entries: `{TypeName:"Product", FieldName:"updateProductPrice", TTL: 30s}` and `{TypeName:"Product", FieldName:"updatedPrice", TTL: 60s}`. +- Two subtests: subscribing to `updateProductPrice` produces a `Set` at `TTL: 30 * time.Second`; subscribing to `updatedPrice` produces a `Set` at `TTL: 60 * time.Second`. +- Assertion: full cache log per subtest, each with a trailing comment naming which config was selected and why. + +### Case 12 — Callbacks fire + +- Populate subtest with `OnSubscriptionCacheWrite` set: assert it is invoked once per entry with the full `CacheWriteEvent` (exact `CacheKey`, `EntityType: "Product"`, exact `ByteSize`, `DataSource`, `CacheLevel: resolve.CacheLevelL2`, `TTL`, `Source: resolve.CacheSourceSubscription`). +- Invalidate subtest with `OnSubscriptionCacheInvalidate` set: assert it is invoked once with `entityType == "Product"` and the full finalized `keys` slice. + +### Case 13 — Per-request L2 disable + +- `CachingOptions{EnableL2Cache: false}` on the captured subscription context. +- Assertion: no cache operations occur (empty log) even though a populate config is present. + +--- + +## 8. Acceptance criteria + +A reviewer can verify each item below against the implementation and the tests above. + +- [ ] **No SDL.** No new wire directive is introduced; the concept is the Go config struct only. +- [ ] **Mandatory pair.** `FindByTypeAndFieldName` matches on both `TypeName` and `FieldName`; an empty `FieldName` silently no-ops, and the router sets `FieldName` on both populate and invalidate paths. +- [ ] **Populate writes projected data.** On an event carrying fields beyond `@key`, one L2 `Set` per entity item with the configured TTL; the stored value contains only the fields present in the response plus `__typename`. +- [ ] **Invalidate deletes.** On a key-only event with `EnableInvalidationOnKeyOnly`, one L2 `Delete` per item and no `Set`. +- [ ] **Key-only without the flag is a no-op** for the root entity (no `Delete`). +- [ ] **Entity key shape.** Keys are rendered by `EntityQueryCacheKeyTemplate` from `@key` fields only, alias-independent, numbers coerced to strings. +- [ ] **Key pipeline parity.** Global prefix → header-hash prefix (gated on `IncludeSubgraphHeaderPrefix` + `SubgraphHeadersBuilder`) → template key → `L2CacheKeyInterceptor`, matching the query-side flow. +- [ ] **Once per trigger.** Exactly one cache operation per affected entity per event, regardless of subscriber count; then a single fanout. +- [ ] **Ordering.** Populate completes before fanout; a child entity fetch on the same event observes the L2 hit. +- [ ] **`__typename` filtering & injection.** Union/interface members of unconfigured types are skipped; missing `__typename` is injected (on the parsed copy, never on wire bytes) before key rendering. +- [ ] **Alias safe.** Root-field aliases do not change the cache key or break population. +- [ ] **Root-field cache excluded.** A configured `Subscription.<field>` root-field cache entry never applies. +- [ ] **Defensive guards.** Missing `CacheName` and per-request `EnableL2Cache == false` both skip cleanly; backend `Set`/`Delete` errors never block delivery. +- [ ] **Key lifetime.** Rendered keys are cloned before crossing the trigger arena boundary into the backend. +- [ ] **Callbacks.** `OnSubscriptionCacheWrite` fires once per entry on populate with the full `CacheWriteEvent`; `OnSubscriptionCacheInvalidate` fires once with `(entityType, keys)` on invalidate. +- [ ] **Tests conform.** All E2E tests use `assert.Equal` on full values, inline literals, vertical multi-item cache-log literals, and a `GetLog()` assertion after every `ClearLog()`. diff --git a/v2/pkg/engine/resolve/cache.go b/v2/pkg/engine/resolve/cache.go new file mode 100644 index 0000000000..6019b77c9b --- /dev/null +++ b/v2/pkg/engine/resolve/cache.go @@ -0,0 +1,102 @@ +package resolve + +import ( + "context" + "time" + + "github.com/wundergraph/astjson" + "github.com/wundergraph/go-arena" +) + +// CacheWriteReason describes why the engine is writing an entry to the cache. +type CacheWriteReason string + +const ( + // CacheWriteReasonDefault leaves the write reason unspecified. + CacheWriteReasonDefault CacheWriteReason = "" + // CacheWriteReasonRefresh records a write of freshly fetched data. + CacheWriteReasonRefresh CacheWriteReason = "refresh" + // CacheWriteReasonBackfill records a write that fills another cache tier from known data. + CacheWriteReasonBackfill CacheWriteReason = "backfill" + // CacheWriteReasonDerived records a write derived from another cached or fetched value. + CacheWriteReasonDerived CacheWriteReason = "derived" +) + +// CacheEntry is the opaque JSON payload exchanged with a router-provided L2 cache backend. +type CacheEntry struct { + // Key is the fully rendered cache key for this entry. + Key string + // Value is the opaque JSON payload stored by the backend. + Value []byte + // TTL is the per-entry write expiration: 0 uses the backend default, negative means indefinite. + TTL time.Duration + // RemainingTTL is set by the backend on read; 0 means unknown. + RemainingTTL time.Duration + // WriteReason is set by the engine to classify the cache write. + WriteReason CacheWriteReason +} + +// LoaderCache is the router-facing L2 backend contract for named cache instances. +// +// Get must return a slice with the same length as keys, using nil entries for misses. +// Set receives per-entry TTLs on CacheEntry.TTL rather than a call-level TTL. +// Backends should be concurrency-safe. +type LoaderCache interface { + // Get returns cache entries aligned 1:1 with keys; nil entries represent cache misses. + Get(ctx context.Context, keys []string) ([]*CacheEntry, error) + // Set stores cache entries using each entry's TTL. + Set(ctx context.Context, entries []*CacheEntry) error + // Delete removes the provided keys from the backend. + Delete(ctx context.Context, keys []string) error +} + +// EntityCacheInvalidationConfig configures the resolve-side cache target for entity invalidation. +type EntityCacheInvalidationConfig struct { + // CacheName selects the named L2 cache backend. + CacheName string + // IncludeSubgraphHeaderPrefix includes the subgraph header-hash prefix in invalidation keys. + IncludeSubgraphHeaderPrefix bool +} + +// CacheKey is the rendered key data for one input item. +type CacheKey struct { + // Item is the input value this key was rendered from. + Item *astjson.Value + // Keys contains the rendered cache key strings for Item. + Keys []string + // FromCache is populated with the cached value on a cache hit; nil otherwise. + FromCache *astjson.Value +} + +// CacheKeyTemplate renders cache keys for a fetch inside the resolve engine. +// +// The template is engine-internal because it depends on arena and astjson values. +// The router configures cache keys declaratively and does not implement this interface. +type CacheKeyTemplate interface { + // RenderCacheKeys renders cache keys for the provided items and key prefix. + RenderCacheKeys(a arena.Arena, ctx *Context, items []*astjson.Value, prefix string) ([]*CacheKey, error) + // IsEntityFetch reports whether the template renders entity-fetch keys. + IsEntityFetch() bool + // BatchEntityKeyArgumentPath returns the batch argument path, or nil when batch support is not needed. + BatchEntityKeyArgumentPath() []string + // EntityMergePath returns the merge path for entity payloads, or nil when full payloads are cached. + EntityMergePath(pp PostProcessingConfiguration) []string +} + +// FetchCacheConfiguration describes the per-fetch cache settings attached by planning. +// +// Future cache layers extend this shape with request-scoped fields, analytics, and argument metadata. +type FetchCacheConfiguration struct { + // CacheName selects the named L2 cache backend for this fetch. + CacheName string + // EnableL2Cache enables reads and writes against the selected L2 cache backend. + EnableL2Cache bool + // TTL is the per-entry L2 write expiration for this fetch. + TTL time.Duration + // KeyTemplate renders L1 and L2 keys for this fetch. + KeyTemplate CacheKeyTemplate + // ProvidesData describes the per-fetch field shape used for cache payloads. + ProvidesData *Object + // UseL1Cache enables per-request L1 cache reads and writes for this fetch. + UseL1Cache bool +} From f042786343a326db9edad5737904105fcef9ef51 Mon Sep 17 00:00:00 2001 From: Jens Neuse <jens.neuse@gmx.de> Date: Mon, 15 Jun 2026 21:46:28 +0200 Subject: [PATCH 3/4] feat(resolve): add ProvidesData alias metadata + ComputeHasAliases Add the alias-aware metadata the entity cache uses to normalize and denormalize a fetch's field shape: Field.OriginalName (schema name behind an alias), Field.CacheArgs (per-field argument metadata for the cache-key arg hash), Object.HasAliases (fast-path flag), the CacheFieldArg type, and the pure ComputeHasAliases(*Object) tree walk. All new fields are json:",omitempty" and default to zero, so existing JSON snapshots are unchanged; Object.Copy/Field.Copy propagate them. Nothing populates or reads these yet (the planner populates them and the copy helpers consume them in later PRs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- v2/pkg/engine/resolve/node_object.go | 74 ++++++++++--- v2/pkg/engine/resolve/providesdata_test.go | 114 +++++++++++++++++++++ 2 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 v2/pkg/engine/resolve/providesdata_test.go diff --git a/v2/pkg/engine/resolve/node_object.go b/v2/pkg/engine/resolve/node_object.go index d8a3d8649b..4e5026eb44 100644 --- a/v2/pkg/engine/resolve/node_object.go +++ b/v2/pkg/engine/resolve/node_object.go @@ -6,9 +6,10 @@ import ( ) type Object struct { - Nullable bool - Path []string - Fields []*Field + Nullable bool + Path []string + Fields []*Field + HasAliases bool `json:",omitempty"` PossibleTypes map[string]struct{} `json:"-"` SourceName string `json:"-"` @@ -21,9 +22,10 @@ func (o *Object) Copy() Node { fields[i] = f.Copy() } return &Object{ - Nullable: o.Nullable, - Path: o.Path, - Fields: fields, + Nullable: o.Nullable, + Path: o.Path, + Fields: fields, + HasAliases: o.HasAliases, } } @@ -102,7 +104,9 @@ func (*EmptyObject) Copy() Node { type Field struct { Name []byte + OriginalName []byte `json:",omitempty"` Value Node + CacheArgs []CacheFieldArg `json:",omitempty"` Position Position Defer *DeferField Stream *StreamField @@ -118,13 +122,15 @@ type ParentOnTypeNames struct { func (f *Field) Copy() *Field { return &Field{ - Name: f.Name, - Value: f.Value.Copy(), - Position: f.Position, - Defer: f.Defer, - Stream: f.Stream, - OnTypeNames: f.OnTypeNames, - Info: f.Info, + Name: f.Name, + OriginalName: f.OriginalName, + Value: f.Value.Copy(), + CacheArgs: f.CacheArgs, + Position: f.Position, + Defer: f.Defer, + Stream: f.Stream, + OnTypeNames: f.OnTypeNames, + Info: f.Info, } } @@ -189,8 +195,50 @@ type Position struct { Column uint32 } +type CacheFieldArg struct { + ArgName string + VariableName string +} + type StreamField struct { InitialBatchSize int } type DeferField struct{} + +func ComputeHasAliases(o *Object) bool { + if o == nil { + return false + } + + for _, field := range o.Fields { + if field == nil { + continue + } + if len(field.CacheArgs) > 0 { + return true + } + if len(field.OriginalName) > 0 && !bytes.Equal(field.OriginalName, field.Name) { + return true + } + if nodeHasAliases(field.Value) { + return true + } + } + + return false +} + +func nodeHasAliases(node Node) bool { + switch n := node.(type) { + case *Object: + return ComputeHasAliases(n) + case *Array: + if n == nil { + return false + } + return nodeHasAliases(n.Item) + default: + return false + } +} diff --git a/v2/pkg/engine/resolve/providesdata_test.go b/v2/pkg/engine/resolve/providesdata_test.go new file mode 100644 index 0000000000..c69ce55ba2 --- /dev/null +++ b/v2/pkg/engine/resolve/providesdata_test.go @@ -0,0 +1,114 @@ +package resolve + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestComputeHasAliases(t *testing.T) { + tests := []struct { + name string + object *Object + expected bool + }{ + { + name: "nil object", + object: nil, + expected: false, + }, + { + name: "object with one plain field", + object: &Object{ + Fields: []*Field{ + { + Name: []byte("realName"), + Value: &String{}, + }, + }, + }, + expected: false, + }, + { + name: "object with one aliased field", + object: &Object{ + Fields: []*Field{ + { + Name: []byte("a"), + OriginalName: []byte("realName"), + Value: &String{}, + }, + }, + }, + expected: true, + }, + { + name: "object with one field carrying a CacheArg", + object: &Object{ + Fields: []*Field{ + { + Name: []byte("realName"), + Value: &String{}, + CacheArgs: []CacheFieldArg{ + { + ArgName: "format", + VariableName: "format", + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "nested object with inner aliased field", + object: &Object{ + Fields: []*Field{ + { + Name: []byte("profile"), + Value: &Object{ + Fields: []*Field{ + { + Name: []byte("a"), + OriginalName: []byte("realName"), + Value: &String{}, + }, + }, + }, + }, + }, + }, + expected: true, + }, + { + name: "array of object with aliased item field", + object: &Object{ + Fields: []*Field{ + { + Name: []byte("profiles"), + Value: &Array{ + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("a"), + OriginalName: []byte("realName"), + Value: &String{}, + }, + }, + }, + }, + }, + }, + }, + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ComputeHasAliases(test.object) + + assert.Equal(t, test.expected, actual) + }) + } +} From 0a441787e2f414a5984cec40b2654ca03794a48a Mon Sep 17 00:00:00 2001 From: Jens Neuse <jens.neuse@gmx.de> Date: Mon, 15 Jun 2026 21:53:40 +0200 Subject: [PATCH 4/4] feat(resolve): add StructuralCopy cache transform helpers Add the four Loader copy helpers that isolate a cached value from the live response tree while renaming the field shape between response aliases and schema names: structuralCopyNormalized / structuralCopyDenormalized (L2, project unlisted fields) and their *Passthrough variants (L1, keep unlisted fields). The buildNormalizeTransform / buildDenormalizeTransform builders turn a ProvidesData *Object into an astjson.Transform, recursing through nested objects and arrays of objects. New file only; nothing calls the helpers yet (the loader wires them in a later PR). Round-trip, projection, passthrough, nested, and array cases are covered with exact full-JSON assertions, race-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../engine/resolve/loader_cache_transform.go | 142 +++++++++++++++ .../resolve/loader_cache_transform_test.go | 164 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 v2/pkg/engine/resolve/loader_cache_transform.go create mode 100644 v2/pkg/engine/resolve/loader_cache_transform_test.go diff --git a/v2/pkg/engine/resolve/loader_cache_transform.go b/v2/pkg/engine/resolve/loader_cache_transform.go new file mode 100644 index 0000000000..1075236664 --- /dev/null +++ b/v2/pkg/engine/resolve/loader_cache_transform.go @@ -0,0 +1,142 @@ +package resolve + +import "github.com/wundergraph/astjson" + +// structuralCopyNormalized performs the L2 write copy: alias keys are renamed to +// schema keys and unlisted fields are projected out. +func (l *Loader) structuralCopyNormalized(v *astjson.Value, provides *Object) *astjson.Value { + if provides == nil { + return astjson.StructuralCopy(l.jsonArena, v) + } + return astjson.StructuralCopyWithTransform(l.jsonArena, v, buildNormalizeTransform(provides, false)) +} + +// structuralCopyDenormalized performs the L2 read copy: schema keys are renamed +// back to alias keys and unlisted fields are projected out. +func (l *Loader) structuralCopyDenormalized(v *astjson.Value, provides *Object) *astjson.Value { + if provides == nil { + return astjson.StructuralCopy(l.jsonArena, v) + } + return astjson.StructuralCopyWithTransform(l.jsonArena, v, buildDenormalizeTransform(provides, false)) +} + +// structuralCopyNormalizedPassthrough performs the L1 write copy: alias keys are +// renamed to schema keys while unlisted fields are kept. +func (l *Loader) structuralCopyNormalizedPassthrough(v *astjson.Value, provides *Object) *astjson.Value { + if provides == nil { + return astjson.StructuralCopy(l.jsonArena, v) + } + return astjson.StructuralCopyWithTransform(l.jsonArena, v, buildNormalizeTransform(provides, true)) +} + +// structuralCopyDenormalizedPassthrough performs the L1 read copy: schema keys +// are renamed back to alias keys while unlisted fields are kept. +func (l *Loader) structuralCopyDenormalizedPassthrough(v *astjson.Value, provides *Object) *astjson.Value { + if provides == nil { + return astjson.StructuralCopy(l.jsonArena, v) + } + return astjson.StructuralCopyWithTransform(l.jsonArena, v, buildDenormalizeTransform(provides, true)) +} + +// buildNormalizeTransform builds an alias-to-schema transform for cache writes. +// With passthrough false it is the L2 projection shape; with passthrough true it +// is the L1 keep-extra-fields shape. +func buildNormalizeTransform(provides *Object, passthrough bool) *astjson.Transform { + if provides == nil { + return nil + } + + transform := &astjson.Transform{ + Entries: make([]astjson.TransformEntry, 0, len(provides.Fields)), + Passthrough: passthrough, + } + for _, field := range provides.Fields { + if field == nil { + continue + } + transform.Entries = append(transform.Entries, astjson.TransformEntry{ + InputKey: fieldAliasName(field), + OutputKey: fieldSchemaName(field), + Child: buildNormalizeChildTransform(field.Value, passthrough), + }) + } + return transform +} + +// buildDenormalizeTransform builds a schema-to-alias transform for cache reads. +// With passthrough false it is the L2 projection shape; with passthrough true it +// is the L1 keep-extra-fields shape. +func buildDenormalizeTransform(provides *Object, passthrough bool) *astjson.Transform { + if provides == nil { + return nil + } + + transform := &astjson.Transform{ + Entries: make([]astjson.TransformEntry, 0, len(provides.Fields)), + Passthrough: passthrough, + } + for _, field := range provides.Fields { + if field == nil { + continue + } + transform.Entries = append(transform.Entries, astjson.TransformEntry{ + InputKey: fieldSchemaName(field), + OutputKey: fieldAliasName(field), + Child: buildDenormalizeChildTransform(field.Value, passthrough), + }) + } + return transform +} + +// buildNormalizeChildTransform builds nested alias-to-schema transforms for the +// L1 and L2 write helpers. +func buildNormalizeChildTransform(node Node, passthrough bool) *astjson.Transform { + switch value := node.(type) { + case *Object: + return buildNormalizeTransform(value, passthrough) + case *Array: + itemObject, ok := value.Item.(*Object) + if !ok { + return nil + } + return &astjson.Transform{ + ArrayItem: buildNormalizeTransform(itemObject, passthrough), + Passthrough: passthrough, + } + default: + return nil + } +} + +// buildDenormalizeChildTransform builds nested schema-to-alias transforms for +// the L1 and L2 read helpers. +func buildDenormalizeChildTransform(node Node, passthrough bool) *astjson.Transform { + switch value := node.(type) { + case *Object: + return buildDenormalizeTransform(value, passthrough) + case *Array: + itemObject, ok := value.Item.(*Object) + if !ok { + return nil + } + return &astjson.Transform{ + ArrayItem: buildDenormalizeTransform(itemObject, passthrough), + Passthrough: passthrough, + } + default: + return nil + } +} + +// fieldAliasName returns the response key used by denormalized cache reads. +func fieldAliasName(field *Field) string { + return string(field.Name) +} + +// fieldSchemaName returns the schema key used by normalized cache writes. +func fieldSchemaName(field *Field) string { + if len(field.OriginalName) > 0 { + return string(field.OriginalName) + } + return string(field.Name) +} diff --git a/v2/pkg/engine/resolve/loader_cache_transform_test.go b/v2/pkg/engine/resolve/loader_cache_transform_test.go new file mode 100644 index 0000000000..21a36c01eb --- /dev/null +++ b/v2/pkg/engine/resolve/loader_cache_transform_test.go @@ -0,0 +1,164 @@ +package resolve + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/astjson" + "github.com/wundergraph/go-arena" +) + +func TestLoaderCacheTransformStructuralCopy(t *testing.T) { + t.Run("round trip aliases through normalized and denormalized copies", func(t *testing.T) { + loader, release := newLoaderCacheTransformTestLoader() + defer release() + + provides := &Object{ + Fields: []*Field{ + { + Name: []byte("fullName"), + OriginalName: []byte("name"), + Value: &String{}, + }, + { + Name: []byte("years"), + OriginalName: []byte("age"), + Value: &Integer{}, + }, + }, + } + input := parseLoaderCacheTransformTestValue(t, loader, `{"fullName":"Bob","years":3}`) + + normalized := loader.structuralCopyNormalized(input, provides) + assert.Equal(t, `{"name":"Bob","age":3}`, string(normalized.MarshalTo(nil))) + + denormalized := loader.structuralCopyDenormalized(normalized, provides) + assert.Equal(t, `{"fullName":"Bob","years":3}`, string(denormalized.MarshalTo(nil))) + }) + + t.Run("projection drops unlisted fields", func(t *testing.T) { + loader, release := newLoaderCacheTransformTestLoader() + defer release() + + provides := nameAndAgeProvidesData() + input := parseLoaderCacheTransformTestValue(t, loader, `{"fullName":"Bob","years":3,"secret":"x"}`) + + normalized := loader.structuralCopyNormalized(input, provides) + assert.Equal(t, `{"name":"Bob","age":3}`, string(normalized.MarshalTo(nil))) + }) + + t.Run("passthrough keeps unlisted fields", func(t *testing.T) { + loader, release := newLoaderCacheTransformTestLoader() + defer release() + + provides := nameAndAgeProvidesData() + input := parseLoaderCacheTransformTestValue(t, loader, `{"fullName":"Bob","years":3,"secret":"x"}`) + + normalized := loader.structuralCopyNormalizedPassthrough(input, provides) + assert.Equal(t, `{"name":"Bob","age":3,"secret":"x"}`, string(normalized.MarshalTo(nil))) + + denormalized := loader.structuralCopyDenormalizedPassthrough(normalized, provides) + assert.Equal(t, `{"fullName":"Bob","years":3,"secret":"x"}`, string(denormalized.MarshalTo(nil))) + }) + + t.Run("nested object rename", func(t *testing.T) { + loader, release := newLoaderCacheTransformTestLoader() + defer release() + + provides := &Object{ + Fields: []*Field{ + { + Name: []byte("profile"), + Value: &Object{ + Fields: []*Field{ + { + Name: []byte("displayName"), + OriginalName: []byte("name"), + Value: &String{}, + }, + }, + }, + }, + }, + } + input := parseLoaderCacheTransformTestValue(t, loader, `{"profile":{"displayName":"Bob"}}`) + + normalized := loader.structuralCopyNormalized(input, provides) + assert.Equal(t, `{"profile":{"name":"Bob"}}`, string(normalized.MarshalTo(nil))) + }) + + t.Run("array of object rename", func(t *testing.T) { + loader, release := newLoaderCacheTransformTestLoader() + defer release() + + provides := &Object{ + Fields: []*Field{ + { + Name: []byte("friends"), + Value: &Array{ + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("fullName"), + OriginalName: []byte("name"), + Value: &String{}, + }, + }, + }, + }, + }, + }, + } + input := parseLoaderCacheTransformTestValue(t, loader, `{"friends":[{"fullName":"Ada"},{"fullName":"Lin"}]}`) + + normalized := loader.structuralCopyNormalized(input, provides) + assert.Equal(t, `{"friends":[{"name":"Ada"},{"name":"Lin"}]}`, string(normalized.MarshalTo(nil))) + }) + + t.Run("nil provides uses plain structural copy", func(t *testing.T) { + loader, release := newLoaderCacheTransformTestLoader() + defer release() + + input := parseLoaderCacheTransformTestValue(t, loader, `{"fullName":"Bob","years":3,"secret":"x"}`) + + copied := loader.structuralCopyNormalized(input, nil) + assert.Equal(t, `{"fullName":"Bob","years":3,"secret":"x"}`, string(copied.MarshalTo(nil))) + }) +} + +func newLoaderCacheTransformTestLoader() (*Loader, func()) { + pool := arena.NewArenaPool() + item := pool.Acquire(0) + loader := &Loader{ + jsonArena: item.Arena, + } + return loader, func() { + pool.Release(item) + } +} + +func parseLoaderCacheTransformTestValue(t *testing.T, loader *Loader, data string) *astjson.Value { + t.Helper() + + value, err := astjson.ParseBytesWithArena(loader.jsonArena, []byte(data)) + assert.NoError(t, err) + return value +} + +func nameAndAgeProvidesData() *Object { + return &Object{ + Fields: []*Field{ + { + Name: []byte("fullName"), + OriginalName: []byte("name"), + Value: &String{}, + }, + { + Name: []byte("years"), + OriginalName: []byte("age"), + Value: &Integer{}, + }, + }, + } +}