Skip to content

Commit edf4946

Browse files
author
Robert Mosolgo
authored
Merge pull request #1394 from rmosolgo/interpreter
Interpreter
2 parents cbcac61 + b6767d6 commit edf4946

File tree

90 files changed

+2298
-579
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

90 files changed

+2298
-579
lines changed

.travis.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ matrix:
4040
gemfile: spec/dummy/Gemfile
4141
script:
4242
- cd spec/dummy && bundle exec rails test:system
43+
- env:
44+
- DISPLAY=':99.0'
45+
- TESTING_INTERPRETER=yes
46+
rvm: 2.4.3
47+
addons:
48+
apt:
49+
sources:
50+
- google-chrome
51+
packages:
52+
- google-chrome-stable
53+
before_install:
54+
- export CHROMEDRIVER_VERSION=`curl -s http://chromedriver.storage.googleapis.com/LATEST_RELEASE`
55+
- curl -L -O "http://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip"
56+
- unzip chromedriver_linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin
57+
before_script:
58+
- sh -e /etc/init.d/xvfb start
59+
gemfile: spec/dummy/Gemfile
60+
script:
61+
- cd spec/dummy && bundle exec rails test:system
62+
- env:
63+
- TESTING_INTERPRETER=yes
64+
rvm: 2.4.3
65+
gemfile: gemfiles/rails_5.2.gemfile
4366
- rvm: 2.2.8
4467
gemfile: Gemfile
4568
- rvm: 2.2.8

benchmark/run.rb

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
# frozen_string_literal: true
2-
require "dummy/schema"
2+
TESTING_INTERPRETER = true
3+
require "graphql"
4+
require "jazz"
35
require "benchmark/ips"
4-
require 'ruby-prof'
5-
require 'memory_profiler'
6+
require "ruby-prof"
7+
require "memory_profiler"
68

79
module GraphQLBenchmark
810
QUERY_STRING = GraphQL::Introspection::INTROSPECTION_QUERY
911
DOCUMENT = GraphQL.parse(QUERY_STRING)
10-
SCHEMA = Dummy::Schema
12+
SCHEMA = Jazz::Schema
1113

1214
BENCHMARK_PATH = File.expand_path("../", __FILE__)
1315
CARD_SCHEMA = GraphQL::Schema.from_definition(File.read(File.join(BENCHMARK_PATH, "schema.graphql")))
1416
ABSTRACT_FRAGMENTS = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments.graphql")))
1517
ABSTRACT_FRAGMENTS_2 = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments_2.graphql")))
1618

17-
1819
BIG_SCHEMA = GraphQL::Schema.from_definition(File.join(BENCHMARK_PATH, "big_schema.graphql"))
1920
BIG_QUERY = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "big_query.graphql")))
2021

2122
module_function
2223
def self.run(task)
23-
2424
Benchmark.ips do |x|
2525
case task
2626
when "query"
@@ -40,15 +40,14 @@ def self.profile
4040
# Warm up any caches:
4141
SCHEMA.execute(document: DOCUMENT)
4242
# CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS)
43-
43+
res = nil
4444
result = RubyProf.profile do
4545
# CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS)
46-
SCHEMA.execute(document: DOCUMENT)
46+
res = SCHEMA.execute(document: DOCUMENT)
4747
end
48-
49-
printer = RubyProf::FlatPrinter.new(result)
48+
# printer = RubyProf::FlatPrinter.new(result)
5049
# printer = RubyProf::GraphHtmlPrinter.new(result)
51-
# printer = RubyProf::FlatPrinterWithLineNumbers.new(result)
50+
printer = RubyProf::FlatPrinterWithLineNumbers.new(result)
5251

5352
printer.print(STDOUT, {})
5453
end

guides/queries/interpreter.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
title: Interpreter
3+
layout: guide
4+
doc_stub: false
5+
search: true
6+
section: Queries
7+
desc: A New Runtime for GraphQL-Ruby
8+
experimental: true
9+
index: 11
10+
---
11+
12+
GraphQL-Ruby 1.9.0 includes a new runtime module which you may use for your schema. Eventually, it will become the default.
13+
14+
It's called `GraphQL::Execute::Interpreter` and you can hook it up with `use ...` in your schema class:
15+
16+
```ruby
17+
class MySchema < GraphQL::Schema
18+
use GraphQL::Execute::Interpreter
19+
end
20+
```
21+
22+
Read on to learn more!
23+
24+
## Rationale
25+
26+
The new runtime was added to address a few specific concerns:
27+
28+
- __Validation Performance__: The previous runtime depended on a preparation step (`GraphQL::InternalRepresentation::Rewrite`) which could be very slow in some cases. In many cases, the overhead of that step provided no value.
29+
- __Runtime Performance__: For very large results, the previous runtime was slow because it allocated a new `ctx` object for every field, even very simple fields that didn't need any special tracking.
30+
- __Extensibility__: Although the GraphQL specification supports custom directives, GraphQL-Ruby didn't have a good way to build them.
31+
32+
## Installation
33+
34+
You can opt in to the interpreter in your schema class:
35+
36+
```ruby
37+
class MySchema < GraphQL::Schema
38+
use GraphQL::Execution::Interpreter
39+
end
40+
```
41+
42+
If you have a subscription root type, it will also need an update. Extend this new module:
43+
44+
```ruby
45+
class Types::Subscription < Types::BaseObject
46+
# Extend this module to support subscription root fields with Interpreter
47+
extend GraphQL::Subscriptions::SubscriptionRoot
48+
end
49+
```
50+
51+
Some Relay configurations must be updated too. For example:
52+
53+
```diff
54+
- field :node, field: GraphQL::Relay::Node.field
55+
+ add_field(GraphQL::Types::Relay::NodeField)
56+
```
57+
58+
(Alternatively, consider implementing `Query.node` in your own app, using `NodeField` as inspiration.)
59+
60+
## Compatibility
61+
62+
The new runtime works with class-based schemas only. Several features are no longer supported:
63+
64+
- Proc-dependent field features:
65+
66+
- Field Instrumentation
67+
- Middleware
68+
- Resolve procs
69+
70+
All these depend on the memory- and time-hungry per-field `ctx` object. To improve performance, only method-based resolves are supported. If need something from `ctx`, you can get it with the `extras: [...]` configuration option. To wrap resolve behaviors, try {% internal_link "Field Extensions", "/type_definitions/field_extensions" %}.
71+
72+
- Query analyzers and `irep_node`s
73+
74+
These depend on the now-removed `Rewrite` step, which wasted a lot of time making often-unneeded preparation. Most of the attributes you might need from an `irep_node` are available with `extras: [...]`. Query analyzers can be refactored to be static checks (custom validation rules) or dynamic checks, made at runtime. The built-in analyzers have been refactored to run as validators.
75+
76+
`irep_node`-based lookahead is not supported. Stay tuned for a replacement.
77+
78+
- `rescue_from`
79+
80+
This was built on middleware, which is not supported anymore. Stay tuned for a replacement.
81+
82+
- `.graphql_definition` and `def to_graphql`
83+
84+
The interpreter uses class-based schema definitions only, and never converts them to legacy GraphQL definition objects. Any custom definitions to GraphQL objects should be re-implemented on custom base classes.
85+
86+
Maybe this section should have been called _incompatibility_ 🤔.
87+
88+
## Extending the Runtime
89+
90+
🚧 👷🚧
91+
92+
The internals aren't clean enough to build on yet. Stay tuned.
93+
94+
## Implementation Notes
95+
96+
Instead of a tree of `irep_nodes`, the interpreter consumes the AST directly. This removes a complicated concept from GraphQL-Ruby (`irep_node`s) and simplifies the query lifecycle. The main difference relates to how fragment spreads are resolved. In the previous runtime, the possible combinations of fields for a given object were calculated ahead of time, then some of those combinations were used during runtime, but many of them may not have been. In the new runtime, no precalculation is made; instead each object is checked against each fragment at runtime.
97+
98+
Instead of creating a `GraphQL::Query::Context::FieldResolutionContext` for _every_ field in the response, the interpreter uses long-lived, mutable objects for execution bookkeeping. This is more complicated to manage, since the changes to those objects can be hard to predict, but it's worth it for the performance gain. When needed, those bookkeeping objects can be "forked", so that two parts of an operation can be resolved independently.
99+
100+
Instead of calling `.to_graphql` internally to convert class-based definitions to `.define`-based definitions, the interpreter operates on class-based definitions directly. This simplifies the workflow for creating custom configurations and using them at runtime.

lib/graphql.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ def self.scan_with_ragel(graphql_string)
6666
require "graphql/language"
6767
require "graphql/analysis"
6868
require "graphql/tracing"
69-
require "graphql/execution"
7069
require "graphql/schema"
70+
require "graphql/execution"
7171
require "graphql/types"
7272
require "graphql/relay"
7373
require "graphql/boolean_type"

lib/graphql/argument.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ def expose_as
8989
@expose_as ||= (@as || @name).to_s
9090
end
9191

92+
# Backport this to support legacy-style directives
93+
def keyword
94+
@keyword ||= GraphQL::Schema::Member::BuildType.underscore(expose_as).to_sym
95+
end
96+
9297
# @param value [Object] The incoming value from variables or query string literal
9398
# @param ctx [GraphQL::Query::Context]
9499
# @return [Object] The prepared `value` for this argument or `value` itself if no `prepare` function exists.

lib/graphql/execution.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "graphql/execution/execute"
44
require "graphql/execution/flatten"
55
require "graphql/execution/instrumentation"
6+
require "graphql/execution/interpreter"
67
require "graphql/execution/lazy"
78
require "graphql/execution/multiplex"
89
require "graphql/execution/typecast"

lib/graphql/execution/execute.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ def execute(ast_operation, root_type, query)
2424
GraphQL::Execution::Flatten.call(query.context)
2525
end
2626

27+
def self.begin_multiplex(_multiplex)
28+
end
29+
30+
def self.begin_query(query, _multiplex)
31+
ExecutionFunctions.resolve_root_selection(query)
32+
end
33+
34+
def self.finish_multiplex(results, multiplex)
35+
ExecutionFunctions.lazy_resolve_root_selection(results, multiplex: multiplex)
36+
end
37+
38+
def self.finish_query(query, _multiplex)
39+
{
40+
"data" => Execution::Flatten.call(query.context)
41+
}
42+
end
43+
2744
# @api private
2845
module ExecutionFunctions
2946
module_function
@@ -179,7 +196,7 @@ def continue_resolve_field(raw_value, field_type, field_ctx)
179196
if list_errors.any?
180197
list_errors.each do |error, index|
181198
error.ast_node = field_ctx.ast_node
182-
error.path = field_ctx.path + [index]
199+
error.path = field_ctx.path + (field_ctx.type.list? ? [index] : [])
183200
query.context.errors.push(error)
184201
end
185202
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
require "graphql/execution/interpreter/execution_errors"
3+
require "graphql/execution/interpreter/hash_response"
4+
require "graphql/execution/interpreter/runtime"
5+
6+
module GraphQL
7+
module Execution
8+
class Interpreter
9+
def initialize
10+
# A buffer shared by all queries running in this interpreter
11+
@lazies = []
12+
end
13+
14+
# Support `Executor` :S
15+
def execute(_operation, _root_type, query)
16+
runtime = evaluate(query)
17+
sync_lazies(query: query)
18+
runtime.final_value
19+
end
20+
21+
def self.use(schema_defn)
22+
schema_defn.query_execution_strategy(GraphQL::Execution::Interpreter)
23+
schema_defn.mutation_execution_strategy(GraphQL::Execution::Interpreter)
24+
schema_defn.subscription_execution_strategy(GraphQL::Execution::Interpreter)
25+
end
26+
27+
def self.begin_multiplex(multiplex)
28+
# Since this is basically the batching context,
29+
# share it for a whole multiplex
30+
multiplex.context[:interpreter_instance] ||= self.new
31+
end
32+
33+
def self.begin_query(query, multiplex)
34+
# The batching context is shared by the multiplex,
35+
# so fetch it out and use that instance.
36+
interpreter =
37+
query.context.namespace(:interpreter)[:interpreter_instance] =
38+
multiplex.context[:interpreter_instance]
39+
interpreter.evaluate(query)
40+
query
41+
end
42+
43+
def self.finish_multiplex(_results, multiplex)
44+
interpreter = multiplex.context[:interpreter_instance]
45+
interpreter.sync_lazies(multiplex: multiplex)
46+
end
47+
48+
def self.finish_query(query, _multiplex)
49+
{
50+
"data" => query.context.namespace(:interpreter)[:runtime].final_value
51+
}
52+
end
53+
54+
def evaluate(query)
55+
query.context.interpreter = true
56+
# Although queries in a multiplex _share_ an Interpreter instance,
57+
# they also have another item of state, which is private to that query
58+
# in particular, assign it here:
59+
runtime = Runtime.new(
60+
query: query,
61+
lazies: @lazies,
62+
response: HashResponse.new,
63+
)
64+
query.context.namespace(:interpreter)[:runtime] = runtime
65+
66+
query.trace("execute_query", {query: query}) do
67+
runtime.run_eager
68+
end
69+
70+
runtime
71+
end
72+
73+
def sync_lazies(query: nil, multiplex: nil)
74+
tracer = query || multiplex
75+
if query.nil? && multiplex.queries.length == 1
76+
query = multiplex.queries[0]
77+
end
78+
tracer.trace("execute_query_lazy", {multiplex: multiplex, query: query}) do
79+
while @lazies.any?
80+
next_wave = @lazies.dup
81+
@lazies.clear
82+
# This will cause a side-effect with `.write(...)`
83+
next_wave.each(&:value)
84+
end
85+
end
86+
end
87+
end
88+
end
89+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL
4+
module Execution
5+
class Interpreter
6+
class ExecutionErrors
7+
def initialize(ctx, ast_node, path)
8+
@context = ctx
9+
@ast_node = ast_node
10+
@path = path
11+
end
12+
13+
def add(err_or_msg)
14+
err = case err_or_msg
15+
when String
16+
GraphQL::ExecutionError.new(err_or_msg)
17+
when GraphQL::ExecutionError
18+
err_or_msg
19+
else
20+
raise ArgumentError, "expected String or GraphQL::ExecutionError, not #{err_or_msg.class} (#{err_or_msg.inspect})"
21+
end
22+
err.ast_node ||= @ast_node
23+
err.path ||= @path
24+
@context.add_error(err)
25+
end
26+
end
27+
end
28+
end
29+
end

0 commit comments

Comments
 (0)