Skip to content

Commit 9ce5ba0

Browse files
committed
Document and test ActiveRecord::Promise usage
1 parent d93e2fb commit 9ce5ba0

File tree

10 files changed

+251
-12
lines changed

10 files changed

+251
-12
lines changed

.github/workflows/ci.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,17 @@ jobs:
4444
include:
4545
- gemfile: Gemfile
4646
ruby: 3.2
47-
- gemfile: gemfiles/rails_5.2.gemfile
48-
ruby: 2.7
4947
# Rails 6.1 is tested with Postgresql below
5048
- gemfile: gemfiles/rails_7.0.gemfile
5149
ruby: 3.1
5250
- gemfile: gemfiles/rails_master.gemfile
5351
ruby: 3.1
5452
- gemfile: gemfiles/rails_7.0.gemfile
5553
ruby: 3.2
54+
- gemfile: gemfiles/rails_7.1.gemfile
55+
ruby: 3.3
5656
- gemfile: gemfiles/rails_master.gemfile
57-
ruby: 3.2
57+
ruby: 3.3
5858
runs-on: ubuntu-latest
5959
steps:
6060
- run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV

gemfiles/rails_7.0.gemfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ gem "bootsnap"
66
gem "ruby-prof", platform: :ruby
77
gem "pry"
88
gem "pry-stack_explorer", platform: :ruby
9-
gem "rails", "~> 7.0", require: "rails/all"
9+
gem "rails", "~> 7.0.0", require: "rails/all"
1010
gem "sqlite3", "~> 1.4", platform: :ruby
1111
gem "activerecord-jdbcsqlite3-adapter", platform: :jruby
1212
gem "sequel"

gemfiles/rails_5.2.gemfile renamed to gemfiles/rails_7.1.gemfile

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ gem "bootsnap"
66
gem "ruby-prof", platform: :ruby
77
gem "pry"
88
gem "pry-stack_explorer", platform: :ruby
9-
gem "rails", "~> 5.2.0", require: "rails/all"
9+
gem "rails", "~> 7.1.0", require: "rails/all"
1010
gem "sqlite3", "~> 1.4", platform: :ruby
11+
gem "pg", platform: :ruby
1112
gem "activerecord-jdbcsqlite3-adapter", platform: :jruby
1213
gem "sequel"
14+
gem "evt"
15+
gem "async"
1316

1417
gemspec path: "../"

guides/dataloader/async_dataloader.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
layout: guide
33
search: true
44
section: Dataloader
5-
title: Parallel Data Loading for GraphQL
5+
title: Async Source Execution
66
desc: Using AsyncDataloader to fetch external data in parallel
77
index: 5
88
---
@@ -26,6 +26,8 @@ Now, {{ "GraphQL::Dataloader::AsyncDataloader" | api_doc }} will create `Async::
2626

2727
For a demonstration of this behavior, see: [https://github.com/rmosolgo/rails-graphql-async-demo](https://github.com/rmosolgo/rails-graphql-async-demo)
2828

29+
_You can also implement {% internal_link "manual parallelism", "/dataloader/parallelism" %} using `dataloader.yield`._
30+
2931
## Rails
3032

3133
For Rails, you'll need **Rails 7.1**, which properly supports fiber-based concurrency, and you'll also want to configure Rails to use Fibers for isolation:
@@ -36,3 +38,7 @@ class Application < Rails::Application
3638
config.active_support.isolation_level = :fiber
3739
end
3840
```
41+
42+
## Other Options
43+
44+
You can also manually implement parallelism with Dataloader. See the {% internal_link "Dataloader Parallelism", "/dataloader/parallelism" } guide for details.

guides/dataloader/overview.md

+7
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,10 @@ end
131131
## Data Sources
132132

133133
To implement batch-loading data sources, see the {% internal_link "Sources guide", "/dataloader/sources" %}.
134+
135+
## Parallelism
136+
137+
You can run I/O operations in parallel with GraphQL::Dataloader. There are two approaches:
138+
139+
- `AsyncDataloader` uses the `async` gem to automatically background I/O from `Dataloader::Source#fetch` calls. {% internal_link "Read More", "/dataloader/async_dataloader" %}
140+
- You can manually call `dataloader.yield` after starting work in the background. {% internal_link "Read More", "/dataloader/parallelism" %}

guides/dataloader/parallelism.md

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
layout: guide
3+
search: true
4+
section: Dataloader
5+
title: Manual Parallelism
6+
desc: Yield to Dataloader after starting work
7+
index: 7
8+
---
9+
10+
You can coordinate with {{ "GraphQL::Dataloader" | api_doc }} to run tasks in the background. To do this, call `dataloader.yield` inside `Source#fetch` after kicking off your task. For example:
11+
12+
```ruby
13+
def fetch(ids)
14+
# somehow queue up a background query,
15+
# see examples below
16+
future_result = async_query_for(ids)
17+
# return control to the dataloader
18+
dataloader.yield
19+
# dataloader will come back here
20+
# after calling other sources,
21+
# now wait for the value
22+
future_result.value
23+
end
24+
```
25+
26+
_Alternatively, you can use {% internal_link "AsyncDataloader", "/dataloader/async_dataloader" %} to automatically background I/O inside `Source#fetch` calls._
27+
28+
## Example: Rails load_async
29+
30+
You can use Rails's `load_async` method to load `ActiveRecord::Relation`s in the background. For example:
31+
32+
```ruby
33+
class Sources::AsyncRelationSource < GraphQL::Dataloader::Source
34+
def fetch(relations)
35+
relations.each(&:load_async) # start loading them in the background
36+
dataloader.yield # hand back to GraphQL::Dataloader
37+
relations.each(&:load) # now, wait for the result, returning the now-loaded relation
38+
end
39+
end
40+
```
41+
42+
You could call that source from a GraphQL field method:
43+
44+
```ruby
45+
field :direct_reports, [Person]
46+
47+
def direct_reports
48+
# prepare an ActiveRecord::Relation:
49+
direct_reports = Person.where(manager: object)
50+
# pass it off to the source:
51+
dataloader
52+
.with(Sources::AsyncRelationSource)
53+
.load(direct_reports)
54+
end
55+
```
56+
57+
## Example: Rails async calculations
58+
59+
In a Dataloader source, you can run Rails async calculations in the background while other work continues. For example:
60+
61+
```ruby
62+
class Sources::DirectReportsCount < GraphQL::Dataloader::Source
63+
def fetch(users)
64+
# Start the queries in the background:
65+
promises = users.map { |u| u.direct_reports.async_count }
66+
# Return to GraphQL::Dataloader:
67+
dataloader.yield
68+
# Now return the results, waiting if necessary:
69+
promises.map(&:value)
70+
end
71+
end
72+
```
73+
74+
Which could be used in a GraphQL field:
75+
76+
```ruby
77+
field :direct_reports_count, Int
78+
79+
def direct_reports_count
80+
dataloader.with(Sources::DirectReportsCount).load(object)
81+
end
82+
```
83+
84+
## Example: Concurrent::Future
85+
86+
You could use `concurrent-ruby` to put work in a background thread. For example, using `Concurrent::Future`:
87+
88+
```ruby
89+
class Sources::ExternalDataSource < GraphQL::Dataloader::Source
90+
def fetch(urls)
91+
# Start some I/O-intensive work:
92+
futures = urls.map do |url|
93+
Concurrent::Future.execute {
94+
# Somehow fetch and parse data:
95+
get_remote_json(url)
96+
}
97+
end
98+
# Yield back to GraphQL::Dataloader:
99+
dataloader.yield
100+
# Dataloader has done what it can,
101+
# so now return the value, waiting if necessary:
102+
futures.map(&:value)
103+
end
104+
end
105+
```

guides/dataloader/sources.md

+1-3
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,7 @@ def fetch(keys)
132132
end
133133
```
134134

135-
For a more robust asynchronous task primitive, check out [`Concurrent::Future`](http://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/Future.html).
136-
137-
Ruby 3.0 added built-in support for yielding Fibers that make I/O calls -- hopefully a future GraphQL-Ruby version will work with that!
135+
See the {% internal_link "parallelism guide", "/dataloader/parallelism" %} for details about this approach.
138136

139137
## Filling the Dataloader Cache
140138

spec/graphql/pagination/active_record_relation_connection_spec.rb

-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33

44
if testing_rails?
55
describe GraphQL::Pagination::ActiveRecordRelationConnection do
6-
class Food < ActiveRecord::Base
7-
end
8-
96
if Food.count == 0 # Backwards-compat version of `.none?`
107
ConnectionAssertions::NAMES.each { |n| Food.create!(name: n) }
118
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
4+
describe GraphQL::Dataloader do
5+
if defined?(ActiveRecord::Promise) && ENV['DATABASE'] == 'POSTGRESQL' # Rails 7.1+
6+
class RailsPromiseSchema < GraphQL::Schema
7+
class LoadAsyncSource < GraphQL::Dataloader::Source
8+
LOG = []
9+
def fetch(relations)
10+
relations.each do |rel|
11+
LOG << Time.now
12+
rel.load_async
13+
end
14+
dataloader.yield
15+
relations.each do |rel|
16+
rel.load
17+
LOG << Time.now
18+
end
19+
end
20+
end
21+
22+
class SleepSource < GraphQL::Dataloader::Source
23+
def initialize(duration)
24+
@duration = duration
25+
end
26+
27+
def fetch(durations)
28+
# puts "[#{Time.now.to_f}] Starting #{durations}"
29+
promise = ::Food.async_find_by_sql("SELECT pg_sleep(#{durations.first})")
30+
# puts "[#{Time.now.to_f}] Yielding #{durations}"
31+
dataloader.yield
32+
# puts "[#{Time.now.to_f}] Finishing #{durations}"
33+
promise.value
34+
durations
35+
end
36+
end
37+
class Query < GraphQL::Schema::Object
38+
field :sleep, Float do
39+
argument :duration, Float
40+
end
41+
42+
def sleep(duration:)
43+
dataloader.with(SleepSource, duration).load(duration)
44+
end
45+
46+
field :things, Integer do
47+
argument :first, Integer
48+
end
49+
50+
def things(first:)
51+
relation = Food
52+
.where(name: "Zucchini")
53+
.select("pg_sleep(0.3)")
54+
.limit(first)
55+
items = dataloader.with(LoadAsyncSource).load(relation)
56+
items.size
57+
end
58+
end
59+
60+
query(Query)
61+
use GraphQL::Dataloader
62+
end
63+
64+
before do
65+
Food.create!(name: "Zucchini")
66+
RailsPromiseSchema::LoadAsyncSource::LOG.clear
67+
end
68+
69+
after do
70+
Food.find_by(name: "Zucchini").destroy
71+
end
72+
73+
it "Supports async queries" do
74+
assert ActiveRecord::Base.connection.async_enabled?, "ActiveRecord must support real async queries"
75+
end
76+
77+
describe "using ActiveRecord::Promise for manual parallelism" do
78+
it "runs queries in parallel" do
79+
query_str = "
80+
{
81+
s1: sleep(duration: 0.1)
82+
s2: sleep(duration: 0.2)
83+
s3: sleep(duration: 0.3)
84+
}"
85+
t1 = Time.now
86+
result = RailsPromiseSchema.execute(query_str)
87+
t2 = Time.now
88+
assert_equal({ "s1" => 0.1, "s2" => 0.2, "s3" => 0.3}, result["data"])
89+
assert_in_delta 0.3, t2 - t1, 0.05, "Sleeps were in parallel"
90+
end
91+
end
92+
93+
describe "using load_async for parallelism" do
94+
it "runs queries in parallel" do
95+
query_str = "
96+
{
97+
t1: things(first: 5)
98+
t2: things(first: 10)
99+
t3: things(first: 100)
100+
}"
101+
t1 = Time.now
102+
result = RailsPromiseSchema.execute(query_str)
103+
t2 = Time.now
104+
105+
load_async_1, load_async_2, load_async_3, load_1, load_2, load_3 = RailsPromiseSchema::LoadAsyncSource::LOG
106+
assert_in_delta load_async_1, load_async_2, 0.05, "load_async happened first"
107+
assert_in_delta load_async_1, load_async_3, 0.05, "the third load_async happened right after"
108+
109+
assert_in_delta load_async_1, load_1, 0.35, "load came 0.3s after"
110+
assert_in_delta load_1, load_2, 0.05, "the second load didn't have to wait because it was already done"
111+
assert_in_delta load_1, load_3, 0.05, "the third load didn't have to wait because it was already done"
112+
113+
assert_equal({ "t1" => 1, "t2" => 1, "t3" => 1}, result["data"])
114+
assert_in_delta 0.3, t2 - t1, 0.05, "Sleeps were in parallel"
115+
end
116+
end
117+
end
118+
end

spec/support/active_record_setup.rb

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# Remove the old sqlite database
44
`rm -f ./_test_.db`
55

6+
ActiveRecord.async_query_executor ||= :global_thread_pool
7+
68
# platform helper
79
def jruby?
810
RUBY_ENGINE == 'jruby'
@@ -47,4 +49,7 @@ def jruby?
4749
t.column :name, :string
4850
end
4951
end
52+
53+
class Food < ActiveRecord::Base
54+
end
5055
end

0 commit comments

Comments
 (0)