Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions execution/engine/federation_caching_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5569,6 +5569,213 @@ func TestCacheAnalyticsE2E(t *testing.T) {
})
assert.Equal(t, expected2, normalizeSnapshot(parseCacheAnalytics(t, headers)))
})

t.Run("root field with args - L2 analytics", func(t *testing.T) {
// Tests that root field caching with arguments properly records L2 analytics events.
// This covers the root field path in tryL2CacheLoad (no L1 keys branch).
defaultCache := NewFakeLoaderCache()
caches := map[string]resolve.LoaderCache{
"default": defaultCache,
}

tracker := newSubgraphCallTracker(http.DefaultTransport)
trackingClient := &http.Client{Transport: tracker}

rootFieldArgsCachingConfigs := engine.SubgraphCachingConfigs{
{
SubgraphName: "accounts",
RootFieldCaching: plan.RootFieldCacheConfigurations{
{TypeName: "Query", FieldName: "user", CacheName: "default", TTL: 30 * time.Second},
},
},
}

setup := federationtesting.NewFederationSetup(addCachingGateway(
withCachingEnableART(false),
withCachingLoaderCache(caches),
withHTTPClient(trackingClient),
withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true, EnableCacheAnalytics: true}),
withSubgraphEntityCachingConfigs(rootFieldArgsCachingConfigs),
))
t.Cleanup(setup.Close)

gqlClient := NewGraphqlClient(http.DefaultClient)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

const (
keyUserById1234 = `{"__typename":"Query","field":"user","args":{"id":"1234"}}`
keyUserById5678 = `{"__typename":"Query","field":"user","args":{"id":"5678"}}`
dsAccountsLocal = "accounts"
byteSizeUser1234 = 38 // {"user":{"id":"1234","username":"Me"}}
byteSizeUser5678 = 45 // {"user":{"id":"5678","username":"User 5678"}}

hashUsernameMeLocal uint64 = 4957449860898447395 // xxhash("Me")
hashUsername5678Local uint64 = 15512417390573333165 // xxhash("User 5678")
entityKeyUser1234Local = `{"id":"1234"}`
entityKeyUser5678Local = `{"id":"5678"}`
)

accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL)
accountsHost := accountsURLParsed.Host

// First query (id=1234) — L2 miss, populates cache
tracker.Reset()
resp, headers := gqlClient.QueryWithHeaders(ctx, setup.GatewayServer.URL, cachingTestQueryPath("queries/user_by_id.query"), queryVariables{"id": "1234"}, t)
assert.Equal(t, `{"data":{"user":{"id":"1234","username":"Me"}}}`, string(resp))
assert.Equal(t, 1, tracker.GetCount(accountsHost), "First query should call accounts subgraph")

expected1 := normalizeSnapshot(resolve.CacheAnalyticsSnapshot{
L2Reads: []resolve.CacheKeyEvent{
{CacheKey: keyUserById1234, EntityType: "Query", Kind: resolve.CacheKeyMiss, DataSource: dsAccountsLocal}, // L2 miss: first request, cache empty
},
L2Writes: []resolve.CacheWriteEvent{
{CacheKey: keyUserById1234, EntityType: "Query", ByteSize: byteSizeUser1234, DataSource: dsAccountsLocal, CacheLevel: resolve.CacheLevelL2, TTL: 30 * time.Second}, // Root field written after accounts fetch
},
FieldHashes: []resolve.EntityFieldHash{
{EntityType: "User", FieldName: "username", FieldHash: hashUsernameMeLocal, KeyRaw: entityKeyUser1234Local, Source: resolve.FieldSourceSubgraph}, // User returned by root field, data from subgraph
},
EntityTypes: []resolve.EntityTypeInfo{
{TypeName: "User", Count: 1, UniqueKeys: 1}, // 1 User entity from root field response
},
})
assert.Equal(t, expected1, normalizeSnapshot(parseCacheAnalytics(t, headers)))

// Second query (same id=1234) — L2 hit
tracker.Reset()
resp, headers = gqlClient.QueryWithHeaders(ctx, setup.GatewayServer.URL, cachingTestQueryPath("queries/user_by_id.query"), queryVariables{"id": "1234"}, t)
assert.Equal(t, `{"data":{"user":{"id":"1234","username":"Me"}}}`, string(resp))
assert.Equal(t, 0, tracker.GetCount(accountsHost), "Second query should skip accounts (cache hit)")

expected2 := normalizeSnapshot(resolve.CacheAnalyticsSnapshot{
L2Reads: []resolve.CacheKeyEvent{
{CacheKey: keyUserById1234, EntityType: "Query", Kind: resolve.CacheKeyHit, DataSource: dsAccountsLocal, ByteSize: byteSizeUser1234}, // L2 hit: populated by first request
},
// No L2Writes: data served from cache
FieldHashes: []resolve.EntityFieldHash{
// Source is FieldSourceSubgraph (default) because entity source tracking operates at
// entity cache level, not root field cache level — no entity caching configured for User
{EntityType: "User", FieldName: "username", FieldHash: hashUsernameMeLocal, KeyRaw: entityKeyUser1234Local, Source: resolve.FieldSourceSubgraph},
},
EntityTypes: []resolve.EntityTypeInfo{
{TypeName: "User", Count: 1, UniqueKeys: 1},
},
})
assert.Equal(t, expected2, normalizeSnapshot(parseCacheAnalytics(t, headers)))

// Third query (different id=5678) — L2 miss (different args = different cache key)
tracker.Reset()
resp, headers = gqlClient.QueryWithHeaders(ctx, setup.GatewayServer.URL, cachingTestQueryPath("queries/user_by_id.query"), queryVariables{"id": "5678"}, t)
assert.Equal(t, `{"data":{"user":{"id":"5678","username":"User 5678"}}}`, string(resp))
assert.Equal(t, 1, tracker.GetCount(accountsHost), "Third query should call accounts (different args)")

expected3 := normalizeSnapshot(resolve.CacheAnalyticsSnapshot{
L2Reads: []resolve.CacheKeyEvent{
{CacheKey: keyUserById5678, EntityType: "Query", Kind: resolve.CacheKeyMiss, DataSource: dsAccountsLocal}, // L2 miss: different args, not cached
},
L2Writes: []resolve.CacheWriteEvent{
{CacheKey: keyUserById5678, EntityType: "Query", ByteSize: byteSizeUser5678, DataSource: dsAccountsLocal, CacheLevel: resolve.CacheLevelL2, TTL: 30 * time.Second}, // New args written to L2
},
FieldHashes: []resolve.EntityFieldHash{
{EntityType: "User", FieldName: "username", FieldHash: hashUsername5678Local, KeyRaw: entityKeyUser5678Local, Source: resolve.FieldSourceSubgraph}, // User 5678 data from subgraph
},
EntityTypes: []resolve.EntityTypeInfo{
{TypeName: "User", Count: 1, UniqueKeys: 1},
},
})
assert.Equal(t, expected3, normalizeSnapshot(parseCacheAnalytics(t, headers)))
})

t.Run("root field only - L2 analytics without entity caching", func(t *testing.T) {
// Tests root field caching analytics in isolation — only root field caching configured,
// no entity caching. Verifies that only root field events appear in analytics.
defaultCache := NewFakeLoaderCache()
caches := map[string]resolve.LoaderCache{
"default": defaultCache,
}

tracker := newSubgraphCallTracker(http.DefaultTransport)
trackingClient := &http.Client{Transport: tracker}

// Only configure root field caching for products — no entity caching at all
rootOnlyConfigs := engine.SubgraphCachingConfigs{
{
SubgraphName: "products",
RootFieldCaching: plan.RootFieldCacheConfigurations{
{TypeName: "Query", FieldName: "topProducts", CacheName: "default", TTL: 30 * time.Second},
},
},
}

setup := federationtesting.NewFederationSetup(addCachingGateway(
withCachingEnableART(false),
withCachingLoaderCache(caches),
withHTTPClient(trackingClient),
withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true, EnableCacheAnalytics: true}),
withSubgraphEntityCachingConfigs(rootOnlyConfigs),
))
t.Cleanup(setup.Close)

gqlClient := NewGraphqlClient(http.DefaultClient)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

productsURLParsed, _ := url.Parse(setup.ProductsUpstreamServer.URL)
productsHost := productsURLParsed.Host
reviewsURLParsed, _ := url.Parse(setup.ReviewsUpstreamServer.URL)
reviewsHost := reviewsURLParsed.Host
accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL)
accountsHost := accountsURLParsed.Host

const (
keyTopProductsLocal = `{"__typename":"Query","field":"topProducts"}`
dsProductsLocal = "products"
byteSizeTP = 127 // Query.topProducts root field response
)

// First query — L2 miss for root field, no events for entities (not configured)
tracker.Reset()
resp, headers := gqlClient.QueryWithHeaders(ctx, setup.GatewayServer.URL, cachingTestQueryPath("queries/multiple_upstream_without_provides.query"), nil, t)
assert.Equal(t, expectedResponseBody, string(resp))

// Products subgraph called (root field miss), reviews + accounts always called (no entity caching)
assert.Equal(t, 1, tracker.GetCount(productsHost), "First query should call products subgraph")
assert.Equal(t, 1, tracker.GetCount(reviewsHost), "First query should call reviews subgraph")
assert.Equal(t, 1, tracker.GetCount(accountsHost), "First query should call accounts subgraph")

expected1 := normalizeSnapshot(resolve.CacheAnalyticsSnapshot{
L2Reads: []resolve.CacheKeyEvent{
{CacheKey: keyTopProductsLocal, EntityType: "Query", Kind: resolve.CacheKeyMiss, DataSource: dsProductsLocal}, // L2 miss: first request, cache empty
},
L2Writes: []resolve.CacheWriteEvent{
{CacheKey: keyTopProductsLocal, EntityType: "Query", ByteSize: byteSizeTP, DataSource: dsProductsLocal, CacheLevel: resolve.CacheLevelL2, TTL: 30 * time.Second}, // Root field written after products fetch
},
// Only entity types tracked during resolution (not caching-dependent)
FieldHashes: multiUpstreamFieldHashes,
EntityTypes: multiUpstreamEntityTypes,
})
assert.Equal(t, expected1, normalizeSnapshot(parseCacheAnalytics(t, headers)))

// Second query — L2 hit for root field, entities still fetched (not cached)
tracker.Reset()
resp, headers = gqlClient.QueryWithHeaders(ctx, setup.GatewayServer.URL, cachingTestQueryPath("queries/multiple_upstream_without_provides.query"), nil, t)
assert.Equal(t, expectedResponseBody, string(resp))

// Products subgraph skipped (root field cache hit), reviews + accounts still called
assert.Equal(t, 0, tracker.GetCount(productsHost), "Second query should skip products (root field cache hit)")
assert.Equal(t, 1, tracker.GetCount(reviewsHost), "Second query should call reviews (no entity caching)")
assert.Equal(t, 1, tracker.GetCount(accountsHost), "Second query should call accounts (no entity caching)")

expected2 := normalizeSnapshot(resolve.CacheAnalyticsSnapshot{
L2Reads: []resolve.CacheKeyEvent{
{CacheKey: keyTopProductsLocal, EntityType: "Query", Kind: resolve.CacheKeyHit, DataSource: dsProductsLocal, ByteSize: byteSizeTP}, // L2 hit: root field cached by first request
},
// No L2Writes: root field served from cache, entities have no caching configured
FieldHashes: multiUpstreamFieldHashes, // Entity field hashes still tracked (resolution, not caching)
EntityTypes: multiUpstreamEntityTypes,
})
assert.Equal(t, expected2, normalizeSnapshot(parseCacheAnalytics(t, headers)))
})
}

func TestShadowCacheE2E(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
},
OperationType: ast.OperationTypeQuery,
ProvidesData: &resolve.Object{
HasAliases: true,
Fields: []*resolve.Field{
{
Name: []byte("name"),
Expand All @@ -1796,11 +1797,13 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Name: []byte("shippingInfo"),
OnTypeNames: [][]byte{[]byte("Account")},
Value: &resolve.Object{
Path: []string{"shippingInfo"},
Nullable: true,
Path: []string{"shippingInfo"},
Nullable: true,
HasAliases: true,
Fields: []*resolve.Field{
{
Name: []byte("z"),
Name: []byte("z"),
OriginalName: []byte("zip"),
Value: &resolve.Scalar{
Path: []string{"z"},
},
Expand Down Expand Up @@ -1869,6 +1872,16 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
TTL: time.Second * 30,
IncludeSubgraphHeaderPrefix: true,
UseL1Cache: false, // Set to false by postprocessor (no L1 benefit for this fetch)
KeyFields: []resolve.KeyField{
{Name: "id"},
{
Name: "info",
Children: []resolve.KeyField{
{Name: "a"},
{Name: "b"},
},
},
},
CacheKeyTemplate: &resolve.EntityQueryCacheKeyTemplate{
Keys: resolve.NewResolvableObjectVariable(&resolve.Object{
Nullable: true,
Expand Down Expand Up @@ -1941,6 +1954,11 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
},
TypeName: "User",
SourceName: "user.service",
CacheAnalytics: &resolve.ObjectCacheAnalytics{
KeyFields: []resolve.KeyField{
{Name: "id"},
},
},
Fields: []*resolve.Field{
{
Name: []byte("account"),
Expand All @@ -1953,6 +1971,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Names: []string{"user.service"},
},
ExactParentTypeName: "User",
CacheAnalyticsHash: true,
},
Value: &resolve.Object{
Path: []string{"account"},
Expand All @@ -1962,6 +1981,14 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
},
TypeName: "Account",
SourceName: "user.service",
CacheAnalytics: &resolve.ObjectCacheAnalytics{
KeyFields: []resolve.KeyField{
{Name: "id"},
{Name: "info"},
{Name: "{a"},
{Name: "b}"},
},
},
Comment on lines +1984 to +1991
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use structured nested KeyFields in CacheAnalytics (current values are malformed).

At Line 1988 and Line 1989, "{a" / "b}" are token fragments, not key fields. This breaks the resolve.KeyField shape and can produce wrong cache analytics/hash behavior for composite keys.

🔧 Proposed fix
  CacheAnalytics: &resolve.ObjectCacheAnalytics{
      KeyFields: []resolve.KeyField{
          {Name: "id"},
-         {Name: "info"},
-         {Name: "{a"},
-         {Name: "b}"},
+         {
+             Name: "info",
+             Children: []resolve.KeyField{
+                 {Name: "a"},
+                 {Name: "b"},
+             },
+         },
      },
  },
📝 Committable suggestion

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

Suggested change
CacheAnalytics: &resolve.ObjectCacheAnalytics{
KeyFields: []resolve.KeyField{
{Name: "id"},
{Name: "info"},
{Name: "{a"},
{Name: "b}"},
},
},
CacheAnalytics: &resolve.ObjectCacheAnalytics{
KeyFields: []resolve.KeyField{
{Name: "id"},
{
Name: "info",
Children: []resolve.KeyField{
{Name: "a"},
{Name: "b"},
},
},
},
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go`
around lines 1984 - 1991, The CacheAnalytics.KeyFields currently contains
malformed token fragments "{a" and "b}" which break the resolve.KeyField shape;
update the resolve.ObjectCacheAnalytics.KeyFields so composite key parts are
represented as a nested resolve.KeyField for the "info" field (i.e., a single
KeyField with Name "info" that contains nested Fields for "a" and "b") rather
than separate string fragments, leaving the top-level id KeyField intact; locate
the CacheAnalytics struct in the graphql_datasource_federation_test.go and
replace the malformed entries with a proper nested resolve.KeyField for "info"
(using the Fields slice) to restore correct cache analytics/hash behavior.

Fields: []*resolve.Field{
{
Name: []byte("__typename"),
Expand All @@ -1974,6 +2001,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Names: []string{"user.service"},
},
ExactParentTypeName: "Account",
CacheAnalyticsHash: true,
},
Value: &resolve.String{
Path: []string{"__typename"},
Expand All @@ -1991,6 +2019,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Names: []string{"account.service"},
},
ExactParentTypeName: "Account",
CacheAnalyticsHash: true,
},
Value: &resolve.String{
Path: []string{"name"},
Expand All @@ -2008,6 +2037,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
},
ExactParentTypeName: "Account",
HasAuthorizationRule: true,
CacheAnalyticsHash: true,
},
Value: &resolve.Object{
Path: []string{"shippingInfo"},
Expand Down Expand Up @@ -3908,6 +3938,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Info: &resolve.FieldInfo{
Name: "account",
ExactParentTypeName: "User",
CacheAnalyticsHash: true,
ParentTypeNames: []string{"User"},
NamedType: "Account",
Source: resolve.TypeFieldSource{
Expand All @@ -3929,6 +3960,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Info: &resolve.FieldInfo{
Name: "address",
ExactParentTypeName: "Account",
CacheAnalyticsHash: true,
ParentTypeNames: []string{"Account"},
NamedType: "Address",
Source: resolve.TypeFieldSource{
Expand All @@ -3953,6 +3985,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) {
Info: &resolve.FieldInfo{
Name: "fullAddress",
ExactParentTypeName: "Address",
CacheAnalyticsHash: true,
ParentTypeNames: []string{"Address"},
NamedType: "String",
Source: resolve.TypeFieldSource{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,14 +462,16 @@ func TestGraphQLDataSource(t *testing.T) {
},
},
ProvidesData: &resolve.Object{
Nullable: false,
Path: []string{},
Nullable: false,
Path: []string{},
HasAliases: true,
Fields: []*resolve.Field{
{
Name: []byte("droid"),
Value: &resolve.Object{
Nullable: true,
Path: []string{"droid"},
Nullable: true,
Path: []string{"droid"},
HasAliases: true,
Fields: []*resolve.Field{
{
Name: []byte("name"),
Expand All @@ -479,7 +481,8 @@ func TestGraphQLDataSource(t *testing.T) {
},
},
{
Name: []byte("aliased"),
Name: []byte("aliased"),
OriginalName: []byte("name"),
Value: &resolve.Scalar{
Path: []string{"aliased"},
Nullable: false,
Expand Down
2 changes: 1 addition & 1 deletion v2/pkg/engine/datasource/service_datasource/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func ExtendSchemaWithServiceTypes(schema *ast.Document) error {
// 1. Find Query type first to fail fast
queryNode, found := findQueryType(schema)
if !found {
return fmt.Errorf("Query type not found in schema")
return fmt.Errorf("query type not found in schema")
}

// 2. Add _Capability type (must be added before _Service since _Service references it)
Expand Down
Loading
Loading