diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index b9782c0eb5..6a355ffa89 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -162,6 +162,18 @@ type ExecutionOptions struct { // // Lookup Order (entity fetches): L1 -> L2 -> Subgraph Fetch // Lookup Order (root fetches): L2 -> Subgraph Fetch (no L1) +// L2CacheKeyInterceptorInfo provides metadata about the cache key being transformed. +type L2CacheKeyInterceptorInfo struct { + SubgraphName string + CacheName string +} + +// L2CacheKeyInterceptor transforms L2 cache key strings before they are used +// for cache lookups and writes. Called once per cache key during key preparation. +// The ctx parameter is the request's context.Context, allowing access to +// request-scoped values (e.g., tenant ID from middleware). +type L2CacheKeyInterceptor func(ctx context.Context, key string, info L2CacheKeyInterceptorInfo) string + type CachingOptions struct { // EnableL1Cache enables per-request in-memory entity caching. // L1 prevents redundant fetches for the same entity within a single request. @@ -181,6 +193,12 @@ type CachingOptions struct { // When false (default), GetCacheStats() returns an empty snapshot. // The analytics collector is nil-guarded so the disabled path has zero overhead. EnableCacheAnalytics bool + // L2CacheKeyInterceptor, when set, transforms L2 cache key strings before + // they are used for lookups, writes, and deletions. This allows library users + // to add custom prefixes/suffixes (e.g., tenant isolation) without modifying + // graphql-go-tools internals. Does not affect L1 cache keys. + // Default: nil (no transformation) + L2CacheKeyInterceptor L2CacheKeyInterceptor } type FieldValue struct { diff --git a/v2/pkg/engine/resolve/l2_cache_key_interceptor_test.go b/v2/pkg/engine/resolve/l2_cache_key_interceptor_test.go new file mode 100644 index 0000000000..0b65246470 --- /dev/null +++ b/v2/pkg/engine/resolve/l2_cache_key_interceptor_test.go @@ -0,0 +1,599 @@ +package resolve + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/go-arena" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/fastjsonext" +) + +// helper functions to reduce boilerplate in interceptor tests + +func newProductCacheKeyTemplate() *EntityQueryCacheKeyTemplate { + return &EntityQueryCacheKeyTemplate{ + Keys: NewResolvableObjectVariable(&Object{ + Fields: []*Field{ + {Name: []byte("__typename"), Value: &String{Path: []string{"__typename"}}}, + {Name: []byte("id"), Value: &String{Path: []string{"id"}}}, + }, + }), + } +} + +func newProductProvidesData() *Object { + return &Object{ + Fields: []*Field{ + {Name: []byte("id"), Value: &Scalar{Path: []string{"id"}, Nullable: false}}, + {Name: []byte("name"), Value: &Scalar{Path: []string{"name"}, Nullable: false}}, + }, + } +} + +func newEntityFetchSegments() []TemplateSegment { + return []TemplateSegment{ + { + Data: []byte(`{"method":"POST","url":"http://products.service","body":{"query":"query($representations: [_Any!]!){_entities(representations: $representations){__typename ... on Product {id name}}}","variables":{"representations":[`), + SegmentType: StaticSegmentType, + }, + { + SegmentType: VariableSegmentType, + VariableKind: ResolvableObjectVariableKind, + Renderer: NewGraphQLVariableResolveRenderer(&Object{ + Fields: []*Field{ + {Name: []byte("__typename"), Value: &String{Path: []string{"__typename"}}}, + {Name: []byte("id"), Value: &String{Path: []string{"id"}}}, + }, + }), + }, + { + Data: []byte(`]}}}`), + SegmentType: StaticSegmentType, + }, + } +} + +func newProductResponseData() *Object { + return &Object{ + Fields: []*Field{ + { + Name: []byte("product"), + Value: &Object{ + Path: []string{"product"}, + Fields: []*Field{ + {Name: []byte("id"), Value: &String{Path: []string{"id"}}}, + {Name: []byte("name"), Value: &String{Path: []string{"name"}}}, + }, + }, + }, + }, + } +} + +func TestL2CacheKeyInterceptor(t *testing.T) { + t.Run("interceptor transforms L2 keys for entity fetch", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cache := NewFakeLoaderCache() + + // Root datasource + rootDS := NewMockDataSource(ctrl) + rootDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"product":{"__typename":"Product","id":"prod-1"}}}`), nil + }).Times(1) + + // Entity datasource - called once (cache miss on first request) + entityDS := NewMockDataSource(ctrl) + entityDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"_entities":[{"__typename":"Product","id":"prod-1","name":"Product One"}]}}`), nil + }).Times(1) + + response := &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: rootDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {Data: []byte(`{"method":"POST","url":"http://root.service","body":{"query":"{product {__typename id}}"}}`), SegmentType: StaticSegmentType}, + }, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query"), + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: entityDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + Caching: FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: newProductCacheKeyTemplate(), + UseL1Cache: true, + }, + }, + InputTemplate: InputTemplate{Segments: newEntityFetchSegments()}, + Info: &FetchInfo{ + DataSourceID: "products", + DataSourceName: "products", + OperationType: ast.OperationTypeQuery, + ProvidesData: newProductProvidesData(), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query.product", ObjectPath("product")), + ), + Data: newProductResponseData(), + } + + loader := &Loader{ + caches: map[string]LoaderCache{"default": cache}, + } + + ctx := NewContext(context.Background()) + ctx.ExecutionOptions.DisableSubgraphRequestDeduplication = true + ctx.ExecutionOptions.Caching.EnableL2Cache = true + ctx.ExecutionOptions.Caching.L2CacheKeyInterceptor = func(_ context.Context, key string, _ L2CacheKeyInterceptorInfo) string { + return "tenant-abc:" + key + } + + ar := arena.NewMonotonicArena(arena.WithMinBufferSize(1024)) + resolvable := NewResolvable(ar, ResolvableOptions{}) + err := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + require.NoError(t, err) + + // First request: cache miss, fetches from datasource, stores in L2 + err = loader.LoadGraphQLResponseData(ctx, response, resolvable) + require.NoError(t, err) + + out := fastjsonext.PrintGraphQLResponse(resolvable.data, resolvable.errors) + assert.Equal(t, `{"data":{"product":{"__typename":"Product","id":"prod-1","name":"Product One"}}}`, out) + + cacheLog := cache.GetLog() + + // Find set operation and verify keys have prefix + var setKeys []string + for _, entry := range cacheLog { + if entry.Operation == "set" { + setKeys = append(setKeys, entry.Keys...) + } + } + require.Equal(t, 1, len(setKeys), "expected exactly 1 cache set key") + assert.Equal(t, `tenant-abc:{"__typename":"Product","key":{"id":"prod-1"}}`, setKeys[0]) + + // Now do a second request against the same cache — should get a cache hit + // Need a new root DS that returns the same data and a new entity DS that should NOT be called + cache.ClearLog() + + ctrl2 := gomock.NewController(t) + defer ctrl2.Finish() + + rootDS2 := NewMockDataSource(ctrl2) + rootDS2.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"product":{"__typename":"Product","id":"prod-1"}}}`), nil + }).Times(1) + + entityDS2 := NewMockDataSource(ctrl2) + entityDS2.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) // Should NOT be called — cache hit + + response2 := &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: rootDS2, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {Data: []byte(`{"method":"POST","url":"http://root.service","body":{"query":"{product {__typename id}}"}}`), SegmentType: StaticSegmentType}, + }, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query"), + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: entityDS2, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + Caching: FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: newProductCacheKeyTemplate(), + UseL1Cache: true, + }, + }, + InputTemplate: InputTemplate{Segments: newEntityFetchSegments()}, + Info: &FetchInfo{ + DataSourceID: "products", + DataSourceName: "products", + OperationType: ast.OperationTypeQuery, + ProvidesData: newProductProvidesData(), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query.product", ObjectPath("product")), + ), + Data: newProductResponseData(), + } + + loader2 := &Loader{ + caches: map[string]LoaderCache{"default": cache}, + } + + ctx2 := NewContext(context.Background()) + ctx2.ExecutionOptions.DisableSubgraphRequestDeduplication = true + ctx2.ExecutionOptions.Caching.EnableL2Cache = true + ctx2.ExecutionOptions.Caching.L2CacheKeyInterceptor = func(_ context.Context, key string, _ L2CacheKeyInterceptorInfo) string { + return "tenant-abc:" + key + } + + ar2 := arena.NewMonotonicArena(arena.WithMinBufferSize(1024)) + resolvable2 := NewResolvable(ar2, ResolvableOptions{}) + err = resolvable2.Init(ctx2, nil, ast.OperationTypeQuery) + require.NoError(t, err) + + err = loader2.LoadGraphQLResponseData(ctx2, response2, resolvable2) + require.NoError(t, err) + + cacheLog2 := cache.GetLog() + var getHits []bool + var getKeys []string + for _, entry := range cacheLog2 { + if entry.Operation == "get" { + getKeys = append(getKeys, entry.Keys...) + getHits = append(getHits, entry.Hits...) + } + } + require.Equal(t, 1, len(getKeys), "expected exactly 1 cache get key") + assert.Equal(t, `tenant-abc:{"__typename":"Product","key":{"id":"prod-1"}}`, getKeys[0]) + assert.Equal(t, true, getHits[0], "second request should be a cache hit") + }) + + t.Run("interceptor does NOT affect L1 keys", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cache := NewFakeLoaderCache() + + // Root datasource + rootDS := NewMockDataSource(ctrl) + rootDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"product":{"__typename":"Product","id":"prod-1"}}}`), nil + }).Times(1) + + // First entity fetch - should be called (populates L1) + entityDS1 := NewMockDataSource(ctrl) + entityDS1.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"_entities":[{"__typename":"Product","id":"prod-1","name":"Product One"}]}}`), nil + }).Times(1) + + // Second entity fetch for SAME entity - should NOT be called (L1 hit) + entityDS2 := NewMockDataSource(ctrl) + entityDS2.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) // L1 should prevent this call + + response := &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: rootDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {Data: []byte(`{"method":"POST","url":"http://root.service","body":{"query":"{product {__typename id}}"}}`), SegmentType: StaticSegmentType}, + }, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query"), + // First entity fetch — populates L1 + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: entityDS1, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + Caching: FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: newProductCacheKeyTemplate(), + UseL1Cache: true, + }, + }, + InputTemplate: InputTemplate{Segments: newEntityFetchSegments()}, + Info: &FetchInfo{ + DataSourceID: "products", + DataSourceName: "products", + OperationType: ast.OperationTypeQuery, + ProvidesData: newProductProvidesData(), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query.product", ObjectPath("product")), + // Second entity fetch for SAME entity — should hit L1 cache + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: entityDS2, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + Caching: FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: newProductCacheKeyTemplate(), + UseL1Cache: true, + }, + }, + InputTemplate: InputTemplate{Segments: newEntityFetchSegments()}, + Info: &FetchInfo{ + DataSourceID: "products", + DataSourceName: "products", + OperationType: ast.OperationTypeQuery, + ProvidesData: newProductProvidesData(), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query.product", ObjectPath("product")), + ), + Data: newProductResponseData(), + } + + loader := &Loader{ + caches: map[string]LoaderCache{"default": cache}, + } + + ctx := NewContext(context.Background()) + ctx.ExecutionOptions.DisableSubgraphRequestDeduplication = true + ctx.ExecutionOptions.Caching.EnableL1Cache = true + ctx.ExecutionOptions.Caching.EnableL2Cache = true + ctx.ExecutionOptions.Caching.L2CacheKeyInterceptor = func(_ context.Context, key string, _ L2CacheKeyInterceptorInfo) string { + return "tenant-xyz:" + key + } + + ar := arena.NewMonotonicArena(arena.WithMinBufferSize(1024)) + resolvable := NewResolvable(ar, ResolvableOptions{}) + err := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + require.NoError(t, err) + + err = loader.LoadGraphQLResponseData(ctx, response, resolvable) + require.NoError(t, err) + + // L1 worked: entityDS2 was not called (Times(0) enforced by gomock) + out := fastjsonext.PrintGraphQLResponse(resolvable.data, resolvable.errors) + assert.Equal(t, `{"data":{"product":{"__typename":"Product","id":"prod-1","name":"Product One"}}}`, out) + + // L2 keys have the prefix + cacheLog := cache.GetLog() + var setKeys []string + for _, entry := range cacheLog { + if entry.Operation == "set" { + setKeys = append(setKeys, entry.Keys...) + } + } + require.Equal(t, 1, len(setKeys), "expected exactly 1 L2 cache set key") + assert.Equal(t, `tenant-xyz:{"__typename":"Product","key":{"id":"prod-1"}}`, setKeys[0]) + }) + + t.Run("interceptor receives correct SubgraphName and CacheName", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cache := NewFakeLoaderCache() + + rootDS := NewMockDataSource(ctrl) + rootDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"product":{"__typename":"Product","id":"prod-1"}}}`), nil + }).Times(1) + + entityDS := NewMockDataSource(ctrl) + entityDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"_entities":[{"__typename":"Product","id":"prod-1","name":"Product One"}]}}`), nil + }).Times(1) + + response := &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: rootDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {Data: []byte(`{"method":"POST","url":"http://root.service","body":{"query":"{product {__typename id}}"}}`), SegmentType: StaticSegmentType}, + }, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query"), + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: entityDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + Caching: FetchCacheConfiguration{ + Enabled: true, + CacheName: "product-cache", + TTL: 30 * time.Second, + CacheKeyTemplate: newProductCacheKeyTemplate(), + UseL1Cache: true, + }, + }, + InputTemplate: InputTemplate{Segments: newEntityFetchSegments()}, + Info: &FetchInfo{ + DataSourceID: "products-ds", + DataSourceName: "products", + OperationType: ast.OperationTypeQuery, + ProvidesData: newProductProvidesData(), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query.product", ObjectPath("product")), + ), + Data: newProductResponseData(), + } + + loader := &Loader{ + caches: map[string]LoaderCache{"product-cache": cache}, + } + + var capturedInfos []L2CacheKeyInterceptorInfo + + ctx := NewContext(context.Background()) + ctx.ExecutionOptions.DisableSubgraphRequestDeduplication = true + ctx.ExecutionOptions.Caching.EnableL2Cache = true + ctx.ExecutionOptions.Caching.L2CacheKeyInterceptor = func(_ context.Context, key string, info L2CacheKeyInterceptorInfo) string { + capturedInfos = append(capturedInfos, info) + return key // pass through unchanged + } + + ar := arena.NewMonotonicArena(arena.WithMinBufferSize(1024)) + resolvable := NewResolvable(ar, ResolvableOptions{}) + err := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + require.NoError(t, err) + + err = loader.LoadGraphQLResponseData(ctx, response, resolvable) + require.NoError(t, err) + + require.Equal(t, 1, len(capturedInfos), "interceptor should be called exactly once") + assert.Equal(t, L2CacheKeyInterceptorInfo{ + SubgraphName: "products", + CacheName: "product-cache", + }, capturedInfos[0]) + }) + + t.Run("nil interceptor has no effect", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + cache := NewFakeLoaderCache() + + rootDS := NewMockDataSource(ctrl) + rootDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"product":{"__typename":"Product","id":"prod-1"}}}`), nil + }).Times(1) + + entityDS := NewMockDataSource(ctrl) + entityDS.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, headers any, input []byte) ([]byte, error) { + return []byte(`{"data":{"_entities":[{"__typename":"Product","id":"prod-1","name":"Product One"}]}}`), nil + }).Times(1) + + response := &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: Sequence( + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: rootDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {Data: []byte(`{"method":"POST","url":"http://root.service","body":{"query":"{product {__typename id}}"}}`), SegmentType: StaticSegmentType}, + }, + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query"), + SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: entityDS, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data", "_entities", "0"}, + }, + Caching: FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: newProductCacheKeyTemplate(), + UseL1Cache: true, + }, + }, + InputTemplate: InputTemplate{Segments: newEntityFetchSegments()}, + Info: &FetchInfo{ + DataSourceID: "products", + DataSourceName: "products", + OperationType: ast.OperationTypeQuery, + ProvidesData: newProductProvidesData(), + }, + DataSourceIdentifier: []byte("graphql_datasource.Source"), + }, "query.product", ObjectPath("product")), + ), + Data: newProductResponseData(), + } + + loader := &Loader{ + caches: map[string]LoaderCache{"default": cache}, + } + + ctx := NewContext(context.Background()) + ctx.ExecutionOptions.DisableSubgraphRequestDeduplication = true + ctx.ExecutionOptions.Caching.EnableL2Cache = true + // L2CacheKeyInterceptor is nil (default) + + ar := arena.NewMonotonicArena(arena.WithMinBufferSize(1024)) + resolvable := NewResolvable(ar, ResolvableOptions{}) + err := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + require.NoError(t, err) + + err = loader.LoadGraphQLResponseData(ctx, response, resolvable) + require.NoError(t, err) + + out := fastjsonext.PrintGraphQLResponse(resolvable.data, resolvable.errors) + assert.Equal(t, `{"data":{"product":{"__typename":"Product","id":"prod-1","name":"Product One"}}}`, out) + + // Cache keys should be in standard format (no transformation) + cacheLog := cache.GetLog() + var setKeys []string + for _, entry := range cacheLog { + if entry.Operation == "set" { + setKeys = append(setKeys, entry.Keys...) + } + } + require.Equal(t, 1, len(setKeys), "expected exactly 1 cache set key") + assert.Equal(t, `{"__typename":"Product","key":{"id":"prod-1"}}`, setKeys[0]) + }) +} diff --git a/v2/pkg/engine/resolve/loader_cache.go b/v2/pkg/engine/resolve/loader_cache.go index b977f25602..7c82666e11 100644 --- a/v2/pkg/engine/resolve/loader_cache.go +++ b/v2/pkg/engine/resolve/loader_cache.go @@ -165,6 +165,19 @@ func (l *Loader) prepareCacheKeys(info *FetchInfo, cfg FetchCacheConfiguration, if err != nil { return false, err } + + // Apply user-provided L2 cache key interceptor + if interceptor := l.ctx.ExecutionOptions.Caching.L2CacheKeyInterceptor; interceptor != nil { + interceptorInfo := L2CacheKeyInterceptorInfo{ + SubgraphName: info.DataSourceName, + CacheName: cfg.CacheName, + } + for _, ck := range res.l2CacheKeys { + for i, key := range ck.Keys { + ck.Keys[i] = interceptor(l.ctx.ctx, key, interceptorInfo) + } + } + } } } @@ -1069,12 +1082,23 @@ func (l *Loader) buildMutationEntityCacheKey(cfg *MutationEntityImpactConfig, en keyJSON := string(keyObj.MarshalTo(nil)) // Add prefix if needed + var cacheKey string if cfg.IncludeSubgraphHeaderPrefix && l.ctx.SubgraphHeadersBuilder != nil { _, headersHash := l.ctx.SubgraphHeadersBuilder.HeadersForSubgraph(info.DataSourceName) prefix := strconv.FormatUint(headersHash, 10) - return prefix + ":" + keyJSON + cacheKey = prefix + ":" + keyJSON + } else { + cacheKey = keyJSON + } + + // Apply user-provided L2 cache key interceptor + if interceptor := l.ctx.ExecutionOptions.Caching.L2CacheKeyInterceptor; interceptor != nil { + cacheKey = interceptor(l.ctx.ctx, cacheKey, L2CacheKeyInterceptorInfo{ + SubgraphName: info.DataSourceName, + CacheName: cfg.CacheName, + }) } - return keyJSON + return cacheKey } // buildMutationEntityDisplayKey builds a display key (without prefix) for analytics.