Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Complexity cost bug fixes #4843

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 87 additions & 7 deletions lib/graphql/analysis/query_complexity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,32 @@ def initialize(query)

# Override this method to use the complexity result
def result
max_possible_complexity
case subject.schema.complexity_cost_calculation_mode_for(subject.context)
when :future
max_possible_complexity
when :legacy
max_possible_complexity(mode: :legacy)
when :compare
future_complexity = max_possible_complexity
legacy_complexity = max_possible_complexity(mode: legacy)
if future_complexity != legacy_complexity
subject.schema.legacy_complexity_cost_calculation_mismatch(subject, future_complexity, legacy_complexity)
else
future_complexity
end
when nil
subject.logger.warn <<~GRAPHQL
GraphQL-Ruby's complexity cost system is getting some "breaking fixes" in a future version. See the migration notes at https://graphql-ruby.org/api-docs/#{GraphQL::VERSION}/Schema.html#complexity_cost_cacluation_mode-class_method

To opt into the future behavior, configure your schema with:

complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare`

GRAPHQL
max_possible_complexity
else
raise ArgumentError, "Expected `:future`, `:legacy`, `:compare`, or `nil` from `#{query.schema}.complexity_cost_calculation_mode_for` but got: #{query.schema.complexity_cost_calculation_mode.inspect}"
end
end

# ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie:
Expand Down Expand Up @@ -44,6 +69,10 @@ def initialize(parent_type, field_definition, query, response_path)
def own_complexity(child_complexity)
@field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity)
end

def composite?
!empty?
end
end

def on_enter_field(node, parent, visitor)
Expand Down Expand Up @@ -77,16 +106,17 @@ def on_leave_field(node, parent, visitor)
private

# @return [Integer]
def max_possible_complexity
def max_possible_complexity(mode: :future)
@complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)|
total + merged_max_complexity_for_scopes(query, [scopes_stack.first])
total + merged_max_complexity_for_scopes(query, [scopes_stack.first], mode)
end
end

# @param query [GraphQL::Query] Used for `query.possible_types`
# @param scopes [Array<ScopedTypeComplexity>] Array of scoped type complexities
# @param mode [:future, :legacy]
# @return [Integer]
def merged_max_complexity_for_scopes(query, scopes)
def merged_max_complexity_for_scopes(query, scopes, mode)
# Aggregate a set of all possible scope types encountered (scope keys).
# Use a hash, but ignore the values; it's just a fast way to work with the keys.
possible_scope_types = scopes.each_with_object({}) do |scope, memo|
Expand Down Expand Up @@ -115,14 +145,20 @@ def merged_max_complexity_for_scopes(query, scopes)
end

# Find the maximum complexity for the scope type among possible lexical branches.
complexity = merged_max_complexity(query, all_inner_selections)
complexity = case mode
when :legacy
legacy_merged_max_complexity(query, all_inner_selections)
when :future
merged_max_complexity(query, all_inner_selections)
else
raise ArgumentError, "Expected :legacy or :future, not: #{mode.inspect}"
end
complexity > max ? complexity : max
end
end

def types_intersect?(query, a, b)
return true if a == b

a_types = query.types.possible_types(a)
query.types.possible_types(b).any? { |t| a_types.include?(t) }
end
Expand All @@ -145,6 +181,50 @@ def merged_max_complexity(query, inner_selections)
memo.merge!(inner_selection)
end

# Add up the total cost for each unique field name's coalesced selections
unique_field_keys.each_key.reduce(0) do |total, field_key|
# Collect all child scopes for this field key;
# all keys come with at least one scope.
child_scopes = inner_selections.filter_map { _1[field_key] }

# Compute maximum possible cost of child selections;
# composites merge their maximums, while leaf scopes are always zero.
# FieldsWillMerge validation assures all scopes are uniformly composite or leaf.
maximum_children_cost = if child_scopes.any?(&:composite?)
merged_max_complexity_for_scopes(query, child_scopes, :future)
else
0
end

# Identify the maximum cost and scope among possibilities
maximum_cost = 0
maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope|
scope_cost = possible_scope.own_complexity(maximum_children_cost)
if scope_cost > maximum_cost
maximum_cost = scope_cost
possible_scope
else
max_scope
end
end

field_complexity(
maximum_scope,
max_complexity: maximum_cost,
child_complexity: maximum_children_cost,
)

total + maximum_cost
end
end

def legacy_merged_max_complexity(query, inner_selections)
# Aggregate a set of all unique field selection keys across all scopes.
# Use a hash, but ignore the values; it's just a fast way to work with the keys.
unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo|
memo.merge!(inner_selection)
end

# Add up the total cost for each unique field name's coalesced selections
unique_field_keys.each_key.reduce(0) do |total, field_key|
composite_scopes = nil
Expand All @@ -167,7 +247,7 @@ def merged_max_complexity(query, inner_selections)
end

if composite_scopes
child_complexity = merged_max_complexity_for_scopes(query, composite_scopes)
child_complexity = merged_max_complexity_for_scopes(query, composite_scopes, :legacy)

# This is the last composite scope visited; assume it's representative (for backwards compatibility).
# Note: it would be more correct to score each composite scope and use the maximum possibility.
Expand Down
5 changes: 5 additions & 0 deletions lib/graphql/execution/multiplex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ def initialize(schema:, queries:, context:, max_complexity:)
@tracers = schema.tracers + (context[:tracers] || [])
@max_complexity = max_complexity
@current_trace = context[:trace] ||= schema.new_trace(multiplex: self)
@logger = nil
end

def logger
@logger ||= @schema.logger_for(context)
end
end
end
Expand Down
8 changes: 1 addition & 7 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n
@result_values = nil
@executed = false

@logger = if context && context[:logger] == false
Logger.new(IO::NULL)
elsif context && (l = context[:logger])
l
else
schema.default_logger
end
@logger = schema.logger_for(context)
end

# If a document was provided to `GraphQL::Schema#execute` instead of the raw query string, we will need to get it from the document
Expand Down
91 changes: 91 additions & 0 deletions lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,18 @@ def default_logger(new_default_logger = NOT_CONFIGURED)
end
end

# @param context [GraphQL::Query::Context, nil]
# @return [Logger] A logger to use for this context configuration, falling back to {.default_logger}
def logger_for(context)
if context && context[:logger] == false
Logger.new(IO::NULL)
elsif context && (l = context[:logger])
l
else
default_logger
end
end

# @param new_context_class [Class<GraphQL::Query::Context>] A subclass to use when executing queries
def context_class(new_context_class = nil)
if new_context_class
Expand Down Expand Up @@ -1735,6 +1747,85 @@ def legacy_invalid_return_type_conflicts(query, type1, type2, node1, node2)
raise "Implement #{self}.legacy_invalid_return_type_conflicts to handle this invalid selection"
end

# The legacy complexity implementation included several bugs:
#
# - In some cases, it used the lexically _last_ field to determine a cost, instead of calculating the maximum among selections
# - In some cases, it called field complexity hooks repeatedly (when it should have only called them once)
#
# The future implementation may produce higher total complexity scores, so it's not active by default yet. You can opt into
# the future default behavior by configuring `:future` here. Or, you can choose a mode for each query with {.complexity_cost_calculation_mode_for}.
#
# The legacy mode is currently maintained alongside the future one, but it will be removed in a future GraphQL-Ruby version.
#
# If you choose `:compare`, you must also implement {.legacy_complexity_cost_calculation_mismatch} to handle the input somehow.
#
# @example Opting into the future calculation mode
# complexity_cost_calculation_mode(:future)
#
# @example Choosing the legacy mode (which will work until that mode is removed...)
# complexity_cost_calculation_mode(:legacy)
#
# @example Run both modes for every query, call {.legacy_complexity_cost_calculation_mismatch} when they don't match:
# complexity_cost_calculation_mode(:compare)
def complexity_cost_calculation_mode(new_mode = NOT_CONFIGURED)
if NOT_CONFIGURED.equal?(new_mode)
@complexity_cost_calculation_mode
else
@complexity_cost_calculation_mode = new_mode
end
end

# Implement this method to produce a per-query complexity cost calculation mode. (Technically, it's per-multiplex.)
#
# This is a way to check the compatibility of queries coming to your API without adding overhead of running `:compare`
# for every query. You could sample traffic, turn it off/on with feature flags, or anything else.
#
# @example Sampling traffic
# def self.complexity_cost_calculation_mode_for(_context)
# if rand < 0.1 # 10% of the time
# :compare
# else
# :legacy
# end
# end
#
# @example Using a feature flag to manage future mode
# def complexity_cost_calculation_mode_for(context)
# current_user = context[:current_user]
# if Flipper.enabled?(:future_complexity_cost, current_user)
# :future
# elsif rand < 0.5 # 50%
# :compare
# else
# :legacy
# end
# end
#
# @param context [Hash] The context for the currently-running {Execution::Multiplex} (which contains one or more queries)
# @return [:future] Use the new calculation algorithm -- may be higher than `:legacy`
# @return [:legacy] Use the legacy calculation algorithm, warts and all
# @return [:compare] Run both algorithms and call {.legacy_complexity_cost_calculation_mismatch} if they don't match
def complexity_cost_calculation_mode_for(multiplex_context)
complexity_cost_calculation_mode
end

# Implement this method in your schema to handle mismatches when `:compare` is used.
#
# @example Logging the mismatch
# def self.legacy_cost_calculation_mismatch(multiplex, future_cost, legacy_cost)
# client_id = multiplex.context[:api_client].id
# operation_names = multiplex.queries.map { |q| q.selected_operation_name || "anonymous" }.join(", ")
# Stats.increment(:complexity_mismatch, tags: { client: client_id, ops: operation_names })
# legacy_cost
# end
# @param multiplex [GraphQL::Execution::Multiplex]
# @param future_complexity_cost [Integer]
# @param legacy_complexity_cost [Integer]
# @return [Integer] the cost to use for this query (probably one of `future_complexity_cost` or `legacy_complexity_cost`)
def legacy_complexity_cost_calculation_mismatch(multiplex, future_complexity_cost, legacy_complexity_cost)
raise "Implement #{self}.legacy_complexity_cost(multiplex, future_complexity_cost, legacy_complexity_cost) to handle this mismatch (#{future_complexity_cost} vs. #{legacy_complexity_cost}) and return a value to use"
end

private

def add_trace_options_for(mode, new_options)
Expand Down
102 changes: 100 additions & 2 deletions spec/graphql/analysis/query_complexity_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -723,10 +723,108 @@ def field_complexity(scoped_type_complexity, max_complexity:, child_complexity:)
field_complexities = reduce_result.first

assert_equal({
['cheese', 'id'] => { max_complexity: 1, child_complexity: nil },
['cheese', 'flavor'] => { max_complexity: 1, child_complexity: nil },
['cheese', 'id'] => { max_complexity: 1, child_complexity: 0 },
['cheese', 'flavor'] => { max_complexity: 1, child_complexity: 0 },
['cheese'] => { max_complexity: 3, child_complexity: 2 },
}, field_complexities)
end
end

describe "maximum of possible scopes regardless of selection order" do
class MaxOfPossibleScopes < GraphQL::Schema
class Cheese < GraphQL::Schema::Object
field :kind, String
end

module Producer
include GraphQL::Schema::Interface
field :cheese, Cheese, complexity: 5
field :name, String, complexity: 5
end

class Farm < GraphQL::Schema::Object
implements Producer
field :cheese, Cheese, complexity: 10
field :name, String, complexity: 10
end

class Entity < GraphQL::Schema::Union
possible_types Farm
end

class Query < GraphQL::Schema::Object
field :entity, Entity
end

def self.resolve_type
Farm
end

def self.cost(query_string)
GraphQL::Analysis::AST.analyze_query(
GraphQL::Query.new(self, query_string),
[GraphQL::Analysis::AST::QueryComplexity],
).first
end

query(Query)
end

it "uses maximum of merged composite fields, regardless of selection order" do
a = MaxOfPossibleScopes.cost(%|
{
entity {
...on Producer { cheese { kind } }
...on Farm { cheese { kind } }
}
}
|)

b = MaxOfPossibleScopes.cost(%|
{
entity {
...on Farm { cheese { kind } }
...on Producer { cheese { kind } }
}
}
|)

assert_equal 0, a - b
end

it "uses maximum of merged leaf fields, regardless of selection order" do
a = MaxOfPossibleScopes.cost(%|
{
entity {
...on Producer { name }
...on Farm { name }
}
}
|)

b = MaxOfPossibleScopes.cost(%|
{
entity {
...on Farm { name }
...on Producer { name }
}
}
|)

assert_equal 0, a - b
end

it "invalid mismatched scope types will still compute without error" do
cost = MaxOfPossibleScopes.cost(%|
{
entity {
...on Farm { cheese { kind } }
...on Producer { cheese: name }
}
}
|)

assert_equal 12, cost
end
end
end
Loading