diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index e24d268365..6d2ccfe9fc 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -1064,6 +1064,315 @@ func TestExecutionEngine_Cost(t *testing.T) { )) }) + t.Run("skipImplementingTypesOnAbstract", func(t *testing.T) { + t.Run("returned abstract field should return 0 cost", 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.FieldCost{ + {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: intPtr(13), // Query.Human (13) + expectedActualCost: intPtr(13), + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + t.Run("hero field returning interface 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.FieldCost{ + {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: intPtr(5), // Query.hero (2) + Human.height (3) + expectedActualCost: intPtr(5), + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + 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.FieldCost{ + {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: intPtr(207), // max(7,5)+ 20 * (7 + max(2,2+1)) + expectedActualCost: intPtr(24), // hero(7) + 2 * (6 + 0.5*(2+0+2+1)) + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + 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.FieldCost{ + {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: intPtr(207), // hero(max(7,5))+ 20 * (7+max(2, 2+1)) + expectedActualCost: intPtr(24), // hero(7) + 2 * (6+0.5*(2+2+1)) + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + t.Run("named fragment on interface without typenames on friends", 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: "", + // Is it possible that friends items would be returned without __typename? + 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.FieldCost{ + {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"}]}}}`, + expectedEstimatedCost: intPtr(20), // 2 + 1*(0 + 6*(3 + 1*0)) + expectedActualCost: intPtr(5), // 2 + 1*(0 + 1*(3 + 1*0)) + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + + t.Run("named fragment on interface with typename on friends", 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":[{"__typename":"Human","name":"Leia"}]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{ + {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"}]}}}`, + expectedEstimatedCost: intPtr(20), // 2 + 1*(0 + 6*(3 + 1*0)) + expectedActualCost: intPtr(4), // 2 + 1*(0 + 1*(2 + 1*0)) + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + }) + }) t.Run("union types", func(t *testing.T) { @@ -3313,6 +3622,51 @@ func TestExecutionEngine_Cost(t *testing.T) { computeCosts(), )) + t.Run("IgnoreImplementingTypeWeights: a field selected directly on the interface should not be charged", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaAbs, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{Query: `{ + boards(ids: ["b1"], limit: 1) { + items_page(limit: 1) { + items { + column_values { + text + } + } + } + }}`} + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"boards":[{"items_page":{"items":[{"column_values":[` + + `{"__typename":"PeopleValue","text":"p1"},` + + `{"__typename":"PeopleValue","text":"p2"},` + + `{"__typename":"StatusValue","text":"s1"},` + + `{"__typename":"StatusValue","text":"s2"},` + + `{"__typename":"StatusValue","text":"s3"}` + + `]}]}}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: costConfig}, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"boards":[{"items_page":{"items":[{"column_values":[{"text":"p1"},{"text":"p2"},{"text":"s1"},{"text":"s2"},{"text":"s3"}]}]}}]}}`, + // 1 * (10 + 1 * (10 + 1 * (10 + 10 * (1 + 1*0)))) + expectedEstimatedCost: intPtr(40), + // 1 * (10 + 1 * (10 + 1 * (10 + 5 * (1 + 0.4*0)))) + expectedActualCost: intPtr(35), + }, + computeCosts(), + costsIgnoreImplementingTypeWeights(), + )) + t.Run("a field selected on the interface and fragments should be charged once", runWithoutError( ExecutionEngineTestCase{ schema: schemaAbs, diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 7111a86734..b99b60ff20 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -95,6 +95,7 @@ func runExecutionTest(testCase ExecutionEngineTestCase, withError bool, expected engineConf.plannerConfig.ValidateRequiredExternalFields = opts.validateRequiredExternalFields engineConf.plannerConfig.ComputeCosts = opts.computeCosts engineConf.plannerConfig.StaticCostDefaultListSize = 10 + engineConf.plannerConfig.IgnoreImplementingTypeWeights = opts.ignoreImplementingTypeWeights engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability resolveOpts := resolve.ResolverOptions{ MaxConcurrency: 1024, @@ -347,6 +348,7 @@ type _executionTestOptions struct { propagateFetchReasons bool validateRequiredExternalFields bool computeCosts bool + ignoreImplementingTypeWeights bool relaxFieldSelectionMergingNullability bool } @@ -378,6 +380,12 @@ func computeCosts() executionTestOptions { } } +func costsIgnoreImplementingTypeWeights() executionTestOptions { + return func(options *_executionTestOptions) { + options.ignoreImplementingTypeWeights = true + } +} + func relaxFieldSelectionMergingNullability() executionTestOptions { return func(options *_executionTestOptions) { options.relaxFieldSelectionMergingNullability = true diff --git a/v2/pkg/engine/plan/configuration.go b/v2/pkg/engine/plan/configuration.go index eebd9df352..922223010d 100644 --- a/v2/pkg/engine/plan/configuration.go +++ b/v2/pkg/engine/plan/configuration.go @@ -53,6 +53,11 @@ type Configuration struct { // When the list size is unknown from directives, this value is used as a default for static cost. StaticCostDefaultListSize int + // IgnoreImplementingTypeWeights, when true, ignores @cost weights contributed by + // implementing types on abstract (interface/union) fields that have no weight of their own. + // Emulates Apollo's cost behavior. + IgnoreImplementingTypeWeights bool + // RelaxSubgraphOperationFieldSelectionMergingNullability relaxes the nullability validation // for field selection merging in upstream (subgraph) operations when enclosing types are // non-overlapping concrete object types. This is a deliberate spec deviation. diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index 88e15651bd..8031df486d 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -414,19 +414,27 @@ type costInput struct { // isEstimation is true for estimated calculation and false for actual. isEstimation bool + + ignoreImplementingTypeWeights bool } // newCostInput bundles the cost-calculation inputs. // defaultListSize designates the mode of operation. // When it is non-negative, then its value is used as a fallback value for list sizes in estimations. // Otherwise, it computes the actual cost and uses the typeStats map for list sizes. -func newCostInput(configs map[DSHash]*DataSourceCostConfig, vars resolve.VariablesView, defaultListSize int, typeStats map[string]resolve.TypeNameStats) *costInput { +func newCostInput(isEstimation bool, c *CostCalculator, vars resolve.VariablesView, typeStats map[string]resolve.TypeNameStats) *costInput { + defaultLS := c.defaultListSize + if !isEstimation { + defaultLS = actualCostMode + } return &costInput{ - configs: configs, + configs: c.costConfigs, vars: vars, - defaultListSize: defaultListSize, + defaultListSize: defaultLS, typeStats: typeStats, - isEstimation: defaultListSize >= 0, + isEstimation: isEstimation, + + ignoreImplementingTypeWeights: c.ignoreImplementingTypeWeights, } } @@ -582,7 +590,9 @@ func (node *CostTreeNode) costsAndMultiplier(input *costInput) (nodeCost costNod // This field is part of the enclosing interface/union. // We look into implementing types and find the max-weighted field. // Found fieldWeight can be used for all the calculations. - fieldWeight = parent.maxWeightImplementingField(dsCostConfig, node.fieldCoords.FieldName) + if !input.ignoreImplementingTypeWeights { + fieldWeight = parent.maxWeightImplementingField(dsCostConfig, node.fieldCoords.FieldName) + } // If this field has listSize defined, then do not look into implementing types. if input.isEstimation && listSize == nil && node.returnsListType { listSize = parent.maxMultiplierImplementingField(dsCostConfig, node.fieldCoords.FieldName, node.arguments, input.vars, input.defaultListSize) @@ -659,7 +669,7 @@ func (node *CostTreeNode) costsAndMultiplier(input *costInput) (nodeCost costNod // Directive weights: sum from the field's own DirectiveArgumentWeights, // or from implementing types when the enclosing type is abstract. - if node.isEnclosingTypeAbstract && parent.returnsAbstractType { + if node.isEnclosingTypeAbstract && parent.returnsAbstractType && !input.ignoreImplementingTypeWeights { for _, weight := range parent.maxDirectiveArgumentWeightsImplementingFields(dsCostConfig, node.fieldCoords.FieldName) { nodeCost.directives += weight } @@ -764,7 +774,7 @@ func (node *CostTreeNode) costsAndMultiplier(input *costInput) (nodeCost costNod if ancestorNode == nil || node.parent != ancestorNode || !ancestorNode.returnsAbstractType || ancestorStats.Size == 0 { return } - if node.isEnclosingTypeAbstract && nodeCost.field > 0 { + if node.isEnclosingTypeAbstract && nodeCost.field > 0 && !input.ignoreImplementingTypeWeights { var weightedSum float64 found := false for _, implTypeName := range parent.implementingTypeNames { @@ -859,6 +869,11 @@ type CostCalculator struct { // defaultListSize is used as a fallback for list sizes when no specific size is provided. defaultListSize int + + // ignoreImplementingTypeWeights, when true, ignores @cost weights contributed by + // implementing types on abstract (interface/union) fields that have no weight of their own. + // Emulates Apollo's cost behavior. + ignoreImplementingTypeWeights bool } // NewCostCalculator creates a new cost calculator. The defaultListSize is floored to 1. @@ -874,22 +889,24 @@ func NewCostCalculator(config Configuration) *CostCalculator { } c.costConfigs[ds.Hash()] = dsCostConfig } - c.defaultListSize = max(config.StaticCostDefaultListSize, - // Zero would estimate all lists as zero. - 1) + // Zero would estimate all lists as zero. + c.defaultListSize = max(config.StaticCostDefaultListSize, 1) + c.ignoreImplementingTypeWeights = config.IgnoreImplementingTypeWeights return &c } // EstimateCost returns the calculated total static cost. // config should be static per process or instance. vars could change between requests. func (c *CostCalculator) EstimateCost(vars resolve.VariablesView) int { - input := newCostInput(c.costConfigs, vars, c.defaultListSize, nil) + input := newCostInput(true, c, vars, nil) + // fmt.Println(c.DebugPrint(vars, nil)) return int(math.RoundToEven(c.tree.cost(input))) } // ActualCost returns the actual cost of the operation that is based on the actual sizes of lists. func (c *CostCalculator) ActualCost(vars resolve.VariablesView, typeStats map[string]resolve.TypeNameStats) int { - input := newCostInput(c.costConfigs, vars, actualCostMode, typeStats) + input := newCostInput(false, c, vars, typeStats) + // fmt.Println(c.DebugPrint(vars, typeStats)) return int(math.RoundToEven(c.tree.cost(input))) } @@ -980,11 +997,11 @@ func (c *CostCalculator) DebugPrint(vars resolve.VariablesView, typeStats map[st var sb strings.Builder var input *costInput if typeStats != nil { - input = newCostInput(c.costConfigs, vars, actualCostMode, typeStats) + input = newCostInput(false, c, vars, typeStats) sb.WriteString("Actual Cost Tree Debug\n") sb.WriteString("======================\n") } else { - input = newCostInput(c.costConfigs, vars, c.defaultListSize, typeStats) + input = newCostInput(true, c, vars, typeStats) sb.WriteString("Estimated Cost Tree Debug\n") sb.WriteString("=========================\n") }