Skip to content

Commit 324d821

Browse files
authored
Merge pull request #4843 from gmac/gmac/v3_complexity_update
Complexity cost bug fixes
2 parents b8e2bff + 99d1d7c commit 324d821

7 files changed

Lines changed: 428 additions & 19 deletions

File tree

lib/graphql/analysis/query_complexity.rb

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,32 @@ def initialize(query)
1313

1414
# Override this method to use the complexity result
1515
def result
16-
max_possible_complexity
16+
case subject.schema.complexity_cost_calculation_mode_for(subject.context)
17+
when :future
18+
max_possible_complexity
19+
when :legacy
20+
max_possible_complexity(mode: :legacy)
21+
when :compare
22+
future_complexity = max_possible_complexity
23+
legacy_complexity = max_possible_complexity(mode: :legacy)
24+
if future_complexity != legacy_complexity
25+
subject.schema.legacy_complexity_cost_calculation_mismatch(subject, future_complexity, legacy_complexity)
26+
else
27+
future_complexity
28+
end
29+
when nil
30+
subject.logger.warn <<~GRAPHQL
31+
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
32+
33+
To opt into the future behavior, configure your schema (#{subject.schema.name ? subject.schema.name : subject.schema.ancestors}) with:
34+
35+
complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare`
36+
37+
GRAPHQL
38+
max_possible_complexity
39+
else
40+
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}"
41+
end
1742
end
1843

1944
# ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie:
@@ -44,6 +69,10 @@ def initialize(parent_type, field_definition, query, response_path)
4469
def own_complexity(child_complexity)
4570
@field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity)
4671
end
72+
73+
def composite?
74+
!empty?
75+
end
4776
end
4877

4978
def on_enter_field(node, parent, visitor)
@@ -77,16 +106,17 @@ def on_leave_field(node, parent, visitor)
77106
private
78107

79108
# @return [Integer]
80-
def max_possible_complexity
109+
def max_possible_complexity(mode: :future)
81110
@complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)|
82-
total + merged_max_complexity_for_scopes(query, [scopes_stack.first])
111+
total + merged_max_complexity_for_scopes(query, [scopes_stack.first], mode)
83112
end
84113
end
85114

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

117147
# Find the maximum complexity for the scope type among possible lexical branches.
118-
complexity = merged_max_complexity(query, all_inner_selections)
148+
complexity = case mode
149+
when :legacy
150+
legacy_merged_max_complexity(query, all_inner_selections)
151+
when :future
152+
merged_max_complexity(query, all_inner_selections)
153+
else
154+
raise ArgumentError, "Expected :legacy or :future, not: #{mode.inspect}"
155+
end
119156
complexity > max ? complexity : max
120157
end
121158
end
122159

123160
def types_intersect?(query, a, b)
124161
return true if a == b
125-
126162
a_types = query.types.possible_types(a)
127163
query.types.possible_types(b).any? { |t| a_types.include?(t) }
128164
end
@@ -145,6 +181,50 @@ def merged_max_complexity(query, inner_selections)
145181
memo.merge!(inner_selection)
146182
end
147183

184+
# Add up the total cost for each unique field name's coalesced selections
185+
unique_field_keys.each_key.reduce(0) do |total, field_key|
186+
# Collect all child scopes for this field key;
187+
# all keys come with at least one scope.
188+
child_scopes = inner_selections.filter_map { _1[field_key] }
189+
190+
# Compute maximum possible cost of child selections;
191+
# composites merge their maximums, while leaf scopes are always zero.
192+
# FieldsWillMerge validation assures all scopes are uniformly composite or leaf.
193+
maximum_children_cost = if child_scopes.any?(&:composite?)
194+
merged_max_complexity_for_scopes(query, child_scopes, :future)
195+
else
196+
0
197+
end
198+
199+
# Identify the maximum cost and scope among possibilities
200+
maximum_cost = 0
201+
maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope|
202+
scope_cost = possible_scope.own_complexity(maximum_children_cost)
203+
if scope_cost > maximum_cost
204+
maximum_cost = scope_cost
205+
possible_scope
206+
else
207+
max_scope
208+
end
209+
end
210+
211+
field_complexity(
212+
maximum_scope,
213+
max_complexity: maximum_cost,
214+
child_complexity: maximum_children_cost,
215+
)
216+
217+
total + maximum_cost
218+
end
219+
end
220+
221+
def legacy_merged_max_complexity(query, inner_selections)
222+
# Aggregate a set of all unique field selection keys across all scopes.
223+
# Use a hash, but ignore the values; it's just a fast way to work with the keys.
224+
unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo|
225+
memo.merge!(inner_selection)
226+
end
227+
148228
# Add up the total cost for each unique field name's coalesced selections
149229
unique_field_keys.each_key.reduce(0) do |total, field_key|
150230
composite_scopes = nil
@@ -167,7 +247,7 @@ def merged_max_complexity(query, inner_selections)
167247
end
168248

169249
if composite_scopes
170-
child_complexity = merged_max_complexity_for_scopes(query, composite_scopes)
250+
child_complexity = merged_max_complexity_for_scopes(query, composite_scopes, :legacy)
171251

172252
# This is the last composite scope visited; assume it's representative (for backwards compatibility).
173253
# Note: it would be more correct to score each composite scope and use the maximum possibility.

lib/graphql/execution/multiplex.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ def initialize(schema:, queries:, context:, max_complexity:)
3636
@tracers = schema.tracers + (context[:tracers] || [])
3737
@max_complexity = max_complexity
3838
@current_trace = context[:trace] ||= schema.new_trace(multiplex: self)
39+
@logger = nil
40+
end
41+
42+
def logger
43+
@logger ||= @schema.logger_for(context)
3944
end
4045
end
4146
end

lib/graphql/query.rb

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n
172172
@result_values = nil
173173
@executed = false
174174

175-
@logger = if context && context[:logger] == false
176-
Logger.new(IO::NULL)
177-
elsif context && (l = context[:logger])
178-
l
179-
else
180-
schema.default_logger
181-
end
175+
@logger = schema.logger_for(context)
182176
end
183177

184178
# If a document was provided to `GraphQL::Schema#execute` instead of the raw query string, we will need to get it from the document

lib/graphql/query/context.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def initialize(query:, schema: query.schema, values:)
5959
@scoped_context = ScopedContext.new(self)
6060
end
6161

62+
# Modify this hash to return extensions to client.
6263
# @return [Hash] A hash that will be added verbatim to the result hash, as `"extensions" => { ... }`
6364
def response_extensions
6465
namespace(:__query_result_extensions__)

lib/graphql/schema.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,18 @@ def default_logger(new_default_logger = NOT_CONFIGURED)
10661066
end
10671067
end
10681068

1069+
# @param context [GraphQL::Query::Context, nil]
1070+
# @return [Logger] A logger to use for this context configuration, falling back to {.default_logger}
1071+
def logger_for(context)
1072+
if context && context[:logger] == false
1073+
Logger.new(IO::NULL)
1074+
elsif context && (l = context[:logger])
1075+
l
1076+
else
1077+
default_logger
1078+
end
1079+
end
1080+
10691081
# @param new_context_class [Class<GraphQL::Query::Context>] A subclass to use when executing queries
10701082
def context_class(new_context_class = nil)
10711083
if new_context_class
@@ -1735,6 +1747,87 @@ def legacy_invalid_return_type_conflicts(query, type1, type2, node1, node2)
17351747
raise "Implement #{self}.legacy_invalid_return_type_conflicts to handle this invalid selection"
17361748
end
17371749

1750+
# The legacy complexity implementation included several bugs:
1751+
#
1752+
# - In some cases, it used the lexically _last_ field to determine a cost, instead of calculating the maximum among selections
1753+
# - In some cases, it called field complexity hooks repeatedly (when it should have only called them once)
1754+
#
1755+
# The future implementation may produce higher total complexity scores, so it's not active by default yet. You can opt into
1756+
# the future default behavior by configuring `:future` here. Or, you can choose a mode for each query with {.complexity_cost_calculation_mode_for}.
1757+
#
1758+
# The legacy mode is currently maintained alongside the future one, but it will be removed in a future GraphQL-Ruby version.
1759+
#
1760+
# If you choose `:compare`, you must also implement {.legacy_complexity_cost_calculation_mismatch} to handle the input somehow.
1761+
#
1762+
# @example Opting into the future calculation mode
1763+
# complexity_cost_calculation_mode(:future)
1764+
#
1765+
# @example Choosing the legacy mode (which will work until that mode is removed...)
1766+
# complexity_cost_calculation_mode(:legacy)
1767+
#
1768+
# @example Run both modes for every query, call {.legacy_complexity_cost_calculation_mismatch} when they don't match:
1769+
# complexity_cost_calculation_mode(:compare)
1770+
def complexity_cost_calculation_mode(new_mode = NOT_CONFIGURED)
1771+
if NOT_CONFIGURED.equal?(new_mode)
1772+
@complexity_cost_calculation_mode
1773+
else
1774+
@complexity_cost_calculation_mode = new_mode
1775+
end
1776+
end
1777+
1778+
# Implement this method to produce a per-query complexity cost calculation mode. (Technically, it's per-multiplex.)
1779+
#
1780+
# This is a way to check the compatibility of queries coming to your API without adding overhead of running `:compare`
1781+
# for every query. You could sample traffic, turn it off/on with feature flags, or anything else.
1782+
#
1783+
# @example Sampling traffic
1784+
# def self.complexity_cost_calculation_mode_for(_context)
1785+
# if rand < 0.1 # 10% of the time
1786+
# :compare
1787+
# else
1788+
# :legacy
1789+
# end
1790+
# end
1791+
#
1792+
# @example Using a feature flag to manage future mode
1793+
# def complexity_cost_calculation_mode_for(context)
1794+
# current_user = context[:current_user]
1795+
# if Flipper.enabled?(:future_complexity_cost, current_user)
1796+
# :future
1797+
# elsif rand < 0.5 # 50%
1798+
# :compare
1799+
# else
1800+
# :legacy
1801+
# end
1802+
# end
1803+
#
1804+
# @param context [Hash] The context for the currently-running {Execution::Multiplex} (which contains one or more queries)
1805+
# @return [:future] Use the new calculation algorithm -- may be higher than `:legacy`
1806+
# @return [:legacy] Use the legacy calculation algorithm, warts and all
1807+
# @return [:compare] Run both algorithms and call {.legacy_complexity_cost_calculation_mismatch} if they don't match
1808+
def complexity_cost_calculation_mode_for(multiplex_context)
1809+
complexity_cost_calculation_mode
1810+
end
1811+
1812+
# Implement this method in your schema to handle mismatches when `:compare` is used.
1813+
#
1814+
# @example Logging the mismatch
1815+
# def self.legacy_cost_calculation_mismatch(multiplex, future_cost, legacy_cost)
1816+
# client_id = multiplex.context[:api_client].id
1817+
# operation_names = multiplex.queries.map { |q| q.selected_operation_name || "anonymous" }.join(", ")
1818+
# Stats.increment(:complexity_mismatch, tags: { client: client_id, ops: operation_names })
1819+
# legacy_cost
1820+
# end
1821+
# @see Query::Context#add_error Adding an error to the response to notify the client
1822+
# @see Query::Context#response_extensions Adding key-value pairs to the response `"extensions" => { ... }`
1823+
# @param multiplex [GraphQL::Execution::Multiplex]
1824+
# @param future_complexity_cost [Integer]
1825+
# @param legacy_complexity_cost [Integer]
1826+
# @return [Integer] the cost to use for this query (probably one of `future_complexity_cost` or `legacy_complexity_cost`)
1827+
def legacy_complexity_cost_calculation_mismatch(multiplex, future_complexity_cost, legacy_complexity_cost)
1828+
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"
1829+
end
1830+
17381831
private
17391832

17401833
def add_trace_options_for(mode, new_options)

0 commit comments

Comments
 (0)