From 6fa1cdb03f1673f6a5cc7f604100189f75214729 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:26:10 +0200 Subject: [PATCH 01/11] chore: move cost tests into separate file (#1418) They have grown and will grow some more. It is better to have them separated. Updates ENG-8739 --- .../engine/execution_engine_cost_test.go | 1953 +++++++++++++++ execution/engine/execution_engine_test.go | 2167 +---------------- 2 files changed, 2057 insertions(+), 2063 deletions(-) create mode 100644 execution/engine/execution_engine_cost_test.go diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go new file mode 100644 index 0000000000..1e53d8a7a7 --- /dev/null +++ b/execution/engine/execution_engine_cost_test.go @@ -0,0 +1,1953 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/execution/graphql" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" +) + +func TestExecutionEngine_Cost(t *testing.T) { + + t.Run("common on star wars scheme", func(t *testing.T) { + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"hero", "droid"}}, + {TypeName: "Human", FieldNames: []string{"name", "height", "friends"}}, + {TypeName: "Droid", FieldNames: []string{"name", "primaryFunction", "friends"}}, + } + childNodes := []plan.TypeField{ + {TypeName: "Character", FieldNames: []string{"name", "friends"}}, + } + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig( + t, + nil, + string(graphql.StarwarsSchema(t).RawSchema()), + ), + }) + + t.Run("droid with weighted plain fields", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + droid(id: "R2D2") { + name + primaryFunction + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + }}, + customConfig, + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + }, + expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + expectedEstimatedCost: 18, // Query.droid (1) + droid.name (17) + }, + computeCosts(), + )) + + t.Run("droid with weighted plain fields and an argument", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + droid(id: "R2D2") { + name + primaryFunction + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "droid"}: { + ArgumentWeights: map[string]int{"id": 3}, + HasWeight: false, + }, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + }}, + customConfig, + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + }, + expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + expectedEstimatedCost: 21, // Query.droid (1) + Query.droid.id (3) + droid.name (17) + }, + computeCosts(), + )) + + t.Run("negative weights - cost is never negative", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + droid(id: "R2D2") { + name + primaryFunction + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "droid"}: { + HasWeight: true, + Weight: -10, // Negative field weight + ArgumentWeights: map[string]int{"id": -5}, // Negative argument weight + }, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: -3}, + {TypeName: "Droid", FieldName: "primaryFunction"}: {HasWeight: true, Weight: -2}, + }, + Types: map[string]int{ + "Droid": -1, // Negative type weight + }, + }}, + customConfig, + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + }, + expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + // All weights are negative. + // But cost should be floored to 0 (never negative) + expectedEstimatedCost: 0, + }, + computeCosts(), + )) + + t.Run("hero field has weight (returns interface) and with concrete fragment", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + name + ... on Human { height } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker","height":"12"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 3}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + Types: map[string]int{ + "Human": 13, + }, + }}, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker","height":"12"}}}`, + expectedEstimatedCost: 22, // Query.hero (2) + Human.height (3) + Droid.name (17=max(7, 17)) + }, + computeCosts(), + )) + + t.Run("hero field has no weight (returns interface) and with concrete fragment", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { name } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + Types: map[string]int{ + "Human": 13, + "Droid": 11, + }, + }}, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker"}}}`, + expectedEstimatedCost: 30, // Query.Human (13) + Droid.name (17=max(7, 17)) + }, + computeCosts(), + )) + + t.Run("query hero without assumedSize on friends", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 127, // Query.hero(max(7,5))+10*(Human(max(7,5))+Human.name(2)+Human.height(1)+Droid.name(2)) + }, + computeCosts(), + )) + + t.Run("query hero with assumedSize on friends", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, + {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 247, // Query.hero(max(7,5))+ 20 * (7+2+2+1) + // We pick maximum on every path independently. This is to reveal the upper boundary. + // Query.hero: picked maximum weight (Human=7) out of two types (Human, Droid) + // Query.hero.friends: the max possible weight (7) is for implementing class Human + // of the returned type of Character; the multiplier picked for the Droid since + // it is the maximum possible value - we considered the enclosing type that contains it. + }, + computeCosts(), + )) + + t.Run("query hero with assumedSize on friends and weight defined", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "friends"}: {HasWeight: true, Weight: 3}, + {TypeName: "Droid", FieldName: "friends"}: {HasWeight: true, Weight: 4}, + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, + {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 187, // Query.hero(max(7,5))+ 20 * (4+2+2+1) + }, + computeCosts(), + )) + + t.Run("query hero with empty cost structures", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{}, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 11, // Query.hero(max(1,1))+ 10 * 1 + }, + computeCosts(), + )) + + // Actual cost tests - verifies that actual cost uses real list sizes from response + // rather than estimated/assumed sizes + + t.Run("actual cost with list field - 2 items instead of default 10", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + // Response has 2 friends (not 10 as estimated) + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2 + 1) = 127 + expectedEstimatedCost: 127, + // Actual uses real list size 2: hero(7) + 2 * (7 + 2 + 2 + 1) = 31 + expectedActualCost: 31, + }, + computeCosts(), + )) + + t.Run("actual cost with empty list", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name } + ...on Human { name } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + // Response has empty friends array + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[]}}}`, + // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2) = 117 + expectedEstimatedCost: 117, + // Actual with empty list: hero(7) + 1 * (7 + 2 + 2) = 18 + // We consider empty lists as lists containing one item to account for the + // resolver work. + expectedActualCost: 18, + }, + computeCosts(), + )) + + t.Run("named fragment on interface", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: ` + fragment CharacterFields on Character { + name + friends { name } + } + { hero { ...CharacterFields } } + `, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","friends":[{"name":"Leia"}]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 4}, + {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 6}, + }, + Types: map[string]int{ + "Human": 2, + "Droid": 3, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke","friends":[{"name":"Leia"}]}}}`, + // Cost calculation: + // Query.hero: 2 + // Character.name: max(Human.name=3, Droid.name=5) = 5 + // friends listSize: max(4, 6) = 6 + // Character type: max(Human=2, Droid=3) = 3 + // name: max(Human.name=3, Droid.name=5) = 5 + // Total: 2 + 5 + 6 * (3 + 5) + expectedEstimatedCost: 55, + }, + computeCosts(), + )) + + t.Run("named fragment with concrete type", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: ` + fragment HumanFields on Human { + name + height + } + { hero { ...HumanFields } } + `, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","height":"1.72"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 7}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, + }, + Types: map[string]int{ + "Human": 1, + "Droid": 1, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke","height":"1.72"}}}`, + // Total: 2 + 3 + 7 + expectedEstimatedCost: 12, + }, + computeCosts(), + )) + + }) + + t.Run("union types", func(t *testing.T) { + unionSchema := ` + type Query { + search(term: String!): [SearchResult!] + } + union SearchResult = User | Post | Comment + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + type Post @key(fields: "id") { + id: ID! + title: String! + body: String! + } + type Comment @key(fields: "id") { + id: ID! + text: String! + } + ` + schema, err := graphql.NewSchemaFromString(unionSchema) + require.NoError(t, err) + + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"search"}}, + {TypeName: "User", FieldNames: []string{"id", "name", "email"}}, + {TypeName: "Post", FieldNames: []string{"id", "title", "body"}}, + {TypeName: "Comment", FieldNames: []string{"id", "text"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, unionSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "search", + Path: []string{"search"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "term", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + + t.Run("union with all member types", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + search(term: "test") { + ... on User { name email } + ... on Post { title body } + ... on Comment { text } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John","email":"john@test.com"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "User", FieldName: "email"}: {HasWeight: true, Weight: 3}, + {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 4}, + {TypeName: "Post", FieldName: "body"}: {HasWeight: true, Weight: 5}, + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: {AssumedSize: 5}, + }, + Types: map[string]int{ + "User": 2, + "Post": 3, + "Comment": 1, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"name":"John","email":"john@test.com"}]}}`, + // search listSize: 10 + // For each SearchResult, use max across all union members: + // Type weight: max(User=2, Post=3, Comment=1) = 3 + // Fields: all fields from all fragments are counted + // (2 + 3) + (4 + 5) + (1) = 15 + // TODO: this is not correct, we should pick a maximum sum among types implementing union. + // 9 should be used instead of 15 + // Total: 5 * (3 + 15) + expectedEstimatedCost: 90, + }, + computeCosts(), + )) + + t.Run("union with weighted search field", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + search(term: "test") { + ... on User { name } + ... on Post { title } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 5}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: {AssumedSize: 3}, + }, + Types: map[string]int{ + "User": 6, + "Post": 10, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"name":"John"}]}}`, + // Query.search: max(User=10, Post=6) + // search listSize: 3 + // Union members: + // All fields from all fragments: User.name(2) + Post.title(5) + // Total: 3 * (10+2+5) + // TODO: we might correct this by counting only members of one implementing types + // of a union when fragments are used. + expectedEstimatedCost: 51, + }, + computeCosts(), + )) + }) + + t.Run("listSize", func(t *testing.T) { + listSchema := ` + type Query { + items(first: Int, last: Int): [Item!] + } + type Item @key(fields: "id") { + id: ID + } + ` + schemaSlicing, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"items"}}, + {TypeName: "Item", FieldNames: []string{"id"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, listSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "items", + Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "first", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + { + Name: "last", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + } + t.Run("multiple slicing arguments as literals", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query MultipleSlicingArguments { + items(first: 5, last: 12) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 8, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 3, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: 48, // slicingArgument(12) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + t.Run("slicing argument as a variable", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query SlicingWithVariable($limit: Int!) { + items(first: $limit) { id } + }`, + Variables: []byte(`{"limit": 25}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 8, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 3, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: 100, // slicingArgument($limit=25) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + t.Run("slicing argument not provided falls back to assumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NoSlicingArg { + items { id } + }`, + // No slicing arguments provided - should fall back to assumedSize + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 15, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, + expectedEstimatedCost: 45, // Total: 15 * (2 + 1) + }, + computeCosts(), + )) + t.Run("zero slicing argument falls back to assumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ZeroSlicing { + items(first: 0) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 20, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[]}}`, + expectedEstimatedCost: 60, // 20 * (2 + 1) + }, + computeCosts(), + )) + t.Run("negative slicing argument falls back to assumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NegativeSlicing { + items(first: -5) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 25, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[]}}`, + expectedEstimatedCost: 75, // 25 * (2 + 1) + }, + computeCosts(), + )) + + }) + + t.Run("nested lists with compounding multipliers", func(t *testing.T) { + nestedSchema := ` + type Query { + users(first: Int): [User!] + } + type User @key(fields: "id") { + id: ID! + posts(first: Int): [Post!] + } + type Post @key(fields: "id") { + id: ID! + comments(first: Int): [Comment!] + } + type Comment @key(fields: "id") { + id: ID! + text: String! + } + ` + schemaNested, err := graphql.NewSchemaFromString(nestedSchema) + require.NoError(t, err) + + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"users"}}, + {TypeName: "User", FieldNames: []string{"id", "posts"}}, + {TypeName: "Post", FieldNames: []string{"id", "comments"}}, + {TypeName: "Comment", FieldNames: []string{"id", "text"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, nestedSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "users", Path: []string{"users"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "User", FieldName: "posts", Path: []string{"posts"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Post", FieldName: "comments", Path: []string{"comments"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + + t.Run("nested lists with slicing arguments", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + // Cost calculation: + // users(first:10): multiplier 10 + // User type weight: 4 + // posts(first:5): multiplier 5 + // Post type weight: 3 + // comments(first:3): multiplier 3 + // Comment type weight: 2 + // text weight: 1 + // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) + expectedEstimatedCost: 640, + }, + computeCosts(), + )) + + t.Run("nested lists fallback to assumedSize when slicing arg not provided", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 2) { + posts { + comments(first: 4) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, // no slicing arg, should use this + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, + // Cost calculation: + // users(first:2): multiplier 2 + // User type weight: 4 + // posts (no arg): assumedSize 50 + // Post type weight: 3 + // comments(first:4): multiplier 4 + // Comment type weight: 2 + // text weight: 1 + // Total: 2 * (4 + 50 * (3 + 4 * (2 + 1))) + expectedEstimatedCost: 1508, + }, + computeCosts(), + )) + + t.Run("actual cost for nested lists - 1 item at each level", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 1 user with 1 post with 1 comment + sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + // Estimated cost with slicing arguments (10, 5, 3): + // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) = 640 + expectedEstimatedCost: 640, + // Actual cost with 1 item at each level: + // Total: 1 * (4 + 1 * (3 + 1 * (2 + 1))) = 10 + expectedActualCost: 10, + }, + computeCosts(), + )) + + t.Run("actual cost for nested lists - varying sizes", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 2 users, each with 2 posts, each with 3 comments + sendResponseBody: `{"data":{"users":[ + {"posts":[ + {"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]}, + {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, + {"posts":[ + {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, + {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]},{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + expectedEstimatedCost: 640, + // Actual cost: 2 * (4 + 2 * (3 + 3 * (2 + 1))) = 56 + expectedActualCost: 56, + }, + computeCosts(), + )) + + t.Run("actual cost for nested lists - uneven sizes", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 2) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 2 users, with 1.5 posts each, each with 3 comments + sendResponseBody: `{"data":{"users":[ + {"posts":[ + {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, + {"posts":[ + {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, + {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + // Estimated : 10 * (4 + 5 * (3 + 2 * (2 + 1))) = 490 + expectedEstimatedCost: 490, + // Actual cost: 2 * (4 + 1.5 * (3 + 3 * (2 + 1))) = 44 + expectedActualCost: 44, + }, + computeCosts(), + )) + + t.Run("actual cost for root-level list - no parent", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ users(first: 10) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 3 users at the root level + sendResponseBody: `{"data":{"users":[ + {"id":"1"}, + {"id":"2"}, + {"id":"3"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "User", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"id":"1"},{"id":"2"},{"id":"3"}]}}`, + // Estimated: 10 * (4 + 1) = 50 + expectedEstimatedCost: 50, + // Actual cost: 3 users at root + // 3 * (4 + 1) = 15 + expectedActualCost: 15, + }, + computeCosts(), + )) + + t.Run("mixed empty and non-empty lists - averaging behavior", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"users":[ + {"posts":[ + {"comments":[{"text":"a"},{"text":"b"}]}, + {"comments":[{"text":"c"},{"text":"d"}]} + ]}, + {"posts":[]}, + {"posts":[ + {"comments":[]} + ]} + ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"}]},{"comments":[{"text":"c"},{"text":"d"}]}]},{"posts":[]},{"posts":[{"comments":[]}]}]}}`, + expectedEstimatedCost: 640, // 10 * (4 + 5 * (3 + 3 * (2 + 1))) + // Actual cost with mixed empty/non-empty lists: + // Users: 3 items, multiplier 3.0 + // Posts: 3 items, 3 parents => multiplier 1.0 (avg) + // Comments: 4 items, 3 parents => multiplier 1.33 (avg) + // + // Calculation: + // Comments: RoundToEven((2 + 1) * 1.33) ~= 4 + // Posts: RoundToEven((3 + 4) * 1.00) = 7 + // Users: RoundToEven((4 + 7) * 3.00) = 33 + // + // Empty lists are included in the averaging: + expectedActualCost: 33, + }, + computeCosts(), + )) + + t.Run("deeply nested lists with fractional multipliers - compounding rounding", runWithoutError( + ExecutionEngineTestCase{ + schema: func() *graphql.Schema { + deepSchema := ` + type Query { + level1(first: Int): [Level1!] + } + type Level1 @key(fields: "id") { + id: ID! + level2(first: Int): [Level2!] + } + type Level2 @key(fields: "id") { + id: ID! + level3(first: Int): [Level3!] + } + type Level3 @key(fields: "id") { + id: ID! + level4(first: Int): [Level4!] + } + type Level4 @key(fields: "id") { + id: ID! + level5(first: Int): [Level5!] + } + type Level5 @key(fields: "id") { + id: ID! + value: String! + } + ` + s, err := graphql.NewSchemaFromString(deepSchema) + require.NoError(t, err) + return s + }(), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + level1(first: 10) { + level2(first: 10) { + level3(first: 10) { + level4(first: 10) { + level5(first: 10) { + value + } + } + } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"level1":[ + {"level2":[ + {"level3":[ + {"level4":[ + {"level5":[{"value":"a"}]}, + {"level5":[{"value":"b"},{"value":"c"}]} + ]}, + {"level4":[ + {"level5":[{"value":"d"}]} + ]} + ]}, + {"level3":[ + {"level4":[ + {"level5":[{"value":"e"}]} + ]} + ]} + ]}, + {"level2":[ + {"level3":[ + {"level4":[ + {"level5":[{"value":"f"},{"value":"g"}]}, + {"level5":[{"value":"h"}]} + ]}, + {"level4":[ + {"level5":[{"value":"i"}]} + ]} + ]} + ]}, + {"level2":[ + {"level3":[ + {"level4":[ + {"level5":[{"value":"j"}]}, + {"level5":[{"value":"k"}]} + ]}, + {"level4":[ + {"level5":[{"value":"l"}]}, + {"level5":[{"value":"m"}]} + ]} + ]} + ]} + ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"level1"}}, + {TypeName: "Level1", FieldNames: []string{"id", "level2"}}, + {TypeName: "Level2", FieldNames: []string{"id", "level3"}}, + {TypeName: "Level3", FieldNames: []string{"id", "level4"}}, + {TypeName: "Level4", FieldNames: []string{"id", "level5"}}, + {TypeName: "Level5", FieldNames: []string{"id", "value"}}, + }, + ChildNodes: []plan.TypeField{}, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Level5", FieldName: "value"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "level1"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level1", FieldName: "level2"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level2", FieldName: "level3"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level3", FieldName: "level4"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level4", FieldName: "level5"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "Level1": 1, + "Level2": 1, + "Level3": 1, + "Level4": 1, + "Level5": 1, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, ` + type Query { + level1(first: Int): [Level1!] + } + type Level1 @key(fields: "id") { + id: ID! + level2(first: Int): [Level2!] + } + type Level2 @key(fields: "id") { + id: ID! + level3(first: Int): [Level3!] + } + type Level3 @key(fields: "id") { + id: ID! + level4(first: Int): [Level4!] + } + type Level4 @key(fields: "id") { + id: ID! + level5(first: Int): [Level5!] + } + type Level5 @key(fields: "id") { + id: ID! + value: String! + } + `), + }), + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "level1", Path: []string{"level1"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level1", FieldName: "level2", Path: []string{"level2"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level2", FieldName: "level3", Path: []string{"level3"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level3", FieldName: "level4", Path: []string{"level4"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level4", FieldName: "level5", Path: []string{"level5"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + }, + expectedResponse: `{"data":{"level1":[{"level2":[{"level3":[{"level4":[{"level5":[{"value":"a"}]},{"level5":[{"value":"b"},{"value":"c"}]}]},{"level4":[{"level5":[{"value":"d"}]}]}]},{"level3":[{"level4":[{"level5":[{"value":"e"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"f"},{"value":"g"}]},{"level5":[{"value":"h"}]}]},{"level4":[{"level5":[{"value":"i"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"j"}]},{"level5":[{"value":"k"}]}]},{"level4":[{"level5":[{"value":"l"}]},{"level5":[{"value":"m"}]}]}]}]}]}}`, + expectedEstimatedCost: 211110, + // Actual cost with fractional multipliers: + // Level5: 13 items, 11 parents => multiplier 1.18 (13/11 = 1.181818...) + // Level4: 11 items, 7 parents => multiplier 1.57 (11/7 = 1.571428...) + // Level3: 7 items, 4 parents => multiplier 1.75 (7/4 = 1.75) + // Level2: 4 items, 3 parents => multiplier 1.33 (4/3 = 1.333...) + // Level1: 3 items, 1 parent => multiplier 3.0 + // + // Ideal calculation without rounding: + // cost = 3 * (1 + 1.33 * (1 + 1.75 * (1 + 1.57 * (1 + 1.18 * (1 + 1))))) + // = 50.806584 ~= 51 + // + // Current implementation: + // Level5: RoundToEven((1 + 1) * 1.18) = 2 + // Level4: RoundToEven((1 + 2) * 1.57) = 5 + // Level3: RoundToEven((1 + 5) * 1.75) = 10 (rounds to even) + // Level2: RoundToEven((1 + 10) * 1.33) = 15 + // Level1: RoundToEven((1 + 15) * 3.00) = 48 + // + // The compounding rounding error: 48 vs 51 (6% underestimate) + expectedActualCost: 48, + }, + computeCosts(), + )) + }) +} diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 9c3370efe0..0f7c48ac00 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -17,7 +17,6 @@ import ( "github.com/sebdah/goldie/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc" "github.com/wundergraph/graphql-go-tools/execution/federationtesting" "github.com/wundergraph/graphql-go-tools/execution/graphql" @@ -61,13 +60,104 @@ func mustFactory(t testing.TB, httpClient *http.Client) plan.PlannerFactory[grap return factory } -func mustFactoryGRPC(t testing.TB, grpcClient grpc.ClientConnInterface) plan.PlannerFactory[graphql_datasource.Configuration] { - t.Helper() +func runExecutionTest(testCase ExecutionEngineTestCase, withError bool, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() - factory, err := graphql_datasource.NewFactoryGRPC(context.Background(), grpcClient) - require.NoError(t, err) + if testCase.skipReason != "" { + t.Skip(testCase.skipReason) + } - return factory + engineConf := NewConfiguration(testCase.schema) + engineConf.SetDataSources(testCase.dataSources) + engineConf.SetFieldConfigurations(testCase.fields) + engineConf.SetCustomResolveMap(testCase.customResolveMap) + + engineConf.plannerConfig.Debug = plan.DebugConfiguration{ + // PrintOperationTransformations: true, + // PrintPlanningPaths: true, + // PrintNodeSuggestions: true, + // PrintQueryPlans: true, + // ConfigurationVisitor: true, + // PlanningVisitor: true, + // DatasourceVisitor: true, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var opts _executionTestOptions + for _, option := range options { + option(&opts) + } + engineConf.plannerConfig.BuildFetchReasons = opts.propagateFetchReasons + engineConf.plannerConfig.ValidateRequiredExternalFields = opts.validateRequiredExternalFields + engineConf.plannerConfig.ComputeCosts = opts.computeCosts + engineConf.plannerConfig.StaticCostDefaultListSize = 10 + engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability + resolveOpts := resolve.ResolverOptions{ + MaxConcurrency: 1024, + ResolvableOptions: opts.resolvableOptions, + ApolloRouterCompatibilitySubrequestHTTPError: opts.apolloRouterCompatibilitySubrequestHTTPError, + PropagateFetchReasons: opts.propagateFetchReasons, + ValidateRequiredExternalFields: opts.validateRequiredExternalFields, + } + engine, err := NewExecutionEngine(ctx, abstractlogger.Noop{}, engineConf, resolveOpts) + require.NoError(t, err) + + operation := testCase.operation(t) + resultWriter := graphql.NewEngineResultWriter() + execCtx, execCtxCancel := context.WithCancel(context.Background()) + defer execCtxCancel() + err = engine.Execute(execCtx, &operation, &resultWriter, testCase.engineOptions...) + actualResponse := resultWriter.String() + + if testCase.indentJSON { + dst := new(bytes.Buffer) + require.NoError(t, json.Indent(dst, []byte(actualResponse), "", " ")) + actualResponse = dst.String() + } + + if testCase.expectedFixture != "" { + g := goldie.New(t, goldie.WithFixtureDir("testdata"), goldie.WithNameSuffix(".json")) + g.Assert(t, testCase.expectedFixture, []byte(actualResponse)) + return + } + + if withError { + require.Error(t, err) + if expectedErrorMessage != "" { + assert.Equal(t, expectedErrorMessage, err.Error()) + } + } else { + require.NoError(t, err) + } + + if testCase.expectedJSONResponse != "" { + assert.JSONEq(t, testCase.expectedJSONResponse, actualResponse) + } + + if testCase.expectedResponse != "" { + assert.Equal(t, testCase.expectedResponse, actualResponse) + } + + if testCase.expectedEstimatedCost != 0 { + gotCost := operation.EstimatedCost() + require.Equal(t, testCase.expectedEstimatedCost, gotCost) + } + + if testCase.expectedActualCost != 0 { + gotActualCost := operation.ActualCost() + require.Equal(t, testCase.expectedActualCost, gotActualCost) + } + } +} + +func runWithAndCompareError(testCase ExecutionEngineTestCase, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { + return runExecutionTest(testCase, true, expectedErrorMessage, options...) +} + +func runWithoutError(testCase ExecutionEngineTestCase, options ...executionTestOptions) func(t *testing.T) { + return runExecutionTest(testCase, false, "", options...) } func mustGraphqlDataSourceConfiguration(t *testing.T, id string, factory plan.PlannerFactory[graphql_datasource.Configuration], metadata *plan.DataSourceMetadata, customConfig graphql_datasource.Configuration) plan.DataSourceConfiguration[graphql_datasource.Configuration] { @@ -262,113 +352,6 @@ func relaxFieldSelectionMergingNullability() executionTestOptions { } func TestExecutionEngine_Execute(t *testing.T) { - run := func(testCase ExecutionEngineTestCase, withError bool, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { - t.Helper() - - return func(t *testing.T) { - t.Helper() - - if testCase.skipReason != "" { - t.Skip(testCase.skipReason) - } - - engineConf := NewConfiguration(testCase.schema) - engineConf.SetDataSources(testCase.dataSources) - engineConf.SetFieldConfigurations(testCase.fields) - engineConf.SetCustomResolveMap(testCase.customResolveMap) - - engineConf.plannerConfig.Debug = plan.DebugConfiguration{ - // PrintOperationTransformations: true, - // PrintPlanningPaths: true, - // PrintNodeSuggestions: true, - // PrintQueryPlans: true, - // ConfigurationVisitor: true, - // PlanningVisitor: true, - // DatasourceVisitor: true, - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - var opts _executionTestOptions - for _, option := range options { - option(&opts) - } - engineConf.plannerConfig.BuildFetchReasons = opts.propagateFetchReasons - engineConf.plannerConfig.ValidateRequiredExternalFields = opts.validateRequiredExternalFields - engineConf.plannerConfig.ComputeCosts = opts.computeCosts - engineConf.plannerConfig.StaticCostDefaultListSize = 10 - engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability - resolveOpts := resolve.ResolverOptions{ - MaxConcurrency: 1024, - ResolvableOptions: opts.resolvableOptions, - ApolloRouterCompatibilitySubrequestHTTPError: opts.apolloRouterCompatibilitySubrequestHTTPError, - PropagateFetchReasons: opts.propagateFetchReasons, - ValidateRequiredExternalFields: opts.validateRequiredExternalFields, - } - engine, err := NewExecutionEngine(ctx, abstractlogger.Noop{}, engineConf, resolveOpts) - require.NoError(t, err) - - operation := testCase.operation(t) - resultWriter := graphql.NewEngineResultWriter() - execCtx, execCtxCancel := context.WithCancel(context.Background()) - defer execCtxCancel() - err = engine.Execute(execCtx, &operation, &resultWriter, testCase.engineOptions...) - actualResponse := resultWriter.String() - - if testCase.indentJSON { - dst := new(bytes.Buffer) - require.NoError(t, json.Indent(dst, []byte(actualResponse), "", " ")) - actualResponse = dst.String() - } - - if testCase.expectedFixture != "" { - g := goldie.New(t, goldie.WithFixtureDir("testdata"), goldie.WithNameSuffix(".json")) - g.Assert(t, testCase.expectedFixture, []byte(actualResponse)) - return - } - - if withError { - require.Error(t, err) - if expectedErrorMessage != "" { - assert.Equal(t, expectedErrorMessage, err.Error()) - } - } else { - require.NoError(t, err) - } - - if testCase.expectedJSONResponse != "" { - assert.JSONEq(t, testCase.expectedJSONResponse, actualResponse) - } - - if testCase.expectedResponse != "" { - assert.Equal(t, testCase.expectedResponse, actualResponse) - } - - if testCase.expectedEstimatedCost != 0 { - gotCost := operation.EstimatedCost() - require.Equal(t, testCase.expectedEstimatedCost, gotCost) - } - - if testCase.expectedActualCost != 0 { - gotActualCost := operation.ActualCost() - require.Equal(t, testCase.expectedActualCost, gotActualCost) - } - - } - } - - runWithAndCompareError := func(testCase ExecutionEngineTestCase, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { - t.Helper() - - return run(testCase, true, expectedErrorMessage, options...) - } - - runWithoutError := func(testCase ExecutionEngineTestCase, options ...executionTestOptions) func(t *testing.T) { - t.Helper() - - return run(testCase, false, "", options...) - } - t.Run("apollo router compatibility subrequest HTTP error enabled", runWithoutError( ExecutionEngineTestCase{ schema: graphql.StarwarsSchema(t), @@ -2346,7 +2329,7 @@ func TestExecutionEngine_Execute(t *testing.T) { schema, err := graphql.NewSchemaFromString(enumSDL) require.NoError(t, err) - t.Run("invalid non-nullable enum input", run( + t.Run("invalid non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -2409,7 +2392,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INVALID"; Value "INVALID" does not exist in "Enum" enum.`, )) - t.Run("nested invalid non-nullable enum input", run( + t.Run("nested invalid non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -2480,7 +2463,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INVALID"; Value "INVALID" does not exist in "Enum" enum.`, )) - t.Run("invalid nullable enum input", run( + t.Run("invalid nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -2543,7 +2526,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INVALID"; Value "INVALID" does not exist in "Enum" enum.`, )) - t.Run("nested invalid nullable enum input", run( + t.Run("nested invalid nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3214,7 +3197,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, )) - t.Run("inaccessible non-nullable enum input", run( + t.Run("inaccessible non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3277,7 +3260,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INACCESSIBLE"; Value "INACCESSIBLE" does not exist in "Enum" enum.`, )) - t.Run("nested inaccessible non-nullable enum input", run( + t.Run("nested inaccessible non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3348,7 +3331,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INACCESSIBLE"; Value "INACCESSIBLE" does not exist in "Enum" enum.`, )) - t.Run("inaccessible nullable enum input", run( + t.Run("inaccessible nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3411,7 +3394,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INACCESSIBLE"; Value "INACCESSIBLE" does not exist in "Enum" enum.`, )) - t.Run("nested inaccessible nullable enum input", run( + t.Run("nested inaccessible nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -5646,1948 +5629,6 @@ func TestExecutionEngine_Execute(t *testing.T) { }) }) - t.Run("costs computation", func(t *testing.T) { - t.Run("common on star wars scheme", func(t *testing.T) { - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"hero", "droid"}}, - {TypeName: "Human", FieldNames: []string{"name", "height", "friends"}}, - {TypeName: "Droid", FieldNames: []string{"name", "primaryFunction", "friends"}}, - } - childNodes := []plan.TypeField{ - {TypeName: "Character", FieldNames: []string{"name", "friends"}}, - } - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig( - t, - nil, - string(graphql.StarwarsSchema(t).RawSchema()), - ), - }) - - t.Run("droid with weighted plain fields", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - droid(id: "R2D2") { - name - primaryFunction - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - }}, - customConfig, - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - }, - expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - expectedEstimatedCost: 18, // Query.droid (1) + droid.name (17) - }, - computeCosts(), - )) - - t.Run("droid with weighted plain fields and an argument", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - droid(id: "R2D2") { - name - primaryFunction - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "droid"}: { - ArgumentWeights: map[string]int{"id": 3}, - HasWeight: false, - }, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - }}, - customConfig, - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - }, - expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - expectedEstimatedCost: 21, // Query.droid (1) + Query.droid.id (3) + droid.name (17) - }, - computeCosts(), - )) - - t.Run("negative weights - cost is never negative", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - droid(id: "R2D2") { - name - primaryFunction - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "droid"}: { - HasWeight: true, - Weight: -10, // Negative field weight - ArgumentWeights: map[string]int{"id": -5}, // Negative argument weight - }, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: -3}, - {TypeName: "Droid", FieldName: "primaryFunction"}: {HasWeight: true, Weight: -2}, - }, - Types: map[string]int{ - "Droid": -1, // Negative type weight - }, - }}, - customConfig, - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - }, - expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - // All weights are negative. - // But cost should be floored to 0 (never negative) - expectedEstimatedCost: 0, - }, - computeCosts(), - )) - - t.Run("hero field has weight (returns interface) and with concrete fragment", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - name - ... on Human { height } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker","height":"12"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 3}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - Types: map[string]int{ - "Human": 13, - }, - }}, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker","height":"12"}}}`, - expectedEstimatedCost: 22, // Query.hero (2) + Human.height (3) + Droid.name (17=max(7, 17)) - }, - computeCosts(), - )) - - t.Run("hero field has no weight (returns interface) and with concrete fragment", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { name } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - Types: map[string]int{ - "Human": 13, - "Droid": 11, - }, - }}, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker"}}}`, - expectedEstimatedCost: 30, // Query.Human (13) + Droid.name (17=max(7, 17)) - }, - computeCosts(), - )) - - t.Run("query hero without assumedSize on friends", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 127, // Query.hero(max(7,5))+10*(Human(max(7,5))+Human.name(2)+Human.height(1)+Droid.name(2)) - }, - computeCosts(), - )) - - t.Run("query hero with assumedSize on friends", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, - {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 247, // Query.hero(max(7,5))+ 20 * (7+2+2+1) - // We pick maximum on every path independently. This is to reveal the upper boundary. - // Query.hero: picked maximum weight (Human=7) out of two types (Human, Droid) - // Query.hero.friends: the max possible weight (7) is for implementing class Human - // of the returned type of Character; the multiplier picked for the Droid since - // it is the maximum possible value - we considered the enclosing type that contains it. - }, - computeCosts(), - )) - - t.Run("query hero with assumedSize on friends and weight defined", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "friends"}: {HasWeight: true, Weight: 3}, - {TypeName: "Droid", FieldName: "friends"}: {HasWeight: true, Weight: 4}, - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, - {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 187, // Query.hero(max(7,5))+ 20 * (4+2+2+1) - }, - computeCosts(), - )) - - t.Run("query hero with empty cost structures", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{}, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 11, // Query.hero(max(1,1))+ 10 * 1 - }, - computeCosts(), - )) - - // Actual cost tests - verifies that actual cost uses real list sizes from response - // rather than estimated/assumed sizes - - t.Run("actual cost with list field - 2 items instead of default 10", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - // Response has 2 friends (not 10 as estimated) - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2 + 1) = 127 - expectedEstimatedCost: 127, - // Actual uses real list size 2: hero(7) + 2 * (7 + 2 + 2 + 1) = 31 - expectedActualCost: 31, - }, - computeCosts(), - )) - - t.Run("actual cost with empty list", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name } - ...on Human { name } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - // Response has empty friends array - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[]}}}`, - // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2) = 117 - expectedEstimatedCost: 117, - // Actual with empty list: hero(7) + 1 * (7 + 2 + 2) = 18 - // We consider empty lists as lists containing one item to account for the - // resolver work. - expectedActualCost: 18, - }, - computeCosts(), - )) - - t.Run("named fragment on interface", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: ` - fragment CharacterFields on Character { - name - friends { name } - } - { hero { ...CharacterFields } } - `, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","friends":[{"name":"Leia"}]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 4}, - {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 6}, - }, - Types: map[string]int{ - "Human": 2, - "Droid": 3, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke","friends":[{"name":"Leia"}]}}}`, - // Cost calculation: - // Query.hero: 2 - // Character.name: max(Human.name=3, Droid.name=5) = 5 - // friends listSize: max(4, 6) = 6 - // Character type: max(Human=2, Droid=3) = 3 - // name: max(Human.name=3, Droid.name=5) = 5 - // Total: 2 + 5 + 6 * (3 + 5) - expectedEstimatedCost: 55, - }, - computeCosts(), - )) - - t.Run("named fragment with concrete type", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: ` - fragment HumanFields on Human { - name - height - } - { hero { ...HumanFields } } - `, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","height":"1.72"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 7}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, - }, - Types: map[string]int{ - "Human": 1, - "Droid": 1, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke","height":"1.72"}}}`, - // Total: 2 + 3 + 7 - expectedEstimatedCost: 12, - }, - computeCosts(), - )) - - }) - - t.Run("union types", func(t *testing.T) { - unionSchema := ` - type Query { - search(term: String!): [SearchResult!] - } - union SearchResult = User | Post | Comment - type User @key(fields: "id") { - id: ID! - name: String! - email: String! - } - type Post @key(fields: "id") { - id: ID! - title: String! - body: String! - } - type Comment @key(fields: "id") { - id: ID! - text: String! - } - ` - schema, err := graphql.NewSchemaFromString(unionSchema) - require.NoError(t, err) - - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"search"}}, - {TypeName: "User", FieldNames: []string{"id", "name", "email"}}, - {TypeName: "Post", FieldNames: []string{"id", "title", "body"}}, - {TypeName: "Comment", FieldNames: []string{"id", "text"}}, - } - childNodes := []plan.TypeField{} - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, unionSchema), - }) - fieldConfig := []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "search", - Path: []string{"search"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "term", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - } - - t.Run("union with all member types", runWithoutError( - ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - search(term: "test") { - ... on User { name email } - ... on Post { title body } - ... on Comment { text } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John","email":"john@test.com"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "User", FieldName: "email"}: {HasWeight: true, Weight: 3}, - {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 4}, - {TypeName: "Post", FieldName: "body"}: {HasWeight: true, Weight: 5}, - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "search"}: {AssumedSize: 5}, - }, - Types: map[string]int{ - "User": 2, - "Post": 3, - "Comment": 1, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"search":[{"name":"John","email":"john@test.com"}]}}`, - // search listSize: 10 - // For each SearchResult, use max across all union members: - // Type weight: max(User=2, Post=3, Comment=1) = 3 - // Fields: all fields from all fragments are counted - // (2 + 3) + (4 + 5) + (1) = 15 - // TODO: this is not correct, we should pick a maximum sum among types implementing union. - // 9 should be used instead of 15 - // Total: 5 * (3 + 15) - expectedEstimatedCost: 90, - }, - computeCosts(), - )) - - t.Run("union with weighted search field", runWithoutError( - ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - search(term: "test") { - ... on User { name } - ... on Post { title } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 5}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "search"}: {AssumedSize: 3}, - }, - Types: map[string]int{ - "User": 6, - "Post": 10, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"search":[{"name":"John"}]}}`, - // Query.search: max(User=10, Post=6) - // search listSize: 3 - // Union members: - // All fields from all fragments: User.name(2) + Post.title(5) - // Total: 3 * (10+2+5) - // TODO: we might correct this by counting only members of one implementing types - // of a union when fragments are used. - expectedEstimatedCost: 51, - }, - computeCosts(), - )) - }) - - t.Run("listSize", func(t *testing.T) { - listSchema := ` - type Query { - items(first: Int, last: Int): [Item!] - } - type Item @key(fields: "id") { - id: ID - } - ` - schemaSlicing, err := graphql.NewSchemaFromString(listSchema) - require.NoError(t, err) - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"items"}}, - {TypeName: "Item", FieldNames: []string{"id"}}, - } - childNodes := []plan.TypeField{} - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, listSchema), - }) - fieldConfig := []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "items", - Path: []string{"items"}, - Arguments: []plan.ArgumentConfiguration{ - { - Name: "first", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - { - Name: "last", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - } - t.Run("multiple slicing arguments as literals", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query MultipleSlicingArguments { - items(first: 5, last: 12) { id } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 8, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 3, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, - expectedEstimatedCost: 48, // slicingArgument(12) * (Item(3)+Item.id(1)) - }, - computeCosts(), - )) - t.Run("slicing argument as a variable", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query SlicingWithVariable($limit: Int!) { - items(first: $limit) { id } - }`, - Variables: []byte(`{"limit": 25}`), - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 8, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 3, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, - expectedEstimatedCost: 100, // slicingArgument($limit=25) * (Item(3)+Item.id(1)) - }, - computeCosts(), - )) - t.Run("slicing argument not provided falls back to assumedSize", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query NoSlicingArg { - items { id } - }`, - // No slicing arguments provided - should fall back to assumedSize - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 15, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, - expectedEstimatedCost: 45, // Total: 15 * (2 + 1) - }, - computeCosts(), - )) - t.Run("zero slicing argument falls back to assumedSize", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query ZeroSlicing { - items(first: 0) { id } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 20, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[]}}`, - expectedEstimatedCost: 60, // 20 * (2 + 1) - }, - computeCosts(), - )) - t.Run("negative slicing argument falls back to assumedSize", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query NegativeSlicing { - items(first: -5) { id } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 25, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[]}}`, - expectedEstimatedCost: 75, // 25 * (2 + 1) - }, - computeCosts(), - )) - - }) - - t.Run("nested lists with compounding multipliers", func(t *testing.T) { - nestedSchema := ` - type Query { - users(first: Int): [User!] - } - type User @key(fields: "id") { - id: ID! - posts(first: Int): [Post!] - } - type Post @key(fields: "id") { - id: ID! - comments(first: Int): [Comment!] - } - type Comment @key(fields: "id") { - id: ID! - text: String! - } - ` - schemaNested, err := graphql.NewSchemaFromString(nestedSchema) - require.NoError(t, err) - - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"users"}}, - {TypeName: "User", FieldNames: []string{"id", "posts"}}, - {TypeName: "Post", FieldNames: []string{"id", "comments"}}, - {TypeName: "Comment", FieldNames: []string{"id", "text"}}, - } - childNodes := []plan.TypeField{} - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, nestedSchema), - }) - fieldConfig := []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "users", Path: []string{"users"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "User", FieldName: "posts", Path: []string{"posts"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Post", FieldName: "comments", Path: []string{"comments"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - } - - t.Run("nested lists with slicing arguments", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - // Cost calculation: - // users(first:10): multiplier 10 - // User type weight: 4 - // posts(first:5): multiplier 5 - // Post type weight: 3 - // comments(first:3): multiplier 3 - // Comment type weight: 2 - // text weight: 1 - // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) - expectedEstimatedCost: 640, - }, - computeCosts(), - )) - - t.Run("nested lists fallback to assumedSize when slicing arg not provided", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 2) { - posts { - comments(first: 4) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, // no slicing arg, should use this - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, - // Cost calculation: - // users(first:2): multiplier 2 - // User type weight: 4 - // posts (no arg): assumedSize 50 - // Post type weight: 3 - // comments(first:4): multiplier 4 - // Comment type weight: 2 - // text weight: 1 - // Total: 2 * (4 + 50 * (3 + 4 * (2 + 1))) - expectedEstimatedCost: 1508, - }, - computeCosts(), - )) - - t.Run("actual cost for nested lists - 1 item at each level", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 1 user with 1 post with 1 comment - sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - // Estimated cost with slicing arguments (10, 5, 3): - // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) = 640 - expectedEstimatedCost: 640, - // Actual cost with 1 item at each level: - // Total: 1 * (4 + 1 * (3 + 1 * (2 + 1))) = 10 - expectedActualCost: 10, - }, - computeCosts(), - )) - - t.Run("actual cost for nested lists - varying sizes", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 2 users, each with 2 posts, each with 3 comments - sendResponseBody: `{"data":{"users":[ - {"posts":[ - {"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]}, - {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, - {"posts":[ - {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, - {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]},{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - expectedEstimatedCost: 640, - // Actual cost: 2 * (4 + 2 * (3 + 3 * (2 + 1))) = 56 - expectedActualCost: 56, - }, - computeCosts(), - )) - - t.Run("actual cost for nested lists - uneven sizes", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 2) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 2 users, with 1.5 posts each, each with 3 comments - sendResponseBody: `{"data":{"users":[ - {"posts":[ - {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, - {"posts":[ - {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, - {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - // Estimated : 10 * (4 + 5 * (3 + 2 * (2 + 1))) = 490 - expectedEstimatedCost: 490, - // Actual cost: 2 * (4 + 1.5 * (3 + 3 * (2 + 1))) = 44 - expectedActualCost: 44, - }, - computeCosts(), - )) - - t.Run("actual cost for root-level list - no parent", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ users(first: 10) { id } }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 3 users at the root level - sendResponseBody: `{"data":{"users":[ - {"id":"1"}, - {"id":"2"}, - {"id":"3"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "User", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"id":"1"},{"id":"2"},{"id":"3"}]}}`, - // Estimated: 10 * (4 + 1) = 50 - expectedEstimatedCost: 50, - // Actual cost: 3 users at root - // 3 * (4 + 1) = 15 - expectedActualCost: 15, - }, - computeCosts(), - )) - - t.Run("mixed empty and non-empty lists - averaging behavior", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"users":[ - {"posts":[ - {"comments":[{"text":"a"},{"text":"b"}]}, - {"comments":[{"text":"c"},{"text":"d"}]} - ]}, - {"posts":[]}, - {"posts":[ - {"comments":[]} - ]} - ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"}]},{"comments":[{"text":"c"},{"text":"d"}]}]},{"posts":[]},{"posts":[{"comments":[]}]}]}}`, - expectedEstimatedCost: 640, // 10 * (4 + 5 * (3 + 3 * (2 + 1))) - // Actual cost with mixed empty/non-empty lists: - // Users: 3 items, multiplier 3.0 - // Posts: 3 items, 3 parents => multiplier 1.0 (avg) - // Comments: 4 items, 3 parents => multiplier 1.33 (avg) - // - // Calculation: - // Comments: RoundToEven((2 + 1) * 1.33) ~= 4 - // Posts: RoundToEven((3 + 4) * 1.00) = 7 - // Users: RoundToEven((4 + 7) * 3.00) = 33 - // - // Empty lists are included in the averaging: - expectedActualCost: 33, - }, - computeCosts(), - )) - - t.Run("deeply nested lists with fractional multipliers - compounding rounding", runWithoutError( - ExecutionEngineTestCase{ - schema: func() *graphql.Schema { - deepSchema := ` - type Query { - level1(first: Int): [Level1!] - } - type Level1 @key(fields: "id") { - id: ID! - level2(first: Int): [Level2!] - } - type Level2 @key(fields: "id") { - id: ID! - level3(first: Int): [Level3!] - } - type Level3 @key(fields: "id") { - id: ID! - level4(first: Int): [Level4!] - } - type Level4 @key(fields: "id") { - id: ID! - level5(first: Int): [Level5!] - } - type Level5 @key(fields: "id") { - id: ID! - value: String! - } - ` - s, err := graphql.NewSchemaFromString(deepSchema) - require.NoError(t, err) - return s - }(), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - level1(first: 10) { - level2(first: 10) { - level3(first: 10) { - level4(first: 10) { - level5(first: 10) { - value - } - } - } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"level1":[ - {"level2":[ - {"level3":[ - {"level4":[ - {"level5":[{"value":"a"}]}, - {"level5":[{"value":"b"},{"value":"c"}]} - ]}, - {"level4":[ - {"level5":[{"value":"d"}]} - ]} - ]}, - {"level3":[ - {"level4":[ - {"level5":[{"value":"e"}]} - ]} - ]} - ]}, - {"level2":[ - {"level3":[ - {"level4":[ - {"level5":[{"value":"f"},{"value":"g"}]}, - {"level5":[{"value":"h"}]} - ]}, - {"level4":[ - {"level5":[{"value":"i"}]} - ]} - ]} - ]}, - {"level2":[ - {"level3":[ - {"level4":[ - {"level5":[{"value":"j"}]}, - {"level5":[{"value":"k"}]} - ]}, - {"level4":[ - {"level5":[{"value":"l"}]}, - {"level5":[{"value":"m"}]} - ]} - ]} - ]} - ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"level1"}}, - {TypeName: "Level1", FieldNames: []string{"id", "level2"}}, - {TypeName: "Level2", FieldNames: []string{"id", "level3"}}, - {TypeName: "Level3", FieldNames: []string{"id", "level4"}}, - {TypeName: "Level4", FieldNames: []string{"id", "level5"}}, - {TypeName: "Level5", FieldNames: []string{"id", "value"}}, - }, - ChildNodes: []plan.TypeField{}, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Level5", FieldName: "value"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "level1"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level1", FieldName: "level2"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level2", FieldName: "level3"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level3", FieldName: "level4"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level4", FieldName: "level5"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "Level1": 1, - "Level2": 1, - "Level3": 1, - "Level4": 1, - "Level5": 1, - }, - }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, ` - type Query { - level1(first: Int): [Level1!] - } - type Level1 @key(fields: "id") { - id: ID! - level2(first: Int): [Level2!] - } - type Level2 @key(fields: "id") { - id: ID! - level3(first: Int): [Level3!] - } - type Level3 @key(fields: "id") { - id: ID! - level4(first: Int): [Level4!] - } - type Level4 @key(fields: "id") { - id: ID! - level5(first: Int): [Level5!] - } - type Level5 @key(fields: "id") { - id: ID! - value: String! - } - `), - }), - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "level1", Path: []string{"level1"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level1", FieldName: "level2", Path: []string{"level2"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level2", FieldName: "level3", Path: []string{"level3"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level3", FieldName: "level4", Path: []string{"level4"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level4", FieldName: "level5", Path: []string{"level5"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - }, - expectedResponse: `{"data":{"level1":[{"level2":[{"level3":[{"level4":[{"level5":[{"value":"a"}]},{"level5":[{"value":"b"},{"value":"c"}]}]},{"level4":[{"level5":[{"value":"d"}]}]}]},{"level3":[{"level4":[{"level5":[{"value":"e"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"f"},{"value":"g"}]},{"level5":[{"value":"h"}]}]},{"level4":[{"level5":[{"value":"i"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"j"}]},{"level5":[{"value":"k"}]}]},{"level4":[{"level5":[{"value":"l"}]},{"level5":[{"value":"m"}]}]}]}]}]}}`, - expectedEstimatedCost: 211110, - // Actual cost with fractional multipliers: - // Level5: 13 items, 11 parents => multiplier 1.18 (13/11 = 1.181818...) - // Level4: 11 items, 7 parents => multiplier 1.57 (11/7 = 1.571428...) - // Level3: 7 items, 4 parents => multiplier 1.75 (7/4 = 1.75) - // Level2: 4 items, 3 parents => multiplier 1.33 (4/3 = 1.333...) - // Level1: 3 items, 1 parent => multiplier 3.0 - // - // Ideal calculation without rounding: - // cost = 3 * (1 + 1.33 * (1 + 1.75 * (1 + 1.57 * (1 + 1.18 * (1 + 1))))) - // = 50.806584 ~= 51 - // - // Current implementation: - // Level5: RoundToEven((1 + 1) * 1.18) = 2 - // Level4: RoundToEven((1 + 2) * 1.57) = 5 - // Level3: RoundToEven((1 + 5) * 1.75) = 10 (rounds to even) - // Level2: RoundToEven((1 + 10) * 1.33) = 15 - // Level1: RoundToEven((1 + 15) * 3.00) = 48 - // - // The compounding rounding error: 48 vs 51 (6% underestimate) - expectedActualCost: 48, - }, - computeCosts(), - )) - }) - - }) - t.Run("field merging with different nullability on non-overlapping union types", func(t *testing.T) { unionSchema := ` union Entity = User | Organization From 087d0a7c2452ebdefdc28aff49e17f0817c7d2b3 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 21:40:08 +0100 Subject: [PATCH 02/11] feat(plan): auto-split root field datasources in NewPlanner Move splitDataSourceByRootFieldCaching from execution/engine into v2/pkg/engine/plan and run it automatically inside NewPlanner. This ensures all callers (not just FederationEngineConfigFactory) benefit from the optimization transparently. Changes: - Add cloneForSplit method to dataSourceConfiguration[T] - Create datasource_split.go with split logic and dataSourceSplitter interface - Call SplitDataSourcesByRootFieldCaching in NewPlanner before duplicate ID check - Remove split methods from config_factory_federation.go - Migrate 8 unit tests to v2/pkg/engine/plan/datasource_split_test.go - Add auto-split verification test in planner_test.go - Add Name() method to dsBuilder for test snapshot assertions - Enhance E2E testing infrastructure with TTL tracking and cache log sorting Co-Authored-By: Claude Opus 4.6 --- .../engine/config_factory_federation_test.go | 1 + execution/engine/federation_caching_test.go | 380 ++++++++++++++++- .../engine/plan/datasource_configuration.go | 6 + .../plan/datasource_filter_visitor_test.go | 5 + v2/pkg/engine/plan/datasource_split.go | 176 ++++++++ v2/pkg/engine/plan/datasource_split_test.go | 403 ++++++++++++++++++ v2/pkg/engine/plan/planner.go | 8 + v2/pkg/engine/plan/planner_test.go | 244 +++++++++++ 8 files changed, 1219 insertions(+), 4 deletions(-) create mode 100644 v2/pkg/engine/plan/datasource_split.go create mode 100644 v2/pkg/engine/plan/datasource_split_test.go diff --git a/execution/engine/config_factory_federation_test.go b/execution/engine/config_factory_federation_test.go index 3b06b1e986..1508d13096 100644 --- a/execution/engine/config_factory_federation_test.go +++ b/execution/engine/config_factory_federation_test.go @@ -453,3 +453,4 @@ type Review { product: Product! }` ) + diff --git a/execution/engine/federation_caching_test.go b/execution/engine/federation_caching_test.go index e4a38f4278..d9192fdaf8 100644 --- a/execution/engine/federation_caching_test.go +++ b/execution/engine/federation_caching_test.go @@ -2606,10 +2606,11 @@ func cachingTestQueryPath(name string) string { } type CacheLogEntry struct { - Operation string // "get", "set", "delete" - Keys []string // Keys involved in the operation - Hits []bool // For Get: whether each key was a hit (true) or miss (false) - Caller string // Fetch identity when debug enabled: "accounts: entity(User)" or "products: rootField(Query.topProducts)" + Operation string // "get", "set", "delete" + Keys []string // Keys involved in the operation + Hits []bool // For Get: whether each key was a hit (true) or miss (false) + TTL time.Duration // For Set: the TTL used + Caller string // Fetch identity when debug enabled: "accounts: entity(User)" or "products: rootField(Query.topProducts)" } // sortCacheLogKeys sorts the keys (and corresponding hits) in each cache log entry. @@ -2664,6 +2665,26 @@ func sortCacheLogKeys(log []CacheLogEntry) []CacheLogEntry { return sorted } +// sortCacheLogEntries sorts both the entries (by operation+first key) and the keys within entries. +// Use this when log entry order is non-deterministic (e.g., split datasources executing in parallel). +func sortCacheLogEntries(log []CacheLogEntry) []CacheLogEntry { + sorted := sortCacheLogKeys(log) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].Operation != sorted[b].Operation { + return sorted[a].Operation < sorted[b].Operation + } + keyA, keyB := "", "" + if len(sorted[a].Keys) > 0 { + keyA = sorted[a].Keys[0] + } + if len(sorted[b].Keys) > 0 { + keyB = sorted[b].Keys[0] + } + return keyA < keyB + }) + return sorted +} + // sortCacheLogKeysWithCaller is like sortCacheLogKeys but preserves the Caller field. // Use this when you want assertions to verify which Loader method chain triggered each cache event. func sortCacheLogKeysWithCaller(log []CacheLogEntry) []CacheLogEntry { @@ -2827,6 +2848,7 @@ func (f *FakeLoaderCache) Set(ctx context.Context, entries []*resolve.CacheEntry Operation: "set", Keys: keys, Hits: nil, // Set operations don't have hits/misses + TTL: ttl, Caller: caller, }) @@ -6523,3 +6545,353 @@ func TestFederationCachingAliases(t *testing.T) { assert.Equal(t, 1, accountsCalls, "Should call accounts subgraph once (sameUserReviewers skipped via L1)") }) } + +func TestRootFieldSplitByDatasource(t *testing.T) { + t.Run("two root fields same subgraph both cached", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + + // First query - both fields miss cache, get set + defaultCache.ClearLog() + tracker.Reset() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterFirst := defaultCache.GetLog() + // Each cached root field gets its own fetch: get+set for me, get+set for cat + assert.Equal(t, 4, len(logAfterFirst), "Should have 4 cache operations (get+set for me, get+set for cat)") + + wantLogFirst := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{false}}, + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"cat"}`}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogFirst), sortCacheLogEntries(logAfterFirst)) + + // Split datasources cause 2 separate calls to accounts subgraph + assert.Equal(t, 2, tracker.GetCount(accountsHost), "Should call accounts subgraph twice (once per root field)") + + // Second query - both fields hit cache + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterSecond := defaultCache.GetLog() + assert.Equal(t, 2, len(logAfterSecond), "Should have 2 cache get operations (both hits)") + + wantLogSecond := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{true}}, + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{true}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogSecond), sortCacheLogEntries(logAfterSecond)) + + assert.Equal(t, 0, tracker.GetCount(accountsHost), "Should not call accounts subgraph (both cache hits)") + }) + + t.Run("two root fields different TTLs", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // First query populates cache + defaultCache.ClearLog() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterFirst := defaultCache.GetLog() + assert.Equal(t, 4, len(logAfterFirst), "Should have 4 cache operations (get+set for each field)") + + // Verify TTLs are set independently by checking the set operations + var meTTL, catTTL time.Duration + for _, entry := range logAfterFirst { + if entry.Operation == "set" && len(entry.Keys) == 1 { + if strings.Contains(entry.Keys[0], `"field":"me"`) { + meTTL = entry.TTL + } + if strings.Contains(entry.Keys[0], `"field":"cat"`) { + catTTL = entry.TTL + } + } + } + assert.Equal(t, 10*time.Second, meTTL, "me field should have 10s TTL") + assert.Equal(t, 60*time.Second, catTTL, "cat field should have 60s TTL") + }) + + t.Run("mixed cached and uncached root fields", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + // Only me has caching, cat does not + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + + // First query + defaultCache.ClearLog() + tracker.Reset() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterFirst := defaultCache.GetLog() + // Only "me" has caching: get (miss) + set + assert.Equal(t, 2, len(logAfterFirst), "Should have 2 cache operations (get+set for me only)") + + wantLogFirst := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogFirst), sortCacheLogEntries(logAfterFirst)) + + // accounts called twice: once for me (split ds), once for cat (remainder ds) + assert.Equal(t, 2, tracker.GetCount(accountsHost), "Should call accounts subgraph twice (once per split datasource)") + + // Second query - me hits cache, cat still fetches + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterSecond := defaultCache.GetLog() + assert.Equal(t, 1, len(logAfterSecond), "Should have 1 cache get (me hit)") + + wantLogSecond := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{true}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogSecond), sortCacheLogEntries(logAfterSecond)) + + // Only cat (uncached) needs subgraph call + assert.Equal(t, 1, tracker.GetCount(accountsHost), "Should call accounts subgraph once (cat only, me from cache)") + }) + + t.Run("root field split with entity caching", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + EntityCaching: plan.EntityCacheConfigurations{ + {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + { + SubgraphName: "products", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "topProducts", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + { + SubgraphName: "reviews", + EntityCaching: plan.EntityCacheConfigurations{ + {TypeName: "Product", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + productsURLParsed, _ := url.Parse(setup.ProductsUpstreamServer.URL) + reviewsURLParsed, _ := url.Parse(setup.ReviewsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + productsHost := productsURLParsed.Host + reviewsHost := reviewsURLParsed.Host + + // Query that exercises root field split (me + cat from accounts) and entity caching (User from accounts) + query := `{ + me { id username } + cat { name } + topProducts { + name + reviews { + body + authorWithoutProvides { username } + } + } + }` + + // First query - all misses + defaultCache.ClearLog() + tracker.Reset() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, query, nil, t) + assert.Contains(t, string(resp), `"me":{"id":"1234","username":"Me"}`) + assert.Contains(t, string(resp), `"cat":{"name":"Pepper"}`) + assert.Contains(t, string(resp), `"topProducts"`) + + // accounts: 2 for root field split (me + cat) + 1 for User entity resolution + assert.Equal(t, 3, tracker.GetCount(accountsHost), "accounts: once for me, once for cat, once for User entity") + assert.Equal(t, 1, tracker.GetCount(productsHost), "products: once for topProducts") + assert.Equal(t, 1, tracker.GetCount(reviewsHost), "reviews: once for Product entity") + + // Second query - all cache hits + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, query, nil, t) + assert.Contains(t, string(resp), `"me":{"id":"1234","username":"Me"}`) + assert.Contains(t, string(resp), `"cat":{"name":"Pepper"}`) + assert.Contains(t, string(resp), `"topProducts"`) + + // All subgraphs should be skipped on second query + assert.Equal(t, 0, tracker.GetCount(accountsHost), "accounts: all from cache") + assert.Equal(t, 0, tracker.GetCount(productsHost), "products: root field from cache") + assert.Equal(t, 0, tracker.GetCount(reviewsHost), "reviews: entity from cache") + }) + + t.Run("independent cache invalidation", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + + // First query - populate cache for both fields + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + // Invalidate only the "me" cache entry + err := defaultCache.Delete(ctx, []string{`{"__typename":"Query","field":"me"}`}) + require.NoError(t, err) + + // Second query - me should miss (re-fetch), cat should hit + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterSecond := defaultCache.GetLog() + wantLog := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, // Invalidated + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, // Re-cached + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{true}}, // Still cached + } + assert.Equal(t, sortCacheLogEntries(wantLog), sortCacheLogEntries(logAfterSecond)) + + // Only me needs re-fetch, cat served from cache + assert.Equal(t, 1, tracker.GetCount(accountsHost), "Should call accounts once (me re-fetch only)") + }) +} diff --git a/v2/pkg/engine/plan/datasource_configuration.go b/v2/pkg/engine/plan/datasource_configuration.go index e51ea16fbf..ee5d82e6a4 100644 --- a/v2/pkg/engine/plan/datasource_configuration.go +++ b/v2/pkg/engine/plan/datasource_configuration.go @@ -299,6 +299,12 @@ func (d *dataSourceConfiguration[T]) CustomConfiguration() T { return d.custom } +// cloneForSplit creates a new dataSourceConfiguration with the same factory and custom config +// but with a new ID and metadata. Used internally by splitSingleDataSourceByRootFieldCaching. +func (d *dataSourceConfiguration[T]) cloneForSplit(newID string, metadata *DataSourceMetadata) (DataSource, error) { + return NewDataSourceConfigurationWithName[T](newID, d.name, d.factory, metadata, d.custom) +} + func (d *dataSourceConfiguration[T]) CreatePlannerConfiguration(logger abstractlogger.Logger, fetchConfig *objectFetchConfiguration, pathConfig *plannerPathsConfiguration, configuration *Configuration) PlannerConfiguration { if configuration.RelaxSubgraphOperationFieldSelectionMergingNullability { if relaxer, ok := d.factory.(SubgraphFieldSelectionMergingNullabilityRelaxer); ok { diff --git a/v2/pkg/engine/plan/datasource_filter_visitor_test.go b/v2/pkg/engine/plan/datasource_filter_visitor_test.go index c385c23d26..f833bec969 100644 --- a/v2/pkg/engine/plan/datasource_filter_visitor_test.go +++ b/v2/pkg/engine/plan/datasource_filter_visitor_test.go @@ -101,6 +101,11 @@ func (b *dsBuilder) Id(id string) *dsBuilder { b.ds.id = id return b } + +func (b *dsBuilder) Name(name string) *dsBuilder { + b.ds.name = name + return b +} func (b *dsBuilder) DS() DataSource { if err := b.ds.DataSourceMetadata.Init(); err != nil { panic(err) diff --git a/v2/pkg/engine/plan/datasource_split.go b/v2/pkg/engine/plan/datasource_split.go new file mode 100644 index 0000000000..222028ea76 --- /dev/null +++ b/v2/pkg/engine/plan/datasource_split.go @@ -0,0 +1,176 @@ +package plan + +import "fmt" + +// dataSourceSplitter is implemented by dataSourceConfiguration[T] to enable +// cloning a datasource with new ID and metadata during root field splitting. +type dataSourceSplitter interface { + cloneForSplit(newID string, metadata *DataSourceMetadata) (DataSource, error) +} + +// SplitDataSourcesByRootFieldCaching splits datasources that have root field caching +// configured into separate per-field datasources. This ensures each cacheable root field +// gets its own fetch, enabling independent L2 caching per field. +// +// Why split? The planner merges root fields from the same datasource into a single fetch. +// This means a query like { me { id } cat { name } } produces one request to the subgraph. +// However, configureFetchCaching requires all root fields in a fetch to have identical +// cache configs. By splitting each cached root field into its own datasource, the planner +// creates separate fetches, and each fetch can have its own TTL and cache key. +// +// The split produces up to N+1 datasources from the original: +// - One datasource per cached root field (each with its own RootFieldCaching entry) +// - One remainder datasource for all uncached root fields (no RootFieldCaching) +// +// All split datasources share the same non-Query root nodes (entity types, Mutation, +// Subscription), child nodes, entity caching config, and federation metadata (keys, +// requires, provides). This preserves entity resolution capability across all splits. +func SplitDataSourcesByRootFieldCaching(dataSources []DataSource) ([]DataSource, error) { + var result []DataSource + for _, ds := range dataSources { + split, err := splitSingleDataSourceByRootFieldCaching(ds) + if err != nil { + return nil, fmt.Errorf("failed to split data source %s by root field caching: %w", ds.Id(), err) + } + result = append(result, split...) + } + return result, nil +} + +func splitSingleDataSourceByRootFieldCaching(ds DataSource) ([]DataSource, error) { + fedConfig := ds.FederationConfiguration() + + // No root field caching configured — nothing to split + if len(fedConfig.RootFieldCaching) == 0 { + return []DataSource{ds}, nil + } + + // Check if the datasource supports cloning (all dataSourceConfiguration[T] do) + splitter, ok := ds.(dataSourceSplitter) + if !ok { + return []DataSource{ds}, nil + } + + nodesAccess, ok := ds.(NodesAccess) + if !ok { + return []DataSource{ds}, nil + } + + // Find the Query root node — we only split Query fields, not Mutation/Subscription + rootNodes := nodesAccess.ListRootNodes() + var queryNodeIdx int = -1 + for i, node := range rootNodes { + if node.TypeName == "Query" { + queryNodeIdx = i + break + } + } + if queryNodeIdx == -1 { + // No Query root node — nothing to split (entity-only datasource) + return []DataSource{ds}, nil + } + + // Partition Query fields into cached and uncached buckets + queryNode := rootNodes[queryNodeIdx] + var cachedFields, uncachedFields []string + for _, fieldName := range queryNode.FieldNames { + if fedConfig.RootFieldCaching.FindByTypeAndField("Query", fieldName) != nil { + cachedFields = append(cachedFields, fieldName) + } else { + uncachedFields = append(uncachedFields, fieldName) + } + } + + // Skip splitting when there's only a single cached field and no uncached fields. + // A single-field datasource already gets its own fetch — splitting adds no benefit. + if len(cachedFields) <= 1 && len(uncachedFields) == 0 { + return []DataSource{ds}, nil + } + + childNodes := nodesAccess.ListChildNodes() + + // Collect non-Query root nodes (e.g. User entity, Mutation) — these are shared + // across all split datasources so entity resolution continues to work + var nonQueryRootNodes TypeFields + for _, node := range rootNodes { + if node.TypeName != "Query" { + nonQueryRootNodes = append(nonQueryRootNodes, node) + } + } + + var result []DataSource + + // Create one datasource per cached Query root field. + // Each gets a unique ID (original_rf_fieldName) and only its own cache config. + for _, fieldName := range cachedFields { + // Build root nodes: single Query field + all non-Query root nodes + splitRootNodes := make(TypeFields, 0, len(nonQueryRootNodes)+1) + splitRootNodes = append(splitRootNodes, TypeField{ + TypeName: "Query", + FieldNames: []string{fieldName}, + ExternalFieldNames: queryNode.ExternalFieldNames, + FetchReasonFields: queryNode.FetchReasonFields, + }) + splitRootNodes = append(splitRootNodes, nonQueryRootNodes...) + + // Attach only this field's cache config to the new datasource + cacheConfig := fedConfig.RootFieldCaching.FindByTypeAndField("Query", fieldName) + metadata := cloneMetadataForSplit(ds, splitRootNodes, childNodes) + metadata.FederationMetaData.RootFieldCaching = RootFieldCacheConfigurations{*cacheConfig} + + splitID := fmt.Sprintf("%s_rf_%s", ds.Id(), fieldName) + splitDS, err := splitter.cloneForSplit(splitID, metadata) + if err != nil { + return nil, err + } + result = append(result, splitDS) + } + + // Create a remainder datasource for uncached fields (if any). + // This keeps the original datasource ID so existing planner behavior is preserved. + if len(uncachedFields) > 0 { + remainderRootNodes := make(TypeFields, 0, len(nonQueryRootNodes)+1) + remainderRootNodes = append(remainderRootNodes, TypeField{ + TypeName: "Query", + FieldNames: uncachedFields, + ExternalFieldNames: queryNode.ExternalFieldNames, + FetchReasonFields: queryNode.FetchReasonFields, + }) + remainderRootNodes = append(remainderRootNodes, nonQueryRootNodes...) + + metadata := cloneMetadataForSplit(ds, remainderRootNodes, childNodes) + // Explicitly clear root field caching — uncached fields should not inherit cache config + metadata.FederationMetaData.RootFieldCaching = nil + + remainderDS, err := splitter.cloneForSplit(ds.Id(), metadata) + if err != nil { + return nil, err + } + result = append(result, remainderDS) + } + + return result, nil +} + +// cloneMetadataForSplit creates new DataSourceMetadata with the given root nodes +// while preserving all federation metadata, child nodes, and directives from the original. +func cloneMetadataForSplit(original DataSource, rootNodes, childNodes TypeFields) *DataSourceMetadata { + origFed := original.FederationConfiguration() + origDirectives := original.DirectiveConfigurations() + + return &DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + Directives: origDirectives, + FederationMetaData: FederationMetaData{ + Keys: origFed.Keys, + Requires: origFed.Requires, + Provides: origFed.Provides, + EntityInterfaces: origFed.EntityInterfaces, + InterfaceObjects: origFed.InterfaceObjects, + EntityCaching: origFed.EntityCaching, + RootFieldCaching: origFed.RootFieldCaching, + SubscriptionEntityPopulation: origFed.SubscriptionEntityPopulation, + }, + } +} diff --git a/v2/pkg/engine/plan/datasource_split_test.go b/v2/pkg/engine/plan/datasource_split_test.go new file mode 100644 index 0000000000..25120f0916 --- /dev/null +++ b/v2/pkg/engine/plan/datasource_split_test.go @@ -0,0 +1,403 @@ +package plan + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// splitDSSnapshot captures the essential state of a split datasource for full assertion. +// Uses plain types instead of plan types to avoid unexported field comparison issues +// (e.g. FederationFieldConfiguration.parsedSelectionSet). +type splitDSSnapshot struct { + ID string + Name string + RootNodes []snapshotTypeField + ChildNodes []snapshotTypeField + Keys []snapshotKey + EntityCaching []snapshotEntityCache + RootFieldCaching []snapshotRootFieldCache +} + +type snapshotTypeField struct { + TypeName string + FieldNames []string +} + +type snapshotKey struct { + TypeName string + SelectionSet string +} + +type snapshotEntityCache struct { + TypeName string + CacheName string + TTL time.Duration +} + +type snapshotRootFieldCache struct { + TypeName string + FieldName string + CacheName string + TTL time.Duration +} + +// snapshotDS extracts a splitDSSnapshot from a datasource for comparison. +func snapshotDS(t *testing.T, ds DataSource) splitDSSnapshot { + t.Helper() + na, ok := ds.(NodesAccess) + require.True(t, ok) + fed := ds.FederationConfiguration() + + var rootNodes []snapshotTypeField + for _, n := range na.ListRootNodes() { + rootNodes = append(rootNodes, snapshotTypeField{TypeName: n.TypeName, FieldNames: n.FieldNames}) + } + var childNodes []snapshotTypeField + for _, n := range na.ListChildNodes() { + childNodes = append(childNodes, snapshotTypeField{TypeName: n.TypeName, FieldNames: n.FieldNames}) + } + var keys []snapshotKey + for _, k := range fed.Keys { + keys = append(keys, snapshotKey{TypeName: k.TypeName, SelectionSet: k.SelectionSet}) + } + var entityCaching []snapshotEntityCache + for _, e := range fed.EntityCaching { + entityCaching = append(entityCaching, snapshotEntityCache{TypeName: e.TypeName, CacheName: e.CacheName, TTL: e.TTL}) + } + var rootFieldCaching []snapshotRootFieldCache + for _, r := range fed.RootFieldCaching { + rootFieldCaching = append(rootFieldCaching, snapshotRootFieldCache{TypeName: r.TypeName, FieldName: r.FieldName, CacheName: r.CacheName, TTL: r.TTL}) + } + + return splitDSSnapshot{ + ID: ds.Id(), + Name: ds.Name(), + RootNodes: rootNodes, + ChildNodes: childNodes, + Keys: keys, + EntityCaching: entityCaching, + RootFieldCaching: rootFieldCaching, + } +} + +// snapshotAll extracts snapshots from all datasources, keyed by ID. +func snapshotAll(t *testing.T, dss []DataSource) map[string]splitDSSnapshot { + t.Helper() + result := make(map[string]splitDSSnapshot, len(dss)) + for _, ds := range dss { + result[ds.Id()] = snapshotDS(t, ds) + } + return result +} + +// splitTestDS creates a datasource for split testing using the dsBuilder. +// Schema is required so that cloneForSplit has a valid factory. +func splitTestDS(id, name string) *dsBuilder { + return dsb().Id(id).Name(name).Schema("type Query { placeholder: String }") +} + +func TestSplitDataSourcesByRootFieldCaching(t *testing.T) { + t.Run("no RootFieldCaching - no split", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat"). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 1, len(result)) + assert.Equal(t, "0", result[0].Id(), "original DS returned unchanged") + }) + + t.Run("two cached root fields - 2 separate datasources", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat"). + RootNode("User", "id", "username"). + ChildNode("Cat", "name"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 2, len(result)) + + snapshots := snapshotAll(t, result) + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_me", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"me"}}, + {TypeName: "User", FieldNames: []string{"id", "username"}}, + }, + ChildNodes: []snapshotTypeField{ + {TypeName: "Cat", FieldNames: []string{"name"}}, + }, + Keys: []snapshotKey{ + {TypeName: "User", SelectionSet: "id"}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + }, + }, snapshots["0_rf_me"]) + + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_cat", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"cat"}}, + {TypeName: "User", FieldNames: []string{"id", "username"}}, + }, + ChildNodes: []snapshotTypeField{ + {TypeName: "Cat", FieldNames: []string{"name"}}, + }, + Keys: []snapshotKey{ + {TypeName: "User", SelectionSet: "id"}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + }, + }, snapshots["0_rf_cat"]) + }) + + t.Run("3 root fields, 2 cached, 1 uncached - 3 datasources", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat", "user"). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 3, len(result)) + + snapshots := snapshotAll(t, result) + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_me", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"me"}}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, + }, + }, snapshots["0_rf_me"]) + + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_cat", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"cat"}}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + }, + }, snapshots["0_rf_cat"]) + + assert.Equal(t, splitDSSnapshot{ + ID: "0", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"user"}}, + }, + }, snapshots["0"]) + }) + + t.Run("entity-only caching, no root field caching - no split", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat"). + RootNode("User", "id", "username"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }). + WithMetadata(func(data *FederationMetaData) { + data.EntityCaching = EntityCacheConfigurations{ + {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 1, len(result)) + assert.Equal(t, "0", result[0].Id(), "original DS returned unchanged") + }) + + t.Run("single cached root field, no uncached - no split", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me"). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 1, len(result)) + assert.Equal(t, "0", result[0].Id(), "original DS returned unchanged") + }) + + t.Run("single cached + uncached fields - 2 datasources", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat"). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 2, len(result)) + + snapshots := snapshotAll(t, result) + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_me", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"me"}}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + }, + }, snapshots["0_rf_me"]) + + assert.Equal(t, splitDSSnapshot{ + ID: "0", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"cat"}}, + }, + }, snapshots["0"]) + }) + + t.Run("entity caching preserved on all split datasources", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat"). + RootNode("User", "id", "username"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }). + WithMetadata(func(data *FederationMetaData) { + data.EntityCaching = EntityCacheConfigurations{ + {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, + } + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 2, len(result)) + + snapshots := snapshotAll(t, result) + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_me", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"me"}}, + {TypeName: "User", FieldNames: []string{"id", "username"}}, + }, + Keys: []snapshotKey{ + {TypeName: "User", SelectionSet: "id"}, + }, + EntityCaching: []snapshotEntityCache{ + {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, + }, + }, snapshots["0_rf_me"]) + + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_cat", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"cat"}}, + {TypeName: "User", FieldNames: []string{"id", "username"}}, + }, + Keys: []snapshotKey{ + {TypeName: "User", SelectionSet: "id"}, + }, + EntityCaching: []snapshotEntityCache{ + {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + }, + }, snapshots["0_rf_cat"]) + }) + + t.Run("non-Query root nodes preserved on all split datasources", func(t *testing.T) { + ds := splitTestDS("0", "accounts"). + RootNode("Query", "me", "cat"). + RootNode("User", "id", "username"). + RootNode("Mutation", "updateUser"). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + } + }). + DS() + + result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) + require.NoError(t, err) + require.Equal(t, 2, len(result)) + + snapshots := snapshotAll(t, result) + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_me", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"me"}}, + {TypeName: "User", FieldNames: []string{"id", "username"}}, + {TypeName: "Mutation", FieldNames: []string{"updateUser"}}, + }, + Keys: []snapshotKey{ + {TypeName: "User", SelectionSet: "id"}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + }, + }, snapshots["0_rf_me"]) + + assert.Equal(t, splitDSSnapshot{ + ID: "0_rf_cat", + Name: "accounts", + RootNodes: []snapshotTypeField{ + {TypeName: "Query", FieldNames: []string{"cat"}}, + {TypeName: "User", FieldNames: []string{"id", "username"}}, + {TypeName: "Mutation", FieldNames: []string{"updateUser"}}, + }, + Keys: []snapshotKey{ + {TypeName: "User", SelectionSet: "id"}, + }, + RootFieldCaching: []snapshotRootFieldCache{ + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + }, snapshots["0_rf_cat"]) + }) +} diff --git a/v2/pkg/engine/plan/planner.go b/v2/pkg/engine/plan/planner.go index f96cc735be..ff4244688a 100644 --- a/v2/pkg/engine/plan/planner.go +++ b/v2/pkg/engine/plan/planner.go @@ -41,6 +41,14 @@ func NewPlanner(config Configuration) (*Planner, error) { config.Logger = abstractlogger.Noop{} } + // Auto-split datasources that have root field caching configured. + // This must happen before the duplicate ID check because splitting creates new IDs. + var err error + config.DataSources, err = SplitDataSourcesByRootFieldCaching(config.DataSources) + if err != nil { + return nil, err + } + entityInterfaceNames := make([]string, 0, 1) dsIDs := make(map[string]struct{}, len(config.DataSources)) for _, ds := range config.DataSources { diff --git a/v2/pkg/engine/plan/planner_test.go b/v2/pkg/engine/plan/planner_test.go index 2f3886a227..2fd821f666 100644 --- a/v2/pkg/engine/plan/planner_test.go +++ b/v2/pkg/engine/plan/planner_test.go @@ -815,6 +815,250 @@ func TestPlanner_Plan(t *testing.T) { assert.Equal(t, plan2Expected, plan2) }) + + // Root field datasource splitting tests + // These tests prove that when datasources are pre-split (as splitDataSourceByRootFieldCaching + // would produce), the planner generates valid plans with separate fetches per datasource. + // Note: FakeFactory creates one fetch per root field regardless of DS grouping, + // so these tests focus on verifying the planner handles split configurations correctly. + t.Run("root field splitting", func(t *testing.T) { + const splitSchema = ` + type Query { + me: User + cat: Cat + user(id: ID!): User + } + type User { + id: ID! + username: String! + } + type Cat { + name: String! + } + ` + + t.Run("two split DSs plan correctly", func(t *testing.T) { + ds1 := dsb(). + Id("accounts_rf_me"). + RootNode("Query", "me"). + ChildNode("User", "id", "username"). + Schema(splitSchema). + Hash(1). + DS() + + ds2 := dsb(). + Id("accounts_rf_cat"). + RootNode("Query", "cat"). + ChildNode("Cat", "name"). + Schema(splitSchema). + Hash(2). + DS() + + config := Configuration{ + DataSources: []DataSource{ds1, ds2}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + } + + var report operationreport.Report + p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) + require.False(t, report.HasErrors()) + + syncPlan, ok := p.(*SynchronousResponsePlan) + require.True(t, ok) + assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "two split DSs should produce two fetches") + }) + + t.Run("mixed split - cached field separate, uncached stays on original", func(t *testing.T) { + // DS1: only Query.me (simulating split cached field) + ds1 := dsb(). + Id("accounts_rf_me"). + RootNode("Query", "me"). + ChildNode("User", "id", "username"). + Schema(splitSchema). + Hash(1). + DS() + + // DS2: Query.cat + Query.user (simulating remainder with uncached fields) + ds2 := dsb(). + Id("accounts"). + RootNode("Query", "cat", "user"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(splitSchema). + Hash(2). + DS() + + config := Configuration{ + DataSources: []DataSource{ds1, ds2}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + } + + var report operationreport.Report + p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) + require.False(t, report.HasErrors()) + + syncPlan, ok := p.(*SynchronousResponsePlan) + require.True(t, ok) + assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "mixed split should produce two fetches") + }) + + t.Run("three separate DSs produce three fetches", func(t *testing.T) { + ds1 := dsb(). + Id("accounts_rf_me"). + RootNode("Query", "me"). + ChildNode("User", "id", "username"). + Schema(splitSchema). + Hash(1). + DS() + + ds2 := dsb(). + Id("accounts_rf_cat"). + RootNode("Query", "cat"). + ChildNode("Cat", "name"). + Schema(splitSchema). + Hash(2). + DS() + + ds3 := dsb(). + Id("accounts_rf_user"). + RootNode("Query", "user"). + ChildNode("User", "id", "username"). + Schema(splitSchema). + Hash(3). + DS() + + config := Configuration{ + DataSources: []DataSource{ds1, ds2, ds3}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + } + + var report operationreport.Report + p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } user(id: "1") { username } }`, "Q", config, &report) + require.False(t, report.HasErrors()) + + syncPlan, ok := p.(*SynchronousResponsePlan) + require.True(t, ok) + assert.Equal(t, 3, len(syncPlan.Response.RawFetches), "three separate DSs should produce three fetches") + }) + + t.Run("split DSs with entity root nodes plan correctly", func(t *testing.T) { + // Both split DSs share entity root nodes (User) for entity resolution from other subgraphs. + // This verifies the planner handles split DSs with shared entity root nodes without errors. + ds1 := dsb(). + Id("accounts_rf_me"). + RootNode("Query", "me"). + RootNode("User", "id", "username"). + ChildNode("User", "id", "username"). + Schema(splitSchema). + Hash(11). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }). + DS() + + ds2 := dsb(). + Id("accounts_rf_cat"). + RootNode("Query", "cat"). + RootNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(splitSchema). + Hash(12). + KeysMetadata(FederationFieldConfigurations{ + {TypeName: "User", SelectionSet: "id"}, + }). + DS() + + config := Configuration{ + DataSources: []DataSource{ds1, ds2}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + } + + var report operationreport.Report + p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) + require.False(t, report.HasErrors()) + + syncPlan, ok := p.(*SynchronousResponsePlan) + require.True(t, ok) + // 4 fetches: me root from DS1, cat root from DS2, + // plus User entity fetches from both DS1 and DS2 (both have User root nodes) + assert.Equal(t, 4, len(syncPlan.Response.RawFetches), "split DSs with entity root nodes should produce four fetches") + }) + + t.Run("query selecting from only one split DS produces single fetch", func(t *testing.T) { + ds1 := dsb(). + Id("accounts_rf_me"). + RootNode("Query", "me"). + ChildNode("User", "id", "username"). + Schema(splitSchema). + Hash(1). + DS() + + ds2 := dsb(). + Id("accounts_rf_cat"). + RootNode("Query", "cat"). + ChildNode("Cat", "name"). + Schema(splitSchema). + Hash(2). + DS() + + config := Configuration{ + DataSources: []DataSource{ds1, ds2}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + } + + // Query only "me" - should use only DS1 + var report operationreport.Report + p := testLogic(t, splitSchema, `query Q { me { id username } }`, "Q", config, &report) + require.False(t, report.HasErrors()) + + syncPlan, ok := p.(*SynchronousResponsePlan) + require.True(t, ok) + assert.Equal(t, 1, len(syncPlan.Response.RawFetches), "query using one split DS should produce single fetch") + }) + + t.Run("NewPlanner auto-splits DS with RootFieldCaching into separate fetches", func(t *testing.T) { + // Single DS with two cached root fields — NewPlanner should auto-split + // into two datasources, producing two separate fetches + ds := dsb(). + Id("accounts"). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(splitSchema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * 1e9}, + } + }). + DS() + + config := Configuration{ + DataSources: []DataSource{ds}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + } + + var report operationreport.Report + p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) + require.False(t, report.HasErrors()) + + syncPlan, ok := p.(*SynchronousResponsePlan) + require.True(t, ok) + assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "auto-split DS should produce two separate fetches") + }) + }) } var expectedMyHeroPlan = &SynchronousResponsePlan{ From 30848cc8f66e4aa610ce53d57cd02e9473f61528 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 21:44:20 +0100 Subject: [PATCH 03/11] fix: resolve lint issues (gci formatting, staticcheck) Co-Authored-By: Claude Opus 4.6 --- execution/engine/config_factory_federation_test.go | 1 - execution/engine/federation_caching_test.go | 6 +++--- v2/pkg/engine/plan/datasource_split.go | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/execution/engine/config_factory_federation_test.go b/execution/engine/config_factory_federation_test.go index 1508d13096..3b06b1e986 100644 --- a/execution/engine/config_factory_federation_test.go +++ b/execution/engine/config_factory_federation_test.go @@ -453,4 +453,3 @@ type Review { product: Product! }` ) - diff --git a/execution/engine/federation_caching_test.go b/execution/engine/federation_caching_test.go index a6b14c95db..4c11142f12 100644 --- a/execution/engine/federation_caching_test.go +++ b/execution/engine/federation_caching_test.go @@ -7479,9 +7479,9 @@ func TestRootFieldSplitByDatasource(t *testing.T) { logAfterSecond := defaultCache.GetLog() wantLog := []CacheLogEntry{ - {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, // Invalidated - {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, // Re-cached - {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{true}}, // Still cached + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, // Invalidated + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, // Re-cached + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{true}}, // Still cached } assert.Equal(t, sortCacheLogEntries(wantLog), sortCacheLogEntries(logAfterSecond)) diff --git a/v2/pkg/engine/plan/datasource_split.go b/v2/pkg/engine/plan/datasource_split.go index 222028ea76..e5fa761afc 100644 --- a/v2/pkg/engine/plan/datasource_split.go +++ b/v2/pkg/engine/plan/datasource_split.go @@ -58,7 +58,7 @@ func splitSingleDataSourceByRootFieldCaching(ds DataSource) ([]DataSource, error // Find the Query root node — we only split Query fields, not Mutation/Subscription rootNodes := nodesAccess.ListRootNodes() - var queryNodeIdx int = -1 + queryNodeIdx := -1 for i, node := range rootNodes { if node.TypeName == "Query" { queryNodeIdx = i From 21d8f74acb83842aff164fdfe5b6682deca6d9ff Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 22:16:30 +0100 Subject: [PATCH 04/11] fix: remove root field caching from field info test The auto-split feature causes plan restructuring when RootFieldCaching is configured. This test verifies FieldInfo correctness, not caching behavior, so remove the caching config to keep it focused. Co-Authored-By: Claude Opus 4.6 --- .../graphql_datasource/graphql_datasource_test.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go index 98d1f13708..f0c4648e82 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -395,11 +395,6 @@ func TestGraphQLDataSource(t *testing.T) { ), PostProcessing: DefaultPostProcessingConfiguration, Caching: resolve.FetchCacheConfiguration{ - Enabled: true, - CacheName: "default", - TTL: 30 * time.Second, - IncludeSubgraphHeaderPrefix: true, - // UseL1Cache defaults to false - root query fetches with RootQueryCacheKeyTemplate don't populate entity L1 cache CacheKeyTemplate: &resolve.RootQueryCacheKeyTemplate{ RootFields: []resolve.QueryField{ { @@ -791,15 +786,7 @@ func TestGraphQLDataSource(t *testing.T) { FieldNames: []string{"name", "primaryFunction", "friends"}, }, }, - FederationMetaData: plan.FederationMetaData{ - RootFieldCaching: plan.RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "droid", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - {TypeName: "Query", FieldName: "hero", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - {TypeName: "Query", FieldName: "stringList", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - {TypeName: "Query", FieldName: "nestedStringList", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - }, }, - }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ URL: "https://swapi.com/graphql", From 36f278a8fa6dcc4e4ea0952261d2e1c4655e97a7 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 23:01:08 +0100 Subject: [PATCH 05/11] refactor(plan): replace datasource pre-split with planner-level root field isolation Instead of cloning datasources before planning (SplitDataSourcesByRootFieldCaching), cached root fields now skip planWithExistingPlanners and get their own planner directly, following the same pattern used for mutation fields. An isolatedRootField flag prevents other query root fields from merging into these planners. This eliminates datasource duplication overhead, preserves original datasource IDs, and keeps the change scoped to fields that actually have caching configured. Co-Authored-By: Claude Opus 4.6 --- .../engine/plan/datasource_configuration.go | 6 - v2/pkg/engine/plan/datasource_split.go | 176 -------- v2/pkg/engine/plan/datasource_split_test.go | 403 ------------------ v2/pkg/engine/plan/path_builder_visitor.go | 33 +- v2/pkg/engine/plan/planner.go | 8 - v2/pkg/engine/plan/planner_test.go | 230 +++------- 6 files changed, 103 insertions(+), 753 deletions(-) delete mode 100644 v2/pkg/engine/plan/datasource_split.go delete mode 100644 v2/pkg/engine/plan/datasource_split_test.go diff --git a/v2/pkg/engine/plan/datasource_configuration.go b/v2/pkg/engine/plan/datasource_configuration.go index ee5d82e6a4..e51ea16fbf 100644 --- a/v2/pkg/engine/plan/datasource_configuration.go +++ b/v2/pkg/engine/plan/datasource_configuration.go @@ -299,12 +299,6 @@ func (d *dataSourceConfiguration[T]) CustomConfiguration() T { return d.custom } -// cloneForSplit creates a new dataSourceConfiguration with the same factory and custom config -// but with a new ID and metadata. Used internally by splitSingleDataSourceByRootFieldCaching. -func (d *dataSourceConfiguration[T]) cloneForSplit(newID string, metadata *DataSourceMetadata) (DataSource, error) { - return NewDataSourceConfigurationWithName[T](newID, d.name, d.factory, metadata, d.custom) -} - func (d *dataSourceConfiguration[T]) CreatePlannerConfiguration(logger abstractlogger.Logger, fetchConfig *objectFetchConfiguration, pathConfig *plannerPathsConfiguration, configuration *Configuration) PlannerConfiguration { if configuration.RelaxSubgraphOperationFieldSelectionMergingNullability { if relaxer, ok := d.factory.(SubgraphFieldSelectionMergingNullabilityRelaxer); ok { diff --git a/v2/pkg/engine/plan/datasource_split.go b/v2/pkg/engine/plan/datasource_split.go deleted file mode 100644 index e5fa761afc..0000000000 --- a/v2/pkg/engine/plan/datasource_split.go +++ /dev/null @@ -1,176 +0,0 @@ -package plan - -import "fmt" - -// dataSourceSplitter is implemented by dataSourceConfiguration[T] to enable -// cloning a datasource with new ID and metadata during root field splitting. -type dataSourceSplitter interface { - cloneForSplit(newID string, metadata *DataSourceMetadata) (DataSource, error) -} - -// SplitDataSourcesByRootFieldCaching splits datasources that have root field caching -// configured into separate per-field datasources. This ensures each cacheable root field -// gets its own fetch, enabling independent L2 caching per field. -// -// Why split? The planner merges root fields from the same datasource into a single fetch. -// This means a query like { me { id } cat { name } } produces one request to the subgraph. -// However, configureFetchCaching requires all root fields in a fetch to have identical -// cache configs. By splitting each cached root field into its own datasource, the planner -// creates separate fetches, and each fetch can have its own TTL and cache key. -// -// The split produces up to N+1 datasources from the original: -// - One datasource per cached root field (each with its own RootFieldCaching entry) -// - One remainder datasource for all uncached root fields (no RootFieldCaching) -// -// All split datasources share the same non-Query root nodes (entity types, Mutation, -// Subscription), child nodes, entity caching config, and federation metadata (keys, -// requires, provides). This preserves entity resolution capability across all splits. -func SplitDataSourcesByRootFieldCaching(dataSources []DataSource) ([]DataSource, error) { - var result []DataSource - for _, ds := range dataSources { - split, err := splitSingleDataSourceByRootFieldCaching(ds) - if err != nil { - return nil, fmt.Errorf("failed to split data source %s by root field caching: %w", ds.Id(), err) - } - result = append(result, split...) - } - return result, nil -} - -func splitSingleDataSourceByRootFieldCaching(ds DataSource) ([]DataSource, error) { - fedConfig := ds.FederationConfiguration() - - // No root field caching configured — nothing to split - if len(fedConfig.RootFieldCaching) == 0 { - return []DataSource{ds}, nil - } - - // Check if the datasource supports cloning (all dataSourceConfiguration[T] do) - splitter, ok := ds.(dataSourceSplitter) - if !ok { - return []DataSource{ds}, nil - } - - nodesAccess, ok := ds.(NodesAccess) - if !ok { - return []DataSource{ds}, nil - } - - // Find the Query root node — we only split Query fields, not Mutation/Subscription - rootNodes := nodesAccess.ListRootNodes() - queryNodeIdx := -1 - for i, node := range rootNodes { - if node.TypeName == "Query" { - queryNodeIdx = i - break - } - } - if queryNodeIdx == -1 { - // No Query root node — nothing to split (entity-only datasource) - return []DataSource{ds}, nil - } - - // Partition Query fields into cached and uncached buckets - queryNode := rootNodes[queryNodeIdx] - var cachedFields, uncachedFields []string - for _, fieldName := range queryNode.FieldNames { - if fedConfig.RootFieldCaching.FindByTypeAndField("Query", fieldName) != nil { - cachedFields = append(cachedFields, fieldName) - } else { - uncachedFields = append(uncachedFields, fieldName) - } - } - - // Skip splitting when there's only a single cached field and no uncached fields. - // A single-field datasource already gets its own fetch — splitting adds no benefit. - if len(cachedFields) <= 1 && len(uncachedFields) == 0 { - return []DataSource{ds}, nil - } - - childNodes := nodesAccess.ListChildNodes() - - // Collect non-Query root nodes (e.g. User entity, Mutation) — these are shared - // across all split datasources so entity resolution continues to work - var nonQueryRootNodes TypeFields - for _, node := range rootNodes { - if node.TypeName != "Query" { - nonQueryRootNodes = append(nonQueryRootNodes, node) - } - } - - var result []DataSource - - // Create one datasource per cached Query root field. - // Each gets a unique ID (original_rf_fieldName) and only its own cache config. - for _, fieldName := range cachedFields { - // Build root nodes: single Query field + all non-Query root nodes - splitRootNodes := make(TypeFields, 0, len(nonQueryRootNodes)+1) - splitRootNodes = append(splitRootNodes, TypeField{ - TypeName: "Query", - FieldNames: []string{fieldName}, - ExternalFieldNames: queryNode.ExternalFieldNames, - FetchReasonFields: queryNode.FetchReasonFields, - }) - splitRootNodes = append(splitRootNodes, nonQueryRootNodes...) - - // Attach only this field's cache config to the new datasource - cacheConfig := fedConfig.RootFieldCaching.FindByTypeAndField("Query", fieldName) - metadata := cloneMetadataForSplit(ds, splitRootNodes, childNodes) - metadata.FederationMetaData.RootFieldCaching = RootFieldCacheConfigurations{*cacheConfig} - - splitID := fmt.Sprintf("%s_rf_%s", ds.Id(), fieldName) - splitDS, err := splitter.cloneForSplit(splitID, metadata) - if err != nil { - return nil, err - } - result = append(result, splitDS) - } - - // Create a remainder datasource for uncached fields (if any). - // This keeps the original datasource ID so existing planner behavior is preserved. - if len(uncachedFields) > 0 { - remainderRootNodes := make(TypeFields, 0, len(nonQueryRootNodes)+1) - remainderRootNodes = append(remainderRootNodes, TypeField{ - TypeName: "Query", - FieldNames: uncachedFields, - ExternalFieldNames: queryNode.ExternalFieldNames, - FetchReasonFields: queryNode.FetchReasonFields, - }) - remainderRootNodes = append(remainderRootNodes, nonQueryRootNodes...) - - metadata := cloneMetadataForSplit(ds, remainderRootNodes, childNodes) - // Explicitly clear root field caching — uncached fields should not inherit cache config - metadata.FederationMetaData.RootFieldCaching = nil - - remainderDS, err := splitter.cloneForSplit(ds.Id(), metadata) - if err != nil { - return nil, err - } - result = append(result, remainderDS) - } - - return result, nil -} - -// cloneMetadataForSplit creates new DataSourceMetadata with the given root nodes -// while preserving all federation metadata, child nodes, and directives from the original. -func cloneMetadataForSplit(original DataSource, rootNodes, childNodes TypeFields) *DataSourceMetadata { - origFed := original.FederationConfiguration() - origDirectives := original.DirectiveConfigurations() - - return &DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - Directives: origDirectives, - FederationMetaData: FederationMetaData{ - Keys: origFed.Keys, - Requires: origFed.Requires, - Provides: origFed.Provides, - EntityInterfaces: origFed.EntityInterfaces, - InterfaceObjects: origFed.InterfaceObjects, - EntityCaching: origFed.EntityCaching, - RootFieldCaching: origFed.RootFieldCaching, - SubscriptionEntityPopulation: origFed.SubscriptionEntityPopulation, - }, - } -} diff --git a/v2/pkg/engine/plan/datasource_split_test.go b/v2/pkg/engine/plan/datasource_split_test.go deleted file mode 100644 index 25120f0916..0000000000 --- a/v2/pkg/engine/plan/datasource_split_test.go +++ /dev/null @@ -1,403 +0,0 @@ -package plan - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// splitDSSnapshot captures the essential state of a split datasource for full assertion. -// Uses plain types instead of plan types to avoid unexported field comparison issues -// (e.g. FederationFieldConfiguration.parsedSelectionSet). -type splitDSSnapshot struct { - ID string - Name string - RootNodes []snapshotTypeField - ChildNodes []snapshotTypeField - Keys []snapshotKey - EntityCaching []snapshotEntityCache - RootFieldCaching []snapshotRootFieldCache -} - -type snapshotTypeField struct { - TypeName string - FieldNames []string -} - -type snapshotKey struct { - TypeName string - SelectionSet string -} - -type snapshotEntityCache struct { - TypeName string - CacheName string - TTL time.Duration -} - -type snapshotRootFieldCache struct { - TypeName string - FieldName string - CacheName string - TTL time.Duration -} - -// snapshotDS extracts a splitDSSnapshot from a datasource for comparison. -func snapshotDS(t *testing.T, ds DataSource) splitDSSnapshot { - t.Helper() - na, ok := ds.(NodesAccess) - require.True(t, ok) - fed := ds.FederationConfiguration() - - var rootNodes []snapshotTypeField - for _, n := range na.ListRootNodes() { - rootNodes = append(rootNodes, snapshotTypeField{TypeName: n.TypeName, FieldNames: n.FieldNames}) - } - var childNodes []snapshotTypeField - for _, n := range na.ListChildNodes() { - childNodes = append(childNodes, snapshotTypeField{TypeName: n.TypeName, FieldNames: n.FieldNames}) - } - var keys []snapshotKey - for _, k := range fed.Keys { - keys = append(keys, snapshotKey{TypeName: k.TypeName, SelectionSet: k.SelectionSet}) - } - var entityCaching []snapshotEntityCache - for _, e := range fed.EntityCaching { - entityCaching = append(entityCaching, snapshotEntityCache{TypeName: e.TypeName, CacheName: e.CacheName, TTL: e.TTL}) - } - var rootFieldCaching []snapshotRootFieldCache - for _, r := range fed.RootFieldCaching { - rootFieldCaching = append(rootFieldCaching, snapshotRootFieldCache{TypeName: r.TypeName, FieldName: r.FieldName, CacheName: r.CacheName, TTL: r.TTL}) - } - - return splitDSSnapshot{ - ID: ds.Id(), - Name: ds.Name(), - RootNodes: rootNodes, - ChildNodes: childNodes, - Keys: keys, - EntityCaching: entityCaching, - RootFieldCaching: rootFieldCaching, - } -} - -// snapshotAll extracts snapshots from all datasources, keyed by ID. -func snapshotAll(t *testing.T, dss []DataSource) map[string]splitDSSnapshot { - t.Helper() - result := make(map[string]splitDSSnapshot, len(dss)) - for _, ds := range dss { - result[ds.Id()] = snapshotDS(t, ds) - } - return result -} - -// splitTestDS creates a datasource for split testing using the dsBuilder. -// Schema is required so that cloneForSplit has a valid factory. -func splitTestDS(id, name string) *dsBuilder { - return dsb().Id(id).Name(name).Schema("type Query { placeholder: String }") -} - -func TestSplitDataSourcesByRootFieldCaching(t *testing.T) { - t.Run("no RootFieldCaching - no split", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat"). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 1, len(result)) - assert.Equal(t, "0", result[0].Id(), "original DS returned unchanged") - }) - - t.Run("two cached root fields - 2 separate datasources", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat"). - RootNode("User", "id", "username"). - ChildNode("Cat", "name"). - KeysMetadata(FederationFieldConfigurations{ - {TypeName: "User", SelectionSet: "id"}, - }). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 2, len(result)) - - snapshots := snapshotAll(t, result) - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_me", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"me"}}, - {TypeName: "User", FieldNames: []string{"id", "username"}}, - }, - ChildNodes: []snapshotTypeField{ - {TypeName: "Cat", FieldNames: []string{"name"}}, - }, - Keys: []snapshotKey{ - {TypeName: "User", SelectionSet: "id"}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - }, - }, snapshots["0_rf_me"]) - - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_cat", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"cat"}}, - {TypeName: "User", FieldNames: []string{"id", "username"}}, - }, - ChildNodes: []snapshotTypeField{ - {TypeName: "Cat", FieldNames: []string{"name"}}, - }, - Keys: []snapshotKey{ - {TypeName: "User", SelectionSet: "id"}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, - }, - }, snapshots["0_rf_cat"]) - }) - - t.Run("3 root fields, 2 cached, 1 uncached - 3 datasources", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat", "user"). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 3, len(result)) - - snapshots := snapshotAll(t, result) - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_me", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"me"}}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, - }, - }, snapshots["0_rf_me"]) - - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_cat", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"cat"}}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, - }, - }, snapshots["0_rf_cat"]) - - assert.Equal(t, splitDSSnapshot{ - ID: "0", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"user"}}, - }, - }, snapshots["0"]) - }) - - t.Run("entity-only caching, no root field caching - no split", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat"). - RootNode("User", "id", "username"). - KeysMetadata(FederationFieldConfigurations{ - {TypeName: "User", SelectionSet: "id"}, - }). - WithMetadata(func(data *FederationMetaData) { - data.EntityCaching = EntityCacheConfigurations{ - {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 1, len(result)) - assert.Equal(t, "0", result[0].Id(), "original DS returned unchanged") - }) - - t.Run("single cached root field, no uncached - no split", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me"). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 1, len(result)) - assert.Equal(t, "0", result[0].Id(), "original DS returned unchanged") - }) - - t.Run("single cached + uncached fields - 2 datasources", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat"). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 2, len(result)) - - snapshots := snapshotAll(t, result) - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_me", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"me"}}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - }, - }, snapshots["0_rf_me"]) - - assert.Equal(t, splitDSSnapshot{ - ID: "0", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"cat"}}, - }, - }, snapshots["0"]) - }) - - t.Run("entity caching preserved on all split datasources", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat"). - RootNode("User", "id", "username"). - KeysMetadata(FederationFieldConfigurations{ - {TypeName: "User", SelectionSet: "id"}, - }). - WithMetadata(func(data *FederationMetaData) { - data.EntityCaching = EntityCacheConfigurations{ - {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, - } - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 2, len(result)) - - snapshots := snapshotAll(t, result) - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_me", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"me"}}, - {TypeName: "User", FieldNames: []string{"id", "username"}}, - }, - Keys: []snapshotKey{ - {TypeName: "User", SelectionSet: "id"}, - }, - EntityCaching: []snapshotEntityCache{ - {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, - }, - }, snapshots["0_rf_me"]) - - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_cat", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"cat"}}, - {TypeName: "User", FieldNames: []string{"id", "username"}}, - }, - Keys: []snapshotKey{ - {TypeName: "User", SelectionSet: "id"}, - }, - EntityCaching: []snapshotEntityCache{ - {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, - }, - }, snapshots["0_rf_cat"]) - }) - - t.Run("non-Query root nodes preserved on all split datasources", func(t *testing.T) { - ds := splitTestDS("0", "accounts"). - RootNode("Query", "me", "cat"). - RootNode("User", "id", "username"). - RootNode("Mutation", "updateUser"). - KeysMetadata(FederationFieldConfigurations{ - {TypeName: "User", SelectionSet: "id"}, - }). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, - } - }). - DS() - - result, err := SplitDataSourcesByRootFieldCaching([]DataSource{ds}) - require.NoError(t, err) - require.Equal(t, 2, len(result)) - - snapshots := snapshotAll(t, result) - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_me", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"me"}}, - {TypeName: "User", FieldNames: []string{"id", "username"}}, - {TypeName: "Mutation", FieldNames: []string{"updateUser"}}, - }, - Keys: []snapshotKey{ - {TypeName: "User", SelectionSet: "id"}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, - }, - }, snapshots["0_rf_me"]) - - assert.Equal(t, splitDSSnapshot{ - ID: "0_rf_cat", - Name: "accounts", - RootNodes: []snapshotTypeField{ - {TypeName: "Query", FieldNames: []string{"cat"}}, - {TypeName: "User", FieldNames: []string{"id", "username"}}, - {TypeName: "Mutation", FieldNames: []string{"updateUser"}}, - }, - Keys: []snapshotKey{ - {TypeName: "User", SelectionSet: "id"}, - }, - RootFieldCaching: []snapshotRootFieldCache{ - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, - }, - }, snapshots["0_rf_cat"]) - }) -} diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index b66b41375a..7b6a690c89 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -113,6 +113,7 @@ type objectFetchConfiguration struct { filter *resolve.SubscriptionFilter planner DataSourceFetchPlanner isSubscription bool + isolatedRootField bool fieldRef int fieldDefinitionRef int sourceID string @@ -560,14 +561,18 @@ func (c *pathBuilderVisitor) handlePlanningField(fieldRef int, typeName, fieldNa } isMutationRoot := c.isMutationRoot(currentPath) + isCachedQueryRoot := c.isCachedQueryRootField(currentPath, typeName, fieldName, ds) var ( plannerIdx int planned bool ) - if isMutationRoot { + if isMutationRoot || isCachedQueryRoot { plannerIdx, planned = c.addNewPlanner(fieldRef, typeName, fieldName, currentPath, parentPath, isMutationRoot, ds) + if planned && isCachedQueryRoot { + c.planners[plannerIdx].ObjectFetchConfiguration().isolatedRootField = true + } } else { plannerIdx, planned = c.planWithExistingPlanners(fieldRef, typeName, fieldName, currentPath, parentPath, precedingParentPath, suggestion) if !planned { @@ -766,6 +771,13 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fi isRootNode := suggestion.IsRootNode isChildNode := !isRootNode + // Don't merge other query root fields into isolated planners (cached root fields). + // Only block at the operation root level (parentPath == "query") — + // nested fields (including entity root nodes like Product.name) must still merge. + if c.isParentPathIsRootOperationPath(parentPath) && plannerConfig.ObjectFetchConfiguration().isolatedRootField { + continue + } + if c.secondaryRun && plannerConfig.HasPath(currentPath) { // on the secondary run we need to process only new fields added by the first run return plannerIdx, true @@ -1305,6 +1317,25 @@ func (c *pathBuilderVisitor) isMutationRoot(path string) bool { return strings.Count(path, ".") == 1 } +// isCachedQueryRootField returns true when the field is a direct child of Query +// and has root field caching configured on the datasource. Such fields must be +// isolated into their own planner to get independent cache configs per fetch. +func (c *pathBuilderVisitor) isCachedQueryRootField(currentPath, typeName, fieldName string, ds DataSource) bool { + if c.plannerConfiguration.DisableEntityCaching { + return false + } + root := c.walker.Ancestors[0] + rootOperationType := c.operation.OperationDefinitions[root.Ref].OperationType + if rootOperationType != ast.OperationTypeQuery { + return false + } + if strings.Count(currentPath, ".") != 1 { + return false + } + fedConfig := ds.FederationConfiguration() + return fedConfig.RootFieldCacheConfig(typeName, fieldName) != nil +} + func (c *pathBuilderVisitor) isNotOperationDefinitionRoot() bool { // potentially this check is not needed, because we should not have root fragments definitions // at this stage of planning diff --git a/v2/pkg/engine/plan/planner.go b/v2/pkg/engine/plan/planner.go index ff4244688a..f96cc735be 100644 --- a/v2/pkg/engine/plan/planner.go +++ b/v2/pkg/engine/plan/planner.go @@ -41,14 +41,6 @@ func NewPlanner(config Configuration) (*Planner, error) { config.Logger = abstractlogger.Noop{} } - // Auto-split datasources that have root field caching configured. - // This must happen before the duplicate ID check because splitting creates new IDs. - var err error - config.DataSources, err = SplitDataSourcesByRootFieldCaching(config.DataSources) - if err != nil { - return nil, err - } - entityInterfaceNames := make([]string, 0, 1) dsIDs := make(map[string]struct{}, len(config.DataSources)) for _, ds := range config.DataSources { diff --git a/v2/pkg/engine/plan/planner_test.go b/v2/pkg/engine/plan/planner_test.go index 2fd821f666..3ee29908d8 100644 --- a/v2/pkg/engine/plan/planner_test.go +++ b/v2/pkg/engine/plan/planner_test.go @@ -816,13 +816,14 @@ func TestPlanner_Plan(t *testing.T) { assert.Equal(t, plan2Expected, plan2) }) - // Root field datasource splitting tests - // These tests prove that when datasources are pre-split (as splitDataSourceByRootFieldCaching - // would produce), the planner generates valid plans with separate fetches per datasource. - // Note: FakeFactory creates one fetch per root field regardless of DS grouping, - // so these tests focus on verifying the planner handles split configurations correctly. - t.Run("root field splitting", func(t *testing.T) { - const splitSchema = ` + // Root field caching isolation tests + // When a root field has caching configured, the planner must isolate it into its own + // planner/fetch so it gets an independent cache config (TTL, cache name, etc.). + // This uses the same pattern as mutations: cached root fields skip planWithExistingPlanners + // and go straight to addNewPlanner. Other fields are prevented from merging into + // isolated planners via the isolatedRootField flag. + t.Run("root field caching isolation", func(t *testing.T) { + const schema = ` type Query { me: User cat: Cat @@ -837,226 +838,137 @@ func TestPlanner_Plan(t *testing.T) { } ` - t.Run("two split DSs plan correctly", func(t *testing.T) { - ds1 := dsb(). - Id("accounts_rf_me"). - RootNode("Query", "me"). - ChildNode("User", "id", "username"). - Schema(splitSchema). - Hash(1). - DS() - - ds2 := dsb(). - Id("accounts_rf_cat"). - RootNode("Query", "cat"). - ChildNode("Cat", "name"). - Schema(splitSchema). - Hash(2). - DS() - - config := Configuration{ - DataSources: []DataSource{ds1, ds2}, - DisableResolveFieldPositions: true, - DisableIncludeInfo: true, - DisableEntityCaching: true, - } - - var report operationreport.Report - p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) - require.False(t, report.HasErrors()) - - syncPlan, ok := p.(*SynchronousResponsePlan) - require.True(t, ok) - assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "two split DSs should produce two fetches") - }) - - t.Run("mixed split - cached field separate, uncached stays on original", func(t *testing.T) { - // DS1: only Query.me (simulating split cached field) - ds1 := dsb(). - Id("accounts_rf_me"). - RootNode("Query", "me"). - ChildNode("User", "id", "username"). - Schema(splitSchema). - Hash(1). - DS() - - // DS2: Query.cat + Query.user (simulating remainder with uncached fields) - ds2 := dsb(). + t.Run("two cached root fields on same DS get separate fetches", func(t *testing.T) { + // With MergeAliasedRootNodes: true, root fields would normally merge. + // Root field caching must prevent this. + ds := dsb(). Id("accounts"). - RootNode("Query", "cat", "user"). - ChildNode("User", "id", "username"). - ChildNode("Cat", "name"). - Schema(splitSchema). - Hash(2). - DS() - - config := Configuration{ - DataSources: []DataSource{ds1, ds2}, - DisableResolveFieldPositions: true, - DisableIncludeInfo: true, - DisableEntityCaching: true, - } - - var report operationreport.Report - p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) - require.False(t, report.HasErrors()) - - syncPlan, ok := p.(*SynchronousResponsePlan) - require.True(t, ok) - assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "mixed split should produce two fetches") - }) - - t.Run("three separate DSs produce three fetches", func(t *testing.T) { - ds1 := dsb(). - Id("accounts_rf_me"). - RootNode("Query", "me"). + WithBehavior(DataSourcePlanningBehavior{ + MergeAliasedRootNodes: true, + }). + RootNode("Query", "me", "cat"). ChildNode("User", "id", "username"). - Schema(splitSchema). - Hash(1). - DS() - - ds2 := dsb(). - Id("accounts_rf_cat"). - RootNode("Query", "cat"). ChildNode("Cat", "name"). - Schema(splitSchema). - Hash(2). - DS() - - ds3 := dsb(). - Id("accounts_rf_user"). - RootNode("Query", "user"). - ChildNode("User", "id", "username"). - Schema(splitSchema). - Hash(3). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * 1e9}, + } + }). DS() config := Configuration{ - DataSources: []DataSource{ds1, ds2, ds3}, + DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, DisableIncludeInfo: true, - DisableEntityCaching: true, } var report operationreport.Report - p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } user(id: "1") { username } }`, "Q", config, &report) + p := testLogic(t, schema, `query Q { me { id username } cat { name } }`, "Q", config, &report) require.False(t, report.HasErrors()) syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - assert.Equal(t, 3, len(syncPlan.Response.RawFetches), "three separate DSs should produce three fetches") + assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "cached root fields should get separate fetches") }) - t.Run("split DSs with entity root nodes plan correctly", func(t *testing.T) { - // Both split DSs share entity root nodes (User) for entity resolution from other subgraphs. - // This verifies the planner handles split DSs with shared entity root nodes without errors. - ds1 := dsb(). - Id("accounts_rf_me"). - RootNode("Query", "me"). - RootNode("User", "id", "username"). - ChildNode("User", "id", "username"). - Schema(splitSchema). - Hash(11). - KeysMetadata(FederationFieldConfigurations{ - {TypeName: "User", SelectionSet: "id"}, + t.Run("cached field isolated from uncached field on same DS", func(t *testing.T) { + // me is cached, user is not — they must not share a fetch + ds := dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{ + MergeAliasedRootNodes: true, }). - DS() - - ds2 := dsb(). - Id("accounts_rf_cat"). - RootNode("Query", "cat"). - RootNode("User", "id", "username"). - ChildNode("Cat", "name"). - Schema(splitSchema). - Hash(12). - KeysMetadata(FederationFieldConfigurations{ - {TypeName: "User", SelectionSet: "id"}, + RootNode("Query", "me", "user"). + ChildNode("User", "id", "username"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, + } }). DS() config := Configuration{ - DataSources: []DataSource{ds1, ds2}, + DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, DisableIncludeInfo: true, - DisableEntityCaching: true, } var report operationreport.Report - p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) + p := testLogic(t, schema, `query Q { me { id } user(id: "1") { username } }`, "Q", config, &report) require.False(t, report.HasErrors()) syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - // 4 fetches: me root from DS1, cat root from DS2, - // plus User entity fetches from both DS1 and DS2 (both have User root nodes) - assert.Equal(t, 4, len(syncPlan.Response.RawFetches), "split DSs with entity root nodes should produce four fetches") + assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "cached field should be isolated from uncached field") }) - t.Run("query selecting from only one split DS produces single fetch", func(t *testing.T) { - ds1 := dsb(). - Id("accounts_rf_me"). - RootNode("Query", "me"). + t.Run("DisableEntityCaching disables isolation", func(t *testing.T) { + // When DisableEntityCaching is true, cached root fields should NOT be isolated — + // they merge normally (default FakePlanner has MergeAliasedRootNodes: false, + // so each root field still gets its own planner for unrelated reasons) + ds := dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{ + MergeAliasedRootNodes: true, + }). + RootNode("Query", "me", "cat"). ChildNode("User", "id", "username"). - Schema(splitSchema). - Hash(1). - DS() - - ds2 := dsb(). - Id("accounts_rf_cat"). - RootNode("Query", "cat"). ChildNode("Cat", "name"). - Schema(splitSchema). - Hash(2). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * 1e9}, + } + }). DS() config := Configuration{ - DataSources: []DataSource{ds1, ds2}, + DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, DisableIncludeInfo: true, DisableEntityCaching: true, } - // Query only "me" - should use only DS1 var report operationreport.Report - p := testLogic(t, splitSchema, `query Q { me { id username } }`, "Q", config, &report) + p := testLogic(t, schema, `query Q { me { id username } cat { name } }`, "Q", config, &report) require.False(t, report.HasErrors()) syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - assert.Equal(t, 1, len(syncPlan.Response.RawFetches), "query using one split DS should produce single fetch") + // With DisableEntityCaching + MergeAliasedRootNodes: true, fields merge into one fetch + assert.Equal(t, 1, len(syncPlan.Response.RawFetches), "DisableEntityCaching should allow fields to merge") }) - t.Run("NewPlanner auto-splits DS with RootFieldCaching into separate fetches", func(t *testing.T) { - // Single DS with two cached root fields — NewPlanner should auto-split - // into two datasources, producing two separate fetches + t.Run("no caching configured - fields merge normally", func(t *testing.T) { + // No RootFieldCaching → fields merge if MergeAliasedRootNodes is true ds := dsb(). Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{ + MergeAliasedRootNodes: true, + }). RootNode("Query", "me", "cat"). ChildNode("User", "id", "username"). ChildNode("Cat", "name"). - Schema(splitSchema). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * 1e9}, - } - }). + Schema(schema). DS() config := Configuration{ DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, DisableIncludeInfo: true, - DisableEntityCaching: true, } var report operationreport.Report - p := testLogic(t, splitSchema, `query Q { me { id username } cat { name } }`, "Q", config, &report) + p := testLogic(t, schema, `query Q { me { id username } cat { name } }`, "Q", config, &report) require.False(t, report.HasErrors()) syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "auto-split DS should produce two separate fetches") + assert.Equal(t, 1, len(syncPlan.Response.RawFetches), "without caching, fields should merge into one fetch") }) }) } From fd150051d68eabeeae93446d0f96c16edf77dbeb Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 23:08:18 +0100 Subject: [PATCH 06/11] docs: add inline comments to root field isolation logic Co-Authored-By: Claude Opus 4.6 --- v2/pkg/engine/plan/path_builder_visitor.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 7b6a690c89..4e7bb3a9e8 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -569,8 +569,14 @@ func (c *pathBuilderVisitor) handlePlanningField(fieldRef int, typeName, fieldNa ) if isMutationRoot || isCachedQueryRoot { + // Mutations always need separate planners for sequential execution. + // Cached query root fields need separate planners so each fetch gets + // its own cache configuration (TTL, cache name). Without isolation, + // configureFetchCaching sees mixed root fields and disables L2 caching. plannerIdx, planned = c.addNewPlanner(fieldRef, typeName, fieldName, currentPath, parentPath, isMutationRoot, ds) if planned && isCachedQueryRoot { + // Mark this planner as isolated so planWithExistingPlanners won't + // merge other root fields into it (see guard in that function). c.planners[plannerIdx].ObjectFetchConfiguration().isolatedRootField = true } } else { @@ -772,8 +778,11 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fi isChildNode := !isRootNode // Don't merge other query root fields into isolated planners (cached root fields). - // Only block at the operation root level (parentPath == "query") — - // nested fields (including entity root nodes like Product.name) must still merge. + // We check parentPath (not isRootNode) because entity types like Product are + // also datasource root nodes — isRootNode would incorrectly block nested entity + // fields from merging into the planner that needs them. + // isParentPathIsRootOperationPath checks if parentPath is "query"/"mutation"/"subscription", + // ensuring only top-level query fields are prevented from merging. if c.isParentPathIsRootOperationPath(parentPath) && plannerConfig.ObjectFetchConfiguration().isolatedRootField { continue } @@ -1320,18 +1329,27 @@ func (c *pathBuilderVisitor) isMutationRoot(path string) bool { // isCachedQueryRootField returns true when the field is a direct child of Query // and has root field caching configured on the datasource. Such fields must be // isolated into their own planner to get independent cache configs per fetch. +// +// This mirrors the mutation pattern (isMutationRoot) but only applies to query +// fields with explicit RootFieldCacheConfiguration. Without isolation, multiple +// root fields from the same datasource merge into one planner/fetch, and +// configureFetchCaching sees mixed cache configs and disables L2 caching. func (c *pathBuilderVisitor) isCachedQueryRootField(currentPath, typeName, fieldName string, ds DataSource) bool { + // When entity caching is globally disabled, no isolation needed if c.plannerConfiguration.DisableEntityCaching { return false } + // Only applies to Query operations, not mutations or subscriptions root := c.walker.Ancestors[0] rootOperationType := c.operation.OperationDefinitions[root.Ref].OperationType if rootOperationType != ast.OperationTypeQuery { return false } + // Only direct children of the root (e.g. "query.me" has exactly one dot) if strings.Count(currentPath, ".") != 1 { return false } + // Check if this specific field has a cache config on its datasource fedConfig := ds.FederationConfiguration() return fedConfig.RootFieldCacheConfig(typeName, fieldName) != nil } From 00662a07e913688f9f632e2ece2658df102e0714 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 23:13:26 +0100 Subject: [PATCH 07/11] test(plan): verify cache configs on isolated root field fetches Extend FakePlanner to support CacheKeyTemplate so configureFetchCaching can populate cache config on fetches. Tests now verify not just fetch count but also Enabled, CacheName, TTL, and RootFields per fetch. Co-Authored-By: Claude Opus 4.6 --- .../plan/datasource_filter_visitor_test.go | 21 ++- v2/pkg/engine/plan/planner_test.go | 125 +++++++++++++----- 2 files changed, 110 insertions(+), 36 deletions(-) diff --git a/v2/pkg/engine/plan/datasource_filter_visitor_test.go b/v2/pkg/engine/plan/datasource_filter_visitor_test.go index f833bec969..0b8751f074 100644 --- a/v2/pkg/engine/plan/datasource_filter_visitor_test.go +++ b/v2/pkg/engine/plan/datasource_filter_visitor_test.go @@ -10,14 +10,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" "github.com/wundergraph/graphql-go-tools/v2/pkg/testing/permutations" ) type dsBuilder struct { - ds *dataSourceConfiguration[any] - behavior *DataSourcePlanningBehavior + ds *dataSourceConfiguration[any] + behavior *DataSourcePlanningBehavior + cacheKeyTemplate resolve.CacheKeyTemplate } func dsb() *dsBuilder { @@ -64,11 +66,17 @@ func (b *dsBuilder) WithBehavior(behavior DataSourcePlanningBehavior) *dsBuilder return b } +func (b *dsBuilder) CacheKeyTemplate(t resolve.CacheKeyTemplate) *dsBuilder { + b.cacheKeyTemplate = t + return b +} + func (b *dsBuilder) Schema(schema string) *dsBuilder { def := unsafeparser.ParseGraphqlDocumentString(schema) b.ds.factory = &FakeFactory[any]{ - upstreamSchema: &def, - behavior: b.behavior, + upstreamSchema: &def, + behavior: b.behavior, + cacheKeyTemplate: b.cacheKeyTemplate, } return b } @@ -76,8 +84,9 @@ func (b *dsBuilder) Schema(schema string) *dsBuilder { func (b *dsBuilder) SchemaMergedWithBase(schema string) *dsBuilder { def := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(schema) b.ds.factory = &FakeFactory[any]{ - upstreamSchema: &def, - behavior: b.behavior, + upstreamSchema: &def, + behavior: b.behavior, + cacheKeyTemplate: b.cacheKeyTemplate, } return b } diff --git a/v2/pkg/engine/plan/planner_test.go b/v2/pkg/engine/plan/planner_test.go index 3ee29908d8..d1eb0d362b 100644 --- a/v2/pkg/engine/plan/planner_test.go +++ b/v2/pkg/engine/plan/planner_test.go @@ -837,23 +837,45 @@ func TestPlanner_Plan(t *testing.T) { name: String! } ` + // Minimal CacheKeyTemplate to enable configureFetchCaching to populate cache config. + // Without this, configureFetchCaching bails early (CacheKeyTemplate == nil). + cacheKeyTpl := &resolve.RootQueryCacheKeyTemplate{} - t.Run("two cached root fields on same DS get separate fetches", func(t *testing.T) { - // With MergeAliasedRootNodes: true, root fields would normally merge. - // Root field caching must prevent this. + // fetchByRootField finds the fetch whose FetchInfo.RootFields contains the given field. + // Returns nil if no matching fetch is found. + fetchByRootField := func(t *testing.T, fetches []*resolve.FetchItem, fieldName string) *resolve.SingleFetch { + t.Helper() + for _, fi := range fetches { + sf, ok := fi.Fetch.(*resolve.SingleFetch) + if !ok || sf.Info == nil { + continue + } + for _, rf := range sf.Info.RootFields { + if rf.FieldName == fieldName { + return sf + } + } + } + return nil + } + + t.Run("two cached root fields get separate fetches with correct cache configs", func(t *testing.T) { + // With MergeAliasedRootNodes: true, root fields would normally merge into one fetch. + // Root field caching isolation must prevent this so each field gets its own TTL. ds := dsb(). Id("accounts"). WithBehavior(DataSourcePlanningBehavior{ MergeAliasedRootNodes: true, }). + CacheKeyTemplate(cacheKeyTpl). RootNode("Query", "me", "cat"). ChildNode("User", "id", "username"). ChildNode("Cat", "name"). Schema(schema). WithMetadata(func(data *FederationMetaData) { data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * 1e9}, + {TypeName: "Query", FieldName: "me", CacheName: "users", TTL: 30 * 1e9}, + {TypeName: "Query", FieldName: "cat", CacheName: "pets", TTL: 60 * 1e9}, } }). DS() @@ -861,7 +883,6 @@ func TestPlanner_Plan(t *testing.T) { config := Configuration{ DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, - DisableIncludeInfo: true, } var report operationreport.Report @@ -870,16 +891,34 @@ func TestPlanner_Plan(t *testing.T) { syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "cached root fields should get separate fetches") + require.Equal(t, 2, len(syncPlan.Response.RawFetches), "each cached root field needs its own fetch") + + // Verify "me" fetch has correct cache config + meFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "me") + require.NotNil(t, meFetch, "should have a fetch for 'me'") + assert.Equal(t, true, meFetch.FetchConfiguration.Caching.Enabled, "me fetch should have L2 caching enabled") + assert.Equal(t, "users", meFetch.FetchConfiguration.Caching.CacheName, "me fetch should use 'users' cache") + assert.Equal(t, 30*1e9, float64(meFetch.FetchConfiguration.Caching.TTL), "me fetch should have 30s TTL") + assert.Equal(t, 1, len(meFetch.Info.RootFields), "me fetch should have exactly one root field") + + // Verify "cat" fetch has correct cache config + catFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "cat") + require.NotNil(t, catFetch, "should have a fetch for 'cat'") + assert.Equal(t, true, catFetch.FetchConfiguration.Caching.Enabled, "cat fetch should have L2 caching enabled") + assert.Equal(t, "pets", catFetch.FetchConfiguration.Caching.CacheName, "cat fetch should use 'pets' cache") + assert.Equal(t, 60*1e9, float64(catFetch.FetchConfiguration.Caching.TTL), "cat fetch should have 60s TTL") + assert.Equal(t, 1, len(catFetch.Info.RootFields), "cat fetch should have exactly one root field") }) - t.Run("cached field isolated from uncached field on same DS", func(t *testing.T) { - // me is cached, user is not — they must not share a fetch + t.Run("cached field isolated from uncached field - only cached gets L2", func(t *testing.T) { + // "me" is cached, "user" is not — they must not share a fetch. + // The cached fetch gets Enabled:true, the uncached fetch gets Enabled:false. ds := dsb(). Id("accounts"). WithBehavior(DataSourcePlanningBehavior{ MergeAliasedRootNodes: true, }). + CacheKeyTemplate(cacheKeyTpl). RootNode("Query", "me", "user"). ChildNode("User", "id", "username"). Schema(schema). @@ -893,7 +932,6 @@ func TestPlanner_Plan(t *testing.T) { config := Configuration{ DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, - DisableIncludeInfo: true, } var report operationreport.Report @@ -902,18 +940,30 @@ func TestPlanner_Plan(t *testing.T) { syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - assert.Equal(t, 2, len(syncPlan.Response.RawFetches), "cached field should be isolated from uncached field") + require.Equal(t, 2, len(syncPlan.Response.RawFetches), "cached and uncached fields need separate fetches") + + // Cached field "me" gets L2 caching + meFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "me") + require.NotNil(t, meFetch, "should have a fetch for 'me'") + assert.Equal(t, true, meFetch.FetchConfiguration.Caching.Enabled, "cached field should have L2 enabled") + assert.Equal(t, "default", meFetch.FetchConfiguration.Caching.CacheName) + assert.Equal(t, 30*1e9, float64(meFetch.FetchConfiguration.Caching.TTL)) + + // Uncached field "user" does NOT get L2 caching + userFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "user") + require.NotNil(t, userFetch, "should have a fetch for 'user'") + assert.Equal(t, false, userFetch.FetchConfiguration.Caching.Enabled, "uncached field should not have L2 enabled") }) - t.Run("DisableEntityCaching disables isolation", func(t *testing.T) { - // When DisableEntityCaching is true, cached root fields should NOT be isolated — - // they merge normally (default FakePlanner has MergeAliasedRootNodes: false, - // so each root field still gets its own planner for unrelated reasons) + t.Run("DisableEntityCaching - fields merge and no L2 caching", func(t *testing.T) { + // When DisableEntityCaching is true, isolation is skipped and fields merge normally. + // configureFetchCaching also returns Enabled:false regardless of RootFieldCaching config. ds := dsb(). Id("accounts"). WithBehavior(DataSourcePlanningBehavior{ MergeAliasedRootNodes: true, }). + CacheKeyTemplate(cacheKeyTpl). RootNode("Query", "me", "cat"). ChildNode("User", "id", "username"). ChildNode("Cat", "name"). @@ -929,7 +979,6 @@ func TestPlanner_Plan(t *testing.T) { config := Configuration{ DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, - DisableIncludeInfo: true, DisableEntityCaching: true, } @@ -939,8 +988,12 @@ func TestPlanner_Plan(t *testing.T) { syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - // With DisableEntityCaching + MergeAliasedRootNodes: true, fields merge into one fetch - assert.Equal(t, 1, len(syncPlan.Response.RawFetches), "DisableEntityCaching should allow fields to merge") + // MergeAliasedRootNodes: true + no isolation → fields merge into one fetch + require.Equal(t, 1, len(syncPlan.Response.RawFetches), "DisableEntityCaching should allow fields to merge") + + sf, ok := syncPlan.Response.RawFetches[0].Fetch.(*resolve.SingleFetch) + require.True(t, ok) + assert.Equal(t, false, sf.FetchConfiguration.Caching.Enabled, "DisableEntityCaching disables L2 caching") }) t.Run("no caching configured - fields merge normally", func(t *testing.T) { @@ -950,6 +1003,7 @@ func TestPlanner_Plan(t *testing.T) { WithBehavior(DataSourcePlanningBehavior{ MergeAliasedRootNodes: true, }). + CacheKeyTemplate(cacheKeyTpl). RootNode("Query", "me", "cat"). ChildNode("User", "id", "username"). ChildNode("Cat", "name"). @@ -959,7 +1013,6 @@ func TestPlanner_Plan(t *testing.T) { config := Configuration{ DataSources: []DataSource{ds}, DisableResolveFieldPositions: true, - DisableIncludeInfo: true, } var report operationreport.Report @@ -968,7 +1021,12 @@ func TestPlanner_Plan(t *testing.T) { syncPlan, ok := p.(*SynchronousResponsePlan) require.True(t, ok) - assert.Equal(t, 1, len(syncPlan.Response.RawFetches), "without caching, fields should merge into one fetch") + require.Equal(t, 1, len(syncPlan.Response.RawFetches), "without caching config, fields should merge into one fetch") + + // Even with CacheKeyTemplate, no RootFieldCaching → L2 disabled + sf, ok := syncPlan.Response.RawFetches[0].Fetch.(*resolve.SingleFetch) + require.True(t, ok) + assert.Equal(t, false, sf.FetchConfiguration.Caching.Enabled, "no caching config means L2 stays disabled") }) }) } @@ -1166,8 +1224,9 @@ func (s *StatefulSource) Start() { } type FakeFactory[T any] struct { - upstreamSchema *ast.Document - behavior *DataSourcePlanningBehavior + upstreamSchema *ast.Document + behavior *DataSourcePlanningBehavior + cacheKeyTemplate resolve.CacheKeyTemplate } func (f *FakeFactory[T]) UpstreamSchema(_ DataSourceConfiguration[T]) (*ast.Document, bool) { @@ -1185,9 +1244,10 @@ func (f *FakeFactory[T]) Planner(_ abstractlogger.Logger) DataSourcePlanner[T] { source := &StatefulSource{} go source.Start() return &FakePlanner[T]{ - source: source, - upstreamSchema: f.upstreamSchema, - behavior: f.behavior, + source: source, + upstreamSchema: f.upstreamSchema, + behavior: f.behavior, + cacheKeyTemplate: f.cacheKeyTemplate, } } @@ -1196,10 +1256,11 @@ func (f *FakeFactory[T]) Context() context.Context { } type FakePlanner[T any] struct { - id int - source *StatefulSource - upstreamSchema *ast.Document - behavior *DataSourcePlanningBehavior + id int + source *StatefulSource + upstreamSchema *ast.Document + behavior *DataSourcePlanningBehavior + cacheKeyTemplate resolve.CacheKeyTemplate } func (f *FakePlanner[T]) ID() int { @@ -1220,11 +1281,15 @@ func (f *FakePlanner[T]) Register(visitor *Visitor, _ DataSourceConfiguration[T] } func (f *FakePlanner[T]) ConfigureFetch() resolve.FetchConfiguration { - return resolve.FetchConfiguration{ + cfg := resolve.FetchConfiguration{ DataSource: &FakeDataSource{ source: f.source, }, } + if f.cacheKeyTemplate != nil { + cfg.Caching.CacheKeyTemplate = f.cacheKeyTemplate + } + return cfg } func (f *FakePlanner[T]) ConfigureSubscription() SubscriptionConfiguration { From b8f684c1310fc342cc9b8253412a7c941a52e7aa Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 23:20:31 +0100 Subject: [PATCH 08/11] fix: correct indentation in graphql_datasource_test.go Fixes gci formatting error reported by CI linter at line 789. Co-Authored-By: Claude Opus 4.6 --- .../datasource/graphql_datasource/graphql_datasource_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go index f0c4648e82..315cdce91f 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -786,7 +786,7 @@ func TestGraphQLDataSource(t *testing.T) { FieldNames: []string{"name", "primaryFunction", "friends"}, }, }, - }, + }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ URL: "https://swapi.com/graphql", From 577cee20a7eb64a37365bd4e3f44780b6c73d6b5 Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Wed, 4 Mar 2026 23:45:05 +0100 Subject: [PATCH 09/11] fix: use exact assertions and add doc comment for isolatedRootField Replace assert.Contains with assert.Equal for full response validation, replace strings.Contains with exact cache key comparison, and document the isolatedRootField struct field. Co-Authored-By: Claude Opus 4.6 --- execution/engine/federation_caching_test.go | 15 ++++++--------- v2/pkg/engine/plan/path_builder_visitor.go | 9 ++++++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/execution/engine/federation_caching_test.go b/execution/engine/federation_caching_test.go index 7d2915babe..3625c7432f 100644 --- a/execution/engine/federation_caching_test.go +++ b/execution/engine/federation_caching_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/url" "strconv" - "strings" "sync" "testing" "time" @@ -2627,13 +2626,15 @@ func TestRootFieldSplitByDatasource(t *testing.T) { assert.Equal(t, 4, len(logAfterFirst), "Should have 4 cache operations (get+set for each field)") // Verify TTLs are set independently by checking the set operations + meKey := `{"__typename":"Query","field":"me"}` + catKey := `{"__typename":"Query","field":"cat"}` var meTTL, catTTL time.Duration for _, entry := range logAfterFirst { if entry.Operation == "set" && len(entry.Keys) == 1 { - if strings.Contains(entry.Keys[0], `"field":"me"`) { + if entry.Keys[0] == meKey { meTTL = entry.TTL } - if strings.Contains(entry.Keys[0], `"field":"cat"`) { + if entry.Keys[0] == catKey { catTTL = entry.TTL } } @@ -2783,9 +2784,7 @@ func TestRootFieldSplitByDatasource(t *testing.T) { defaultCache.ClearLog() tracker.Reset() resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, query, nil, t) - assert.Contains(t, string(resp), `"me":{"id":"1234","username":"Me"}`) - assert.Contains(t, string(resp), `"cat":{"name":"Pepper"}`) - assert.Contains(t, string(resp), `"topProducts"`) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"},"topProducts":[{"name":"Trilby","reviews":[{"body":"A highly effective form of birth control.","authorWithoutProvides":{"username":"Me"}}]},{"name":"Fedora","reviews":[{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","authorWithoutProvides":{"username":"Me"}}]}]}}`, string(resp)) // accounts: 2 for root field split (me + cat) + 1 for User entity resolution assert.Equal(t, 3, tracker.GetCount(accountsHost), "accounts: once for me, once for cat, once for User entity") @@ -2796,9 +2795,7 @@ func TestRootFieldSplitByDatasource(t *testing.T) { defaultCache.ClearLog() tracker.Reset() resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, query, nil, t) - assert.Contains(t, string(resp), `"me":{"id":"1234","username":"Me"}`) - assert.Contains(t, string(resp), `"cat":{"name":"Pepper"}`) - assert.Contains(t, string(resp), `"topProducts"`) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"},"topProducts":[{"name":"Trilby","reviews":[{"body":"A highly effective form of birth control.","authorWithoutProvides":{"username":"Me"}}]},{"name":"Fedora","reviews":[{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","authorWithoutProvides":{"username":"Me"}}]}]}}`, string(resp)) // All subgraphs should be skipped on second query assert.Equal(t, 0, tracker.GetCount(accountsHost), "accounts: all from cache") diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index 4e7bb3a9e8..41ecb52d2f 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -110,9 +110,12 @@ type selectionSetTypeInfo struct { } type objectFetchConfiguration struct { - filter *resolve.SubscriptionFilter - planner DataSourceFetchPlanner - isSubscription bool + filter *resolve.SubscriptionFilter + planner DataSourceFetchPlanner + isSubscription bool + // isolatedRootField marks planners for cached query root fields that must + // not merge with other root fields. Set in handlePlanningField; checked in + // planWithExistingPlanners to prevent other fields from joining this planner. isolatedRootField bool fieldRef int fieldDefinitionRef int From 987d94e662e832f6ec6f177e6aa700d4923d912d Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Thu, 5 Mar 2026 09:17:58 +0100 Subject: [PATCH 10/11] test: use full plan assertions and complete cache log comparisons Replace field-by-field planner test assertions with full SynchronousResponsePlan comparisons using the test() pattern. This proves parallel execution via independent FetchDependencies and verifies complete plan structure. Replace TTL loop extraction with sortCacheLogEntriesWithTTL for exact cache log assertions. Co-Authored-By: Claude Opus 4.6 --- .../engine/federation_caching_helpers_test.go | 67 +++ execution/engine/federation_caching_test.go | 21 +- v2/pkg/engine/plan/planner_test.go | 516 ++++++++++++------ 3 files changed, 410 insertions(+), 194 deletions(-) diff --git a/execution/engine/federation_caching_helpers_test.go b/execution/engine/federation_caching_helpers_test.go index c0c96cc274..0ec1cdbf20 100644 --- a/execution/engine/federation_caching_helpers_test.go +++ b/execution/engine/federation_caching_helpers_test.go @@ -310,6 +310,73 @@ func sortCacheLogEntries(log []CacheLogEntry) []CacheLogEntry { return sorted } +// sortCacheLogKeysWithTTL is like sortCacheLogKeys but preserves the TTL field. +// Use this when assertions need to verify TTL values on set operations. +func sortCacheLogKeysWithTTL(log []CacheLogEntry) []CacheLogEntry { + sorted := make([]CacheLogEntry, len(log)) + for i, entry := range log { + if len(entry.Keys) <= 1 { + sorted[i] = CacheLogEntry{ + Operation: entry.Operation, + Keys: entry.Keys, + Hits: entry.Hits, + TTL: entry.TTL, + } + continue + } + + pairs := make([]struct { + key string + hit bool + }, len(entry.Keys)) + for j := range entry.Keys { + pairs[j].key = entry.Keys[j] + if entry.Hits != nil && j < len(entry.Hits) { + pairs[j].hit = entry.Hits[j] + } + } + sort.Slice(pairs, func(a, b int) bool { + return pairs[a].key < pairs[b].key + }) + sorted[i] = CacheLogEntry{ + Operation: entry.Operation, + Keys: make([]string, len(pairs)), + Hits: nil, + TTL: entry.TTL, + } + if len(entry.Hits) > 0 { + sorted[i].Hits = make([]bool, len(pairs)) + } + for j := range pairs { + sorted[i].Keys[j] = pairs[j].key + if sorted[i].Hits != nil { + sorted[i].Hits[j] = pairs[j].hit + } + } + } + return sorted +} + +// sortCacheLogEntriesWithTTL sorts both entries and keys while preserving TTL. +// Use this when entry order is non-deterministic and TTL values need to be verified. +func sortCacheLogEntriesWithTTL(log []CacheLogEntry) []CacheLogEntry { + sorted := sortCacheLogKeysWithTTL(log) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].Operation != sorted[b].Operation { + return sorted[a].Operation < sorted[b].Operation + } + keyA, keyB := "", "" + if len(sorted[a].Keys) > 0 { + keyA = sorted[a].Keys[0] + } + if len(sorted[b].Keys) > 0 { + keyB = sorted[b].Keys[0] + } + return keyA < keyB + }) + return sorted +} + type cacheEntry struct { data []byte expiresAt *time.Time diff --git a/execution/engine/federation_caching_test.go b/execution/engine/federation_caching_test.go index 3625c7432f..d72152ac33 100644 --- a/execution/engine/federation_caching_test.go +++ b/execution/engine/federation_caching_test.go @@ -2623,24 +2623,15 @@ func TestRootFieldSplitByDatasource(t *testing.T) { assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) logAfterFirst := defaultCache.GetLog() - assert.Equal(t, 4, len(logAfterFirst), "Should have 4 cache operations (get+set for each field)") - - // Verify TTLs are set independently by checking the set operations meKey := `{"__typename":"Query","field":"me"}` catKey := `{"__typename":"Query","field":"cat"}` - var meTTL, catTTL time.Duration - for _, entry := range logAfterFirst { - if entry.Operation == "set" && len(entry.Keys) == 1 { - if entry.Keys[0] == meKey { - meTTL = entry.TTL - } - if entry.Keys[0] == catKey { - catTTL = entry.TTL - } - } + wantLogFirst := []CacheLogEntry{ + {Operation: "get", Keys: []string{meKey}, Hits: []bool{false}}, // me: L2 miss + {Operation: "set", Keys: []string{meKey}, TTL: 10 * time.Second}, // me: cached with 10s TTL + {Operation: "get", Keys: []string{catKey}, Hits: []bool{false}}, // cat: L2 miss + {Operation: "set", Keys: []string{catKey}, TTL: 60 * time.Second}, // cat: cached with 60s TTL } - assert.Equal(t, 10*time.Second, meTTL, "me field should have 10s TTL") - assert.Equal(t, 60*time.Second, catTTL, "cat field should have 60s TTL") + assert.Equal(t, sortCacheLogEntriesWithTTL(wantLogFirst), sortCacheLogEntriesWithTTL(logAfterFirst)) }) t.Run("mixed cached and uncached root fields", func(t *testing.T) { diff --git a/v2/pkg/engine/plan/planner_test.go b/v2/pkg/engine/plan/planner_test.go index d1eb0d362b..f440710137 100644 --- a/v2/pkg/engine/plan/planner_test.go +++ b/v2/pkg/engine/plan/planner_test.go @@ -8,6 +8,7 @@ import ( "reflect" "slices" "testing" + "time" "github.com/jensneuse/abstractlogger" "github.com/kylelemons/godebug/diff" @@ -841,193 +842,350 @@ func TestPlanner_Plan(t *testing.T) { // Without this, configureFetchCaching bails early (CacheKeyTemplate == nil). cacheKeyTpl := &resolve.RootQueryCacheKeyTemplate{} - // fetchByRootField finds the fetch whose FetchInfo.RootFields contains the given field. - // Returns nil if no matching fetch is found. - fetchByRootField := func(t *testing.T, fetches []*resolve.FetchItem, fieldName string) *resolve.SingleFetch { - t.Helper() - for _, fi := range fetches { - sf, ok := fi.Fetch.(*resolve.SingleFetch) - if !ok || sf.Info == nil { - continue - } - for _, rf := range sf.Info.RootFields { - if rf.FieldName == fieldName { - return sf - } - } - } - return nil - } - - t.Run("two cached root fields get separate fetches with correct cache configs", func(t *testing.T) { - // With MergeAliasedRootNodes: true, root fields would normally merge into one fetch. - // Root field caching isolation must prevent this so each field gets its own TTL. - ds := dsb(). - Id("accounts"). - WithBehavior(DataSourcePlanningBehavior{ - MergeAliasedRootNodes: true, - }). - CacheKeyTemplate(cacheKeyTpl). - RootNode("Query", "me", "cat"). - ChildNode("User", "id", "username"). - ChildNode("Cat", "name"). - Schema(schema). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "users", TTL: 30 * 1e9}, - {TypeName: "Query", FieldName: "cat", CacheName: "pets", TTL: 60 * 1e9}, - } - }). - DS() - - config := Configuration{ - DataSources: []DataSource{ds}, + // Two cached root fields produce parallel, independent fetches (FetchID 0 and 1, no DependsOnFetchIDs). + // Each fetch gets its own cache config (Enabled, CacheName, TTL). + t.Run("two cached root fields get separate parallel fetches with correct cache configs", test(schema, + `query Q { me { id username } cat { name } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + Enabled: true, + CacheName: "users", + TTL: 30 * time.Second, + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + { + Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + Enabled: true, + CacheName: "pets", + TTL: 60 * time.Second, + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + { + Name: []byte("cat"), + Value: &resolve.Object{ + Path: []string{"cat"}, + Nullable: true, + TypeName: "Cat", + PossibleTypes: map[string]struct{}{"Cat": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{Path: []string{"name"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "users", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "pets", TTL: 60 * time.Second}, + } + }). + DS()}, DisableResolveFieldPositions: true, - } - - var report operationreport.Report - p := testLogic(t, schema, `query Q { me { id username } cat { name } }`, "Q", config, &report) - require.False(t, report.HasErrors()) - - syncPlan, ok := p.(*SynchronousResponsePlan) - require.True(t, ok) - require.Equal(t, 2, len(syncPlan.Response.RawFetches), "each cached root field needs its own fetch") - - // Verify "me" fetch has correct cache config - meFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "me") - require.NotNil(t, meFetch, "should have a fetch for 'me'") - assert.Equal(t, true, meFetch.FetchConfiguration.Caching.Enabled, "me fetch should have L2 caching enabled") - assert.Equal(t, "users", meFetch.FetchConfiguration.Caching.CacheName, "me fetch should use 'users' cache") - assert.Equal(t, 30*1e9, float64(meFetch.FetchConfiguration.Caching.TTL), "me fetch should have 30s TTL") - assert.Equal(t, 1, len(meFetch.Info.RootFields), "me fetch should have exactly one root field") - - // Verify "cat" fetch has correct cache config - catFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "cat") - require.NotNil(t, catFetch, "should have a fetch for 'cat'") - assert.Equal(t, true, catFetch.FetchConfiguration.Caching.Enabled, "cat fetch should have L2 caching enabled") - assert.Equal(t, "pets", catFetch.FetchConfiguration.Caching.CacheName, "cat fetch should use 'pets' cache") - assert.Equal(t, 60*1e9, float64(catFetch.FetchConfiguration.Caching.TTL), "cat fetch should have 60s TTL") - assert.Equal(t, 1, len(catFetch.Info.RootFields), "cat fetch should have exactly one root field") - }) - - t.Run("cached field isolated from uncached field - only cached gets L2", func(t *testing.T) { - // "me" is cached, "user" is not — they must not share a fetch. - // The cached fetch gets Enabled:true, the uncached fetch gets Enabled:false. - ds := dsb(). - Id("accounts"). - WithBehavior(DataSourcePlanningBehavior{ - MergeAliasedRootNodes: true, - }). - CacheKeyTemplate(cacheKeyTpl). - RootNode("Query", "me", "user"). - ChildNode("User", "id", "username"). - Schema(schema). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, - } - }). - DS() + DisableIncludeInfo: true, + DisableEntityCaching: false, + }, + )) - config := Configuration{ - DataSources: []DataSource{ds}, + // Cached "me" is isolated from uncached "user" — each gets its own fetch. + // Only the cached field gets Enabled:true. + t.Run("cached field isolated from uncached field - only cached gets L2", test(schema, + `query Q { me { id } user(id: "1") { username } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + { + Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + }, + }, + }, + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "user"). + ChildNode("User", "id", "username"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + } + }). + DS()}, DisableResolveFieldPositions: true, - } - - var report operationreport.Report - p := testLogic(t, schema, `query Q { me { id } user(id: "1") { username } }`, "Q", config, &report) - require.False(t, report.HasErrors()) - - syncPlan, ok := p.(*SynchronousResponsePlan) - require.True(t, ok) - require.Equal(t, 2, len(syncPlan.Response.RawFetches), "cached and uncached fields need separate fetches") - - // Cached field "me" gets L2 caching - meFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "me") - require.NotNil(t, meFetch, "should have a fetch for 'me'") - assert.Equal(t, true, meFetch.FetchConfiguration.Caching.Enabled, "cached field should have L2 enabled") - assert.Equal(t, "default", meFetch.FetchConfiguration.Caching.CacheName) - assert.Equal(t, 30*1e9, float64(meFetch.FetchConfiguration.Caching.TTL)) - - // Uncached field "user" does NOT get L2 caching - userFetch := fetchByRootField(t, syncPlan.Response.RawFetches, "user") - require.NotNil(t, userFetch, "should have a fetch for 'user'") - assert.Equal(t, false, userFetch.FetchConfiguration.Caching.Enabled, "uncached field should not have L2 enabled") - }) - - t.Run("DisableEntityCaching - fields merge and no L2 caching", func(t *testing.T) { - // When DisableEntityCaching is true, isolation is skipped and fields merge normally. - // configureFetchCaching also returns Enabled:false regardless of RootFieldCaching config. - ds := dsb(). - Id("accounts"). - WithBehavior(DataSourcePlanningBehavior{ - MergeAliasedRootNodes: true, - }). - CacheKeyTemplate(cacheKeyTpl). - RootNode("Query", "me", "cat"). - ChildNode("User", "id", "username"). - ChildNode("Cat", "name"). - Schema(schema). - WithMetadata(func(data *FederationMetaData) { - data.RootFieldCaching = RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * 1e9}, - {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * 1e9}, - } - }). - DS() + DisableIncludeInfo: true, + }, + )) - config := Configuration{ - DataSources: []DataSource{ds}, + // DisableEntityCaching skips isolation — fields merge into one fetch, L2 disabled. + t.Run("DisableEntityCaching - fields merge and no L2 caching", test(schema, + `query Q { me { id username } cat { name } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + { + Name: []byte("cat"), + Value: &resolve.Object{ + Path: []string{"cat"}, + Nullable: true, + TypeName: "Cat", + PossibleTypes: map[string]struct{}{"Cat": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{Path: []string{"name"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + } + }). + DS()}, DisableResolveFieldPositions: true, + DisableIncludeInfo: true, DisableEntityCaching: true, - } - - var report operationreport.Report - p := testLogic(t, schema, `query Q { me { id username } cat { name } }`, "Q", config, &report) - require.False(t, report.HasErrors()) - - syncPlan, ok := p.(*SynchronousResponsePlan) - require.True(t, ok) - // MergeAliasedRootNodes: true + no isolation → fields merge into one fetch - require.Equal(t, 1, len(syncPlan.Response.RawFetches), "DisableEntityCaching should allow fields to merge") - - sf, ok := syncPlan.Response.RawFetches[0].Fetch.(*resolve.SingleFetch) - require.True(t, ok) - assert.Equal(t, false, sf.FetchConfiguration.Caching.Enabled, "DisableEntityCaching disables L2 caching") - }) + }, + )) - t.Run("no caching configured - fields merge normally", func(t *testing.T) { - // No RootFieldCaching → fields merge if MergeAliasedRootNodes is true - ds := dsb(). - Id("accounts"). - WithBehavior(DataSourcePlanningBehavior{ - MergeAliasedRootNodes: true, - }). - CacheKeyTemplate(cacheKeyTpl). - RootNode("Query", "me", "cat"). - ChildNode("User", "id", "username"). - ChildNode("Cat", "name"). - Schema(schema). - DS() - - config := Configuration{ - DataSources: []DataSource{ds}, + // No RootFieldCaching at all — fields merge normally, L2 disabled. + t.Run("no caching configured - fields merge normally", test(schema, + `query Q { me { id username } cat { name } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + { + Name: []byte("cat"), + Value: &resolve.Object{ + Path: []string{"cat"}, + Nullable: true, + TypeName: "Cat", + PossibleTypes: map[string]struct{}{"Cat": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{Path: []string{"name"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(schema). + DS()}, DisableResolveFieldPositions: true, - } - - var report operationreport.Report - p := testLogic(t, schema, `query Q { me { id username } cat { name } }`, "Q", config, &report) - require.False(t, report.HasErrors()) - - syncPlan, ok := p.(*SynchronousResponsePlan) - require.True(t, ok) - require.Equal(t, 1, len(syncPlan.Response.RawFetches), "without caching config, fields should merge into one fetch") - - // Even with CacheKeyTemplate, no RootFieldCaching → L2 disabled - sf, ok := syncPlan.Response.RawFetches[0].Fetch.(*resolve.SingleFetch) - require.True(t, ok) - assert.Equal(t, false, sf.FetchConfiguration.Caching.Enabled, "no caching config means L2 stays disabled") - }) + DisableIncludeInfo: true, + }, + )) }) } From 048367937e63c6b5d91056c23212b261083ed44b Mon Sep 17 00:00:00 2001 From: Jens Neuse Date: Thu, 5 Mar 2026 11:07:28 +0100 Subject: [PATCH 11/11] fix: add explicit FetchID: 0 to first fetch in planner isolation tests Makes FetchDependencies consistent across both fetches in two-fetch test cases, clearly showing both are independent parallel fetches. Co-Authored-By: Claude Opus 4.6 --- v2/pkg/engine/plan/planner_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/v2/pkg/engine/plan/planner_test.go b/v2/pkg/engine/plan/planner_test.go index f440710137..23ba6942c5 100644 --- a/v2/pkg/engine/plan/planner_test.go +++ b/v2/pkg/engine/plan/planner_test.go @@ -851,6 +851,9 @@ func TestPlanner_Plan(t *testing.T) { RawFetches: []*resolve.FetchItem{ { Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, FetchConfiguration: resolve.FetchConfiguration{ DataSource: &FakeDataSource{&StatefulSource{}}, Caching: resolve.FetchCacheConfiguration{ @@ -952,6 +955,9 @@ func TestPlanner_Plan(t *testing.T) { RawFetches: []*resolve.FetchItem{ { Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, FetchConfiguration: resolve.FetchConfiguration{ DataSource: &FakeDataSource{&StatefulSource{}}, Caching: resolve.FetchCacheConfiguration{