Skip to content

Commit

Permalink
Merge pull request #1394 from rmosolgo/interpreter
Browse files Browse the repository at this point in the history
Interpreter
  • Loading branch information
Robert Mosolgo authored Oct 5, 2018
2 parents cbcac61 + b6767d6 commit edf4946
Show file tree
Hide file tree
Showing 90 changed files with 2,298 additions and 579 deletions.
23 changes: 23 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ matrix:
gemfile: spec/dummy/Gemfile
script:
- cd spec/dummy && bundle exec rails test:system
- env:
- DISPLAY=':99.0'
- TESTING_INTERPRETER=yes
rvm: 2.4.3
addons:
apt:
sources:
- google-chrome
packages:
- google-chrome-stable
before_install:
- export CHROMEDRIVER_VERSION=`curl -s http://chromedriver.storage.googleapis.com/LATEST_RELEASE`
- curl -L -O "http://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip"
- unzip chromedriver_linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin
before_script:
- sh -e /etc/init.d/xvfb start
gemfile: spec/dummy/Gemfile
script:
- cd spec/dummy && bundle exec rails test:system
- env:
- TESTING_INTERPRETER=yes
rvm: 2.4.3
gemfile: gemfiles/rails_5.2.gemfile
- rvm: 2.2.8
gemfile: Gemfile
- rvm: 2.2.8
Expand Down
21 changes: 10 additions & 11 deletions benchmark/run.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
# frozen_string_literal: true
require "dummy/schema"
TESTING_INTERPRETER = true
require "graphql"
require "jazz"
require "benchmark/ips"
require 'ruby-prof'
require 'memory_profiler'
require "ruby-prof"
require "memory_profiler"

module GraphQLBenchmark
QUERY_STRING = GraphQL::Introspection::INTROSPECTION_QUERY
DOCUMENT = GraphQL.parse(QUERY_STRING)
SCHEMA = Dummy::Schema
SCHEMA = Jazz::Schema

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


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

module_function
def self.run(task)

Benchmark.ips do |x|
case task
when "query"
Expand All @@ -40,15 +40,14 @@ def self.profile
# Warm up any caches:
SCHEMA.execute(document: DOCUMENT)
# CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS)

res = nil
result = RubyProf.profile do
# CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS)
SCHEMA.execute(document: DOCUMENT)
res = SCHEMA.execute(document: DOCUMENT)
end

printer = RubyProf::FlatPrinter.new(result)
# printer = RubyProf::FlatPrinter.new(result)
# printer = RubyProf::GraphHtmlPrinter.new(result)
# printer = RubyProf::FlatPrinterWithLineNumbers.new(result)
printer = RubyProf::FlatPrinterWithLineNumbers.new(result)

printer.print(STDOUT, {})
end
Expand Down
100 changes: 100 additions & 0 deletions guides/queries/interpreter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
title: Interpreter
layout: guide
doc_stub: false
search: true
section: Queries
desc: A New Runtime for GraphQL-Ruby
experimental: true
index: 11
---

GraphQL-Ruby 1.9.0 includes a new runtime module which you may use for your schema. Eventually, it will become the default.

It's called `GraphQL::Execute::Interpreter` and you can hook it up with `use ...` in your schema class:

```ruby
class MySchema < GraphQL::Schema
use GraphQL::Execute::Interpreter
end
```

Read on to learn more!

## Rationale

The new runtime was added to address a few specific concerns:

- __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.
- __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.
- __Extensibility__: Although the GraphQL specification supports custom directives, GraphQL-Ruby didn't have a good way to build them.

## Installation

You can opt in to the interpreter in your schema class:

```ruby
class MySchema < GraphQL::Schema
use GraphQL::Execution::Interpreter
end
```

If you have a subscription root type, it will also need an update. Extend this new module:

```ruby
class Types::Subscription < Types::BaseObject
# Extend this module to support subscription root fields with Interpreter
extend GraphQL::Subscriptions::SubscriptionRoot
end
```

Some Relay configurations must be updated too. For example:

```diff
- field :node, field: GraphQL::Relay::Node.field
+ add_field(GraphQL::Types::Relay::NodeField)
```

(Alternatively, consider implementing `Query.node` in your own app, using `NodeField` as inspiration.)

## Compatibility

The new runtime works with class-based schemas only. Several features are no longer supported:

- Proc-dependent field features:

- Field Instrumentation
- Middleware
- Resolve procs

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" %}.

- Query analyzers and `irep_node`s

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.

`irep_node`-based lookahead is not supported. Stay tuned for a replacement.

- `rescue_from`

This was built on middleware, which is not supported anymore. Stay tuned for a replacement.

- `.graphql_definition` and `def to_graphql`

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.

Maybe this section should have been called _incompatibility_ 🤔.

## Extending the Runtime

🚧 👷🚧

The internals aren't clean enough to build on yet. Stay tuned.

## Implementation Notes

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.

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.

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.
2 changes: 1 addition & 1 deletion lib/graphql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ def self.scan_with_ragel(graphql_string)
require "graphql/language"
require "graphql/analysis"
require "graphql/tracing"
require "graphql/execution"
require "graphql/schema"
require "graphql/execution"
require "graphql/types"
require "graphql/relay"
require "graphql/boolean_type"
Expand Down
5 changes: 5 additions & 0 deletions lib/graphql/argument.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ def expose_as
@expose_as ||= (@as || @name).to_s
end

# Backport this to support legacy-style directives
def keyword
@keyword ||= GraphQL::Schema::Member::BuildType.underscore(expose_as).to_sym
end

# @param value [Object] The incoming value from variables or query string literal
# @param ctx [GraphQL::Query::Context]
# @return [Object] The prepared `value` for this argument or `value` itself if no `prepare` function exists.
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/execution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "graphql/execution/execute"
require "graphql/execution/flatten"
require "graphql/execution/instrumentation"
require "graphql/execution/interpreter"
require "graphql/execution/lazy"
require "graphql/execution/multiplex"
require "graphql/execution/typecast"
19 changes: 18 additions & 1 deletion lib/graphql/execution/execute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ def execute(ast_operation, root_type, query)
GraphQL::Execution::Flatten.call(query.context)
end

def self.begin_multiplex(_multiplex)
end

def self.begin_query(query, _multiplex)
ExecutionFunctions.resolve_root_selection(query)
end

def self.finish_multiplex(results, multiplex)
ExecutionFunctions.lazy_resolve_root_selection(results, multiplex: multiplex)
end

def self.finish_query(query, _multiplex)
{
"data" => Execution::Flatten.call(query.context)
}
end

# @api private
module ExecutionFunctions
module_function
Expand Down Expand Up @@ -179,7 +196,7 @@ def continue_resolve_field(raw_value, field_type, field_ctx)
if list_errors.any?
list_errors.each do |error, index|
error.ast_node = field_ctx.ast_node
error.path = field_ctx.path + [index]
error.path = field_ctx.path + (field_ctx.type.list? ? [index] : [])
query.context.errors.push(error)
end
end
Expand Down
89 changes: 89 additions & 0 deletions lib/graphql/execution/interpreter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# frozen_string_literal: true
require "graphql/execution/interpreter/execution_errors"
require "graphql/execution/interpreter/hash_response"
require "graphql/execution/interpreter/runtime"

module GraphQL
module Execution
class Interpreter
def initialize
# A buffer shared by all queries running in this interpreter
@lazies = []
end

# Support `Executor` :S
def execute(_operation, _root_type, query)
runtime = evaluate(query)
sync_lazies(query: query)
runtime.final_value
end

def self.use(schema_defn)
schema_defn.query_execution_strategy(GraphQL::Execution::Interpreter)
schema_defn.mutation_execution_strategy(GraphQL::Execution::Interpreter)
schema_defn.subscription_execution_strategy(GraphQL::Execution::Interpreter)
end

def self.begin_multiplex(multiplex)
# Since this is basically the batching context,
# share it for a whole multiplex
multiplex.context[:interpreter_instance] ||= self.new
end

def self.begin_query(query, multiplex)
# The batching context is shared by the multiplex,
# so fetch it out and use that instance.
interpreter =
query.context.namespace(:interpreter)[:interpreter_instance] =
multiplex.context[:interpreter_instance]
interpreter.evaluate(query)
query
end

def self.finish_multiplex(_results, multiplex)
interpreter = multiplex.context[:interpreter_instance]
interpreter.sync_lazies(multiplex: multiplex)
end

def self.finish_query(query, _multiplex)
{
"data" => query.context.namespace(:interpreter)[:runtime].final_value
}
end

def evaluate(query)
query.context.interpreter = true
# Although queries in a multiplex _share_ an Interpreter instance,
# they also have another item of state, which is private to that query
# in particular, assign it here:
runtime = Runtime.new(
query: query,
lazies: @lazies,
response: HashResponse.new,
)
query.context.namespace(:interpreter)[:runtime] = runtime

query.trace("execute_query", {query: query}) do
runtime.run_eager
end

runtime
end

def sync_lazies(query: nil, multiplex: nil)
tracer = query || multiplex
if query.nil? && multiplex.queries.length == 1
query = multiplex.queries[0]
end
tracer.trace("execute_query_lazy", {multiplex: multiplex, query: query}) do
while @lazies.any?
next_wave = @lazies.dup
@lazies.clear
# This will cause a side-effect with `.write(...)`
next_wave.each(&:value)
end
end
end
end
end
end
29 changes: 29 additions & 0 deletions lib/graphql/execution/interpreter/execution_errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module GraphQL
module Execution
class Interpreter
class ExecutionErrors
def initialize(ctx, ast_node, path)
@context = ctx
@ast_node = ast_node
@path = path
end

def add(err_or_msg)
err = case err_or_msg
when String
GraphQL::ExecutionError.new(err_or_msg)
when GraphQL::ExecutionError
err_or_msg
else
raise ArgumentError, "expected String or GraphQL::ExecutionError, not #{err_or_msg.class} (#{err_or_msg.inspect})"
end
err.ast_node ||= @ast_node
err.path ||= @path
@context.add_error(err)
end
end
end
end
end
Loading

0 comments on commit edf4946

Please sign in to comment.