From 84e471230fa0b3f5ef2c1fb3b9e62ececa9930ef Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:30:19 +0300 Subject: [PATCH 1/3] feat: enable to ignore implementing types on abstract fields This PR adds a feature flag that allows to emulate behavior of Apollo that ignored weights on implementing types when the fields on interface does not have cost weight assigned. --- .../engine/execution_engine_cost_test.go | 309 ++++++++++++++++++ execution/engine/execution_engine_test.go | 8 + v2/pkg/engine/plan/configuration.go | 2 + v2/pkg/engine/plan/cost.go | 38 ++- 4 files changed, 344 insertions(+), 13 deletions(-) diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index e24d26836..fdacc96d2 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(), + costsWithoutImplementingTypes(), + )) + 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(), + costsWithoutImplementingTypes(), + )) + 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(107), // max(7,5)+ 10 * (7 + max(2,2+1)) + expectedActualCost: intPtr(24), // hero(7) + 2 * (6 + 0.5*(2+0+2+1)) + }, + computeCosts(), + costsWithoutImplementingTypes(), + )) + 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(107), // hero(max(7,5))+ 10 * (7+max(2, 2+1)) + expectedActualCost: intPtr(24), // hero(7) + 2 * (6+0.5*(2+2+1)) + }, + computeCosts(), + costsWithoutImplementingTypes(), + )) + 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(32), // 2 + 1*(0 + 10*(3 + 1*0)) + expectedActualCost: intPtr(5), // 2 + 1*(0 + 1*(3 + 1*0)) + }, + computeCosts(), + costsWithoutImplementingTypes(), + )) + + 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(32), // 2 + 1*(0 + 10*(3 + 1*0)) + expectedActualCost: intPtr(4), // 2 + 1*(0 + 1*(2 + 1*0)) + }, + computeCosts(), + costsWithoutImplementingTypes(), + )) + }) + }) t.Run("union types", func(t *testing.T) { diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 7111a8673..48a28da26 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.SkipImplementingTypesOnAbstract = opts.skipImplementingTypesOnAbstract engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability resolveOpts := resolve.ResolverOptions{ MaxConcurrency: 1024, @@ -347,6 +348,7 @@ type _executionTestOptions struct { propagateFetchReasons bool validateRequiredExternalFields bool computeCosts bool + skipImplementingTypesOnAbstract bool relaxFieldSelectionMergingNullability bool } @@ -378,6 +380,12 @@ func computeCosts() executionTestOptions { } } +func costsWithoutImplementingTypes() executionTestOptions { + return func(options *_executionTestOptions) { + options.skipImplementingTypesOnAbstract = 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 eebd9df35..ba167784c 100644 --- a/v2/pkg/engine/plan/configuration.go +++ b/v2/pkg/engine/plan/configuration.go @@ -53,6 +53,8 @@ type Configuration struct { // When the list size is unknown from directives, this value is used as a default for static cost. StaticCostDefaultListSize int + SkipImplementingTypesOnAbstract 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 88e15651b..e07c8e01c 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 + + skipImplementingTypesOnAbstract 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, + + skipImplementingTypesOnAbstract: c.skipImplementingTypesOnAbstract, } } @@ -578,7 +586,7 @@ func (node *CostTreeNode) costsAndMultiplier(input *costInput) (nodeCost costNod // the enclosing type is concrete. // Commented condition is a good check for that. Might be needed later: // fieldWeight != nil && node.isEnclosingTypeAbstract && parent.returnsAbstractType - if node.isEnclosingTypeAbstract && parent.returnsAbstractType { + if node.isEnclosingTypeAbstract && parent.returnsAbstractType && !input.skipImplementingTypesOnAbstract { // 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. @@ -659,7 +667,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.skipImplementingTypesOnAbstract { for _, weight := range parent.maxDirectiveArgumentWeightsImplementingFields(dsCostConfig, node.fieldCoords.FieldName) { nodeCost.directives += weight } @@ -859,6 +867,8 @@ type CostCalculator struct { // defaultListSize is used as a fallback for list sizes when no specific size is provided. defaultListSize int + + skipImplementingTypesOnAbstract bool } // NewCostCalculator creates a new cost calculator. The defaultListSize is floored to 1. @@ -874,22 +884,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.skipImplementingTypesOnAbstract = config.SkipImplementingTypesOnAbstract 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 +992,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") } From 507f6128805aefece6d0171cb1d38aa723d6d983 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:02:41 +0300 Subject: [PATCH 2/3] rename to the same names --- execution/engine/execution_engine_cost_test.go | 12 ++++++------ execution/engine/execution_engine_test.go | 8 ++++---- v2/pkg/engine/plan/configuration.go | 5 ++++- v2/pkg/engine/plan/cost.go | 9 ++++++--- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index fdacc96d2..201f76406 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -1102,7 +1102,7 @@ func TestExecutionEngine_Cost(t *testing.T) { expectedActualCost: intPtr(13), }, computeCosts(), - costsWithoutImplementingTypes(), + costsIgnoreImplementingTypeWeights(), )) t.Run("hero field returning interface with concrete fragment", runWithoutError( ExecutionEngineTestCase{ @@ -1145,7 +1145,7 @@ func TestExecutionEngine_Cost(t *testing.T) { expectedActualCost: intPtr(5), }, computeCosts(), - costsWithoutImplementingTypes(), + costsIgnoreImplementingTypeWeights(), )) t.Run("query hero with assumedSize on friends", runWithoutError( ExecutionEngineTestCase{ @@ -1201,7 +1201,7 @@ func TestExecutionEngine_Cost(t *testing.T) { expectedActualCost: intPtr(24), // hero(7) + 2 * (6 + 0.5*(2+0+2+1)) }, computeCosts(), - costsWithoutImplementingTypes(), + costsIgnoreImplementingTypeWeights(), )) t.Run("query hero with assumedSize on friends and weight defined", runWithoutError( ExecutionEngineTestCase{ @@ -1259,7 +1259,7 @@ func TestExecutionEngine_Cost(t *testing.T) { expectedActualCost: intPtr(24), // hero(7) + 2 * (6+0.5*(2+2+1)) }, computeCosts(), - costsWithoutImplementingTypes(), + costsIgnoreImplementingTypeWeights(), )) t.Run("named fragment on interface without typenames on friends", runWithoutError( ExecutionEngineTestCase{ @@ -1314,7 +1314,7 @@ func TestExecutionEngine_Cost(t *testing.T) { expectedActualCost: intPtr(5), // 2 + 1*(0 + 1*(3 + 1*0)) }, computeCosts(), - costsWithoutImplementingTypes(), + costsIgnoreImplementingTypeWeights(), )) t.Run("named fragment on interface with typename on friends", runWithoutError( @@ -1369,7 +1369,7 @@ func TestExecutionEngine_Cost(t *testing.T) { expectedActualCost: intPtr(4), // 2 + 1*(0 + 1*(2 + 1*0)) }, computeCosts(), - costsWithoutImplementingTypes(), + costsIgnoreImplementingTypeWeights(), )) }) diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 48a28da26..b99b60ff2 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -95,7 +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.SkipImplementingTypesOnAbstract = opts.skipImplementingTypesOnAbstract + engineConf.plannerConfig.IgnoreImplementingTypeWeights = opts.ignoreImplementingTypeWeights engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability resolveOpts := resolve.ResolverOptions{ MaxConcurrency: 1024, @@ -348,7 +348,7 @@ type _executionTestOptions struct { propagateFetchReasons bool validateRequiredExternalFields bool computeCosts bool - skipImplementingTypesOnAbstract bool + ignoreImplementingTypeWeights bool relaxFieldSelectionMergingNullability bool } @@ -380,9 +380,9 @@ func computeCosts() executionTestOptions { } } -func costsWithoutImplementingTypes() executionTestOptions { +func costsIgnoreImplementingTypeWeights() executionTestOptions { return func(options *_executionTestOptions) { - options.skipImplementingTypesOnAbstract = true + options.ignoreImplementingTypeWeights = true } } diff --git a/v2/pkg/engine/plan/configuration.go b/v2/pkg/engine/plan/configuration.go index ba167784c..922223010 100644 --- a/v2/pkg/engine/plan/configuration.go +++ b/v2/pkg/engine/plan/configuration.go @@ -53,7 +53,10 @@ type Configuration struct { // When the list size is unknown from directives, this value is used as a default for static cost. StaticCostDefaultListSize int - SkipImplementingTypesOnAbstract bool + // 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 diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index e07c8e01c..808120db3 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -434,7 +434,7 @@ func newCostInput(isEstimation bool, c *CostCalculator, vars resolve.VariablesVi typeStats: typeStats, isEstimation: isEstimation, - skipImplementingTypesOnAbstract: c.skipImplementingTypesOnAbstract, + skipImplementingTypesOnAbstract: c.ignoreImplementingTypeWeights, } } @@ -868,7 +868,10 @@ type CostCalculator struct { // defaultListSize is used as a fallback for list sizes when no specific size is provided. defaultListSize int - skipImplementingTypesOnAbstract bool + // 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. @@ -886,7 +889,7 @@ func NewCostCalculator(config Configuration) *CostCalculator { } // Zero would estimate all lists as zero. c.defaultListSize = max(config.StaticCostDefaultListSize, 1) - c.skipImplementingTypesOnAbstract = config.SkipImplementingTypesOnAbstract + c.ignoreImplementingTypeWeights = config.IgnoreImplementingTypeWeights return &c } From 89e8ecffddd6b8546c1d741dac9698fa6106f410 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:41:02 +0300 Subject: [PATCH 3/3] handle enclosing abstract fields --- .../engine/execution_engine_cost_test.go | 57 +++++++++++++++++-- v2/pkg/engine/plan/cost.go | 14 +++-- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index 201f76406..6d2ccfe9f 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -1197,7 +1197,7 @@ func TestExecutionEngine_Cost(t *testing.T) { ), }, expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: intPtr(107), // max(7,5)+ 10 * (7 + max(2,2+1)) + 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(), @@ -1255,7 +1255,7 @@ func TestExecutionEngine_Cost(t *testing.T) { ), }, expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: intPtr(107), // hero(max(7,5))+ 10 * (7+max(2, 2+1)) + 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(), @@ -1310,8 +1310,8 @@ func TestExecutionEngine_Cost(t *testing.T) { ), }, expectedResponse: `{"data":{"hero":{"name":"Luke","friends":[{"name":"Leia"}]}}}`, - expectedEstimatedCost: intPtr(32), // 2 + 1*(0 + 10*(3 + 1*0)) - expectedActualCost: intPtr(5), // 2 + 1*(0 + 1*(3 + 1*0)) + expectedEstimatedCost: intPtr(20), // 2 + 1*(0 + 6*(3 + 1*0)) + expectedActualCost: intPtr(5), // 2 + 1*(0 + 1*(3 + 1*0)) }, computeCosts(), costsIgnoreImplementingTypeWeights(), @@ -1365,8 +1365,8 @@ func TestExecutionEngine_Cost(t *testing.T) { ), }, expectedResponse: `{"data":{"hero":{"name":"Luke","friends":[{"name":"Leia"}]}}}`, - expectedEstimatedCost: intPtr(32), // 2 + 1*(0 + 10*(3 + 1*0)) - expectedActualCost: intPtr(4), // 2 + 1*(0 + 1*(2 + 1*0)) + expectedEstimatedCost: intPtr(20), // 2 + 1*(0 + 6*(3 + 1*0)) + expectedActualCost: intPtr(4), // 2 + 1*(0 + 1*(2 + 1*0)) }, computeCosts(), costsIgnoreImplementingTypeWeights(), @@ -3622,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/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index 808120db3..8031df486 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -415,7 +415,7 @@ type costInput struct { // isEstimation is true for estimated calculation and false for actual. isEstimation bool - skipImplementingTypesOnAbstract bool + ignoreImplementingTypeWeights bool } // newCostInput bundles the cost-calculation inputs. @@ -434,7 +434,7 @@ func newCostInput(isEstimation bool, c *CostCalculator, vars resolve.VariablesVi typeStats: typeStats, isEstimation: isEstimation, - skipImplementingTypesOnAbstract: c.ignoreImplementingTypeWeights, + ignoreImplementingTypeWeights: c.ignoreImplementingTypeWeights, } } @@ -586,11 +586,13 @@ func (node *CostTreeNode) costsAndMultiplier(input *costInput) (nodeCost costNod // the enclosing type is concrete. // Commented condition is a good check for that. Might be needed later: // fieldWeight != nil && node.isEnclosingTypeAbstract && parent.returnsAbstractType - if node.isEnclosingTypeAbstract && parent.returnsAbstractType && !input.skipImplementingTypesOnAbstract { + if node.isEnclosingTypeAbstract && parent.returnsAbstractType { // 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) @@ -667,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 && !input.skipImplementingTypesOnAbstract { + if node.isEnclosingTypeAbstract && parent.returnsAbstractType && !input.ignoreImplementingTypeWeights { for _, weight := range parent.maxDirectiveArgumentWeightsImplementingFields(dsCostConfig, node.fieldCoords.FieldName) { nodeCost.directives += weight } @@ -772,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 {