pipeloader
Cut ActiveRecord N+1 on both axes, round trips and query count, with plain models and no
dataloader.load keys. The pieces compose:
use Pipeloaderroutes everySELECTin a graphql-ruby response through a libpq pipeline, so a nested query resolves in roughly one round trip per tree level. Plain resolvers, no Futures, no field changes.auto_fusecollapses each level's per-record lookups into oneWHERE key = ANY($1), dropping query count to DataLoader's.Pipeloader::Batchbrings that batching to plain ActiveRecord (batch_has_many,batch_belongs_to) for jobs, serializers, and other non-GraphQL paths.
Adopt them together: a fused or batch-loaded query is itself pipelined, so you get DataLoader-class query counts and one round trip per level at once.
Adopting it
These compose; adopt the ones your app needs. A query gathered by fusion or a batch loader is itself pipelined, so you get few queries and few round trips together.
Pipelining
One line. Types and resolvers stay ordinary ActiveRecord:
class AppSchema < GraphQL::Schema
use Pipeloader
end
class Types::Post < GraphQL::Schema::Object
field :title, String, null: false
field :author, Types::Author, null: false # resolves via post.author
field :comments, [Types::Comment], null: false # resolves via post.comments
end
Any AR SELECT issued while building the response (a belongs_to, a has_many, a
.where(...) in a hand-written resolver) is intercepted and pipelined. It hooks AR's
query path rather than the GraphQL field, so nothing leaks back to synchronous N+1, even
from custom resolver code. By default the pipeline fetches whole rows.
Field-exact projection (opt-in)
Set field_exact and each SELECT narrows to the columns the query selected, using
graphql-ruby's lookahead:
Pipeloader.field_exact = true # globally, before your types load, or
class Types::Post < GraphQL::Schema::Object
pipeloader_field_exact! # per type
field :title, String, null: false
field :author, Types::Author, null: false
end
For { posts { title author { name } } } the posts SELECT becomes
SELECT id, title, author_id FROM ... (primary key, selected column, and the FK needed
for author), and the authors SELECT becomes SELECT id, name FROM ....
Projection narrows only when it can prove every selected field reads a known column or
association. If a selection is opaque (a computed field, a custom resolver, anything it
can't map to a column) it falls back to a whole-row fetch for that record, so a projected
field never raises MissingAttributeError. A computed field can declare the columns it
reads with selects:, so projection keeps them:
field :excerpt, String, null: false, selects: %i[body]
def excerpt = object.body[0, 200]
With no opt-in, selects: is accepted and ignored and every SELECT is whole-row.
Auto-fuse (opt-in)
Field-exact also fuses: the per-record belongs_to / has_one / has_many lookups on a
level collapse into one WHERE key = ANY($1) (DataLoader-class server cost, still
pipelined, so round trips stay at the tree depth). To get that fusion whole-row, with no
projection and no resolver code, set auto_fuse:
Pipeloader.auto_fuse = true # before your types load
A plain object.author / object.comments now fuses automatically. It fuses only when
the demux is provably unambiguous (a unique primary key for belongs_to, a unique FK
index for has_one, a bare unscoped has_many). Anything else (scopes, chained
order/limit, polymorphic, custom resolvers, SQLite) falls back to the plain pipelined
load. Results are byte-identical to the un-fused path.
Batch loaders
The same gathering for plain ActiveRecord, for the jobs, serializers, and non-GraphQL
endpoints the resolvers don't cover. Include the concern and swap has_many for
batch_has_many (or belongs_to for batch_belongs_to):
class Author < ApplicationRecord
include Pipeloader::Batch::Model
batch_has_many :books
end
Author.all.to_a.each { |a| a.books.to_a } # one query for everyone's books
a.books loads for every author loaded alongside it on first access, as one IN query via
AR's Preloader, with no setup: the sibling group is stamped onto the records as they load.
Inside a use Pipeloader response those batched queries are pipelined too. Full surface (the
chainable proxy, counts and aggregates, the general batch macro) in
Batch loaders for plain ActiveRecord below.
What it does
example/run.rb, plain resolvers against a seeded database:
{ posts(limit: 50) { title author { name } comments { body commenter { name } } } }
resolved 50 posts with PLAIN AR resolvers
pipeline round-trips: 3
queries pipelined: 403
naive N+1 would be: ~594 round trips
Three round trips: posts, then authors and comments, then commenters. The to-one
author and the to-many comments are different shapes at the same level but collapse
into one round trip.
How it works
use GraphQL::Dataloaderruns resolution in fibers, so a synchronous-lookingpost.authorcan yield instead of blocking and sibling queries gather before anything hits the wire.- A monkey-patch on
select_allhands each SELECT to a Dataloader source instead of running it. The active dataloader is stashed on the connection for the multiplex (and cleared after), so the patch finds it asself. - When the fibers park, the source prepares each distinct query shape (once per request,
reused across bursts), Bind/Executes every gathered query in one libpq burst
(
enter_pipeline_modetopipeline_sync), and returns anActiveRecord::Resultper query so AR builds models normally.
Prepared statements are scoped to the request. The next request's first burst
DEALLOCATEs the previous one's, piggybacked into the same pipeline so cleanup costs no
extra round trip, so no plan goes stale across a reconnect or migration. If a query
errors, the burst is drained to its sync point, the connection is restored, and the error
is raised rather than swallowed.
Benchmark
A wide GraphQL query (10 issues, each fanning out to assignee, creator, project, parent, and comments, those nesting to team, lead, and authors), resolved against Postgres at a realistic 5 ms network RTT (app and primary DB in different AZs through a pooler) via a local TCP proxy. Min of 3 iterations; your numbers will vary.
| approach | time | round-trips |
|---|---|---|
| naive (N+1) | 1160 ms | 164 |
AR includes (hand-written) |
83 ms | 11 |
GraphQL::Dataloader |
56 ms | 7 |
| pipeloader | 62 ms | 3 |
pipeloader (auto_fuse) |
46 ms | 3 |
Against the N+1 you have, pipeloader turns 164 round trips into 3 with no resolver code, about 25x faster than naive at this latency.
Against batching, latency multiplies round trips, and pipeloader does the fewest: 3, the
tree depth, where includes and GraphQL::Dataloader run a separate IN query per
association (7 to 11). The transparent path does more server work (N point queries) for
those few round trips and lands close to Dataloader. auto_fuse fuses each level into one
WHERE key = ANY($1), getting Dataloader's server work and pipeloader's round trips, and
comes out fastest.
GraphQL::Dataloader needs a source and a .load per association; includes is
hand-written per query. pipeloader is use Pipeloader, plus one flag for auto_fuse.
Run it: ruby example/bench_wide.rb (needs the seeded graphql_experiment DB).
Batch loaders for plain ActiveRecord
The full surface behind the quick-start above.
class Author < ApplicationRecord
include Pipeloader::Batch::Model
batch_has_many :books # chainable, batched
batch_has_one :profile
batch_belongs_to :publisher
end
batch_has_many / batch_has_one / batch_belongs_to declare a real AR association and
accept everything the matching macro does (a scope, class_name:, foreign_key:, and so
on). batch_belongs_to and batch_has_one return native records, batched the first time
any sibling's target is read. batch_has_many returns a lazy, chainable proxy whose
where / order / limit apply inside the one batched query (limit and offset are
per-owner, top-N per group):
.each { |a| a.books.where(published: true).to_a } # filter pushed down, one query
.each { |a| a.books.order(pages: :desc).limit(3) } # each author's 3 longest, one query
The proxy covers the common read surface (where, order, limit, select, pluck,
find_by, exists?, and Enumerable). Only writes (<<, create, build, ...)
delegate to the real association; any read it doesn't implement raises NoMethodError
rather than silently issuing a per-record query.
Counts and aggregates batch into a single GROUP BY:
batch_count :books_count # Integer, default 0
batch_aggregate :total_pages, of: :books, function: :sum, column: :pages
batch_aggregate :longest, of: :books, function: :maximum, column: :pages
For anything that isn't a plain association (an existence or viewer-scoped flag, a lookup
by a non-PK column, a derived value) the general batch macro takes a loader returning a
{ key => value } Hash, run once across all siblings:
batch :viewer_has_starred, default: false do |book_ids|
Star.where(user_id: Current.user.id, book_id: book_ids).pluck(:book_id).index_with(true)
end
DATALOADERS.md puts the common GraphQL::Dataloader sources
(record-by-id, has-many, count, by-column, existence, derived) side by side with their
batch-loader equivalents.
Siblings are the records loaded by the same query, and the group rides on the records, so
batching needs no setup and is correct across threads, fibers, GraphQL::Dataloader, and
fiber-per-request servers alike. Records loaded on their own (a find, a separate query)
form their own group and don't cross-batch. has_many / has_one / belongs_to (including
:through and polymorphic), counts, and aggregates are covered; the has_many proxy is
read-only.
Status and caveats
A proof of concept.
- Whole rows by default; field-exact is opt-in. Off, AR picks the columns; on, the pipeline projects to the selected columns and falls back to whole rows on anything opaque.
- PostgreSQL pipelines, SQLite narrows only, anything else raises. Pipelining is
libpq-specific. On SQLite, queries run un-pipelined (the opt-in projection still
applies), which is safe because SQLite is embedded: its in-process queries have no round
trip to collapse, and N+1 there is just cheap local calls. Any other adapter raises a
RuntimeErrorat query time rather than misbehaving silently. - Reads only. It intercepts
select_all(SELECTs); writes and non-SELECTs pass through, and queries inside an open transaction are skipped. - Assumes thread-isolated connections (the ActiveRecord default): a request's resolver
fibers share one connection. Under
:fiberisolation you'd stash per leased connection. - Stats are process-global, single-threaded demo instrumentation.
- Statements are prepared once per request and
DEALLOCATEd by the next one (piggybacked onto its first burst, so cleanup adds no round trip), so no cache goes stale across a reconnect or migration. A query error is drained and raised, leaving the connection usable. Not yet hardened for multiple databases orcount/exists?(which route through other methods).
Running the example
# Needs a Postgres DB with posts/authors/comments/users tables. In this repo:
# go run ./cmd/gqlbench -reset # seeds the graphql_experiment DB
ruby example/run.rb # shows the round-trip collapse
ruby example/bench_wide.rb # the latency benchmark
Requires activerecord, graphql, and pg (libpq ≥ 14 for pipelining).
Tests
rake test. The pipelining suites are parity-first: the pipelined result must be
byte-identical to plain ActiveRecord. The batch suites assert one query per level.
test/pipeloader_test.rb: every query runs through a plain schema and ause Pipeloaderschema, asserting identical results across each relationship kind, nullable foreign keys, empty has-many, deduplication, ordering, type casting, aliases, variables, and multiplex. It also checks round-trip counts (= tree depth), that the patch leaves writes, transactions, and non-GraphQL ActiveRecord untouched, that a database error inside a burst surfaces and leaves the connection usable, that prepared statements don't linger, and that existingGraphQL::Dataloadersources keep working once pipeloader is installed.test/field_exact_test.rb: projected results match the whole-row schema, the emitted SQL is narrowed (and keeps the FK), theselects:hatch includes its columns, and opaque fields fall back to a whole-rowSELECT *.test/auto_fuse_test.rb: a fused result is byte-identical to the un-fused path, fusion collapses each level into oneANY($1)(round trips = depth, even on wide levels), and every non-fusable shape falls back cleanly.test/adapter_test.rb: PostgreSQL pipelines, an unsupported adapter raises, and a real in-memory SQLite run (in a subprocess) proves projection works there with pipelining off.test/batch_*_test.rb: the batch loaders, exhaustively.batch_proxy_testcovers everyhas_many-proxy variant:where(hash, string, range,not, chained, rewhere),order(asc, desc, multi-column, reorder, SQL string), per-grouplimit/offset,select/distinct/pluck/joins, the materializers, scope caching, and write-through. With it:batch_singular_test(belongs_toincluding optional and a non-PK key,has_one),batch_aggregate_test(count/sum/avg/min/max and defaults),batch_through_test(:throughand polymorphic),batch_custom_test(thebatchmacro),batch_testfor the basics, andbatch_context_testfor the sibling-group model (grouping by load, fiber- and thread-safety by construction).
Coverage: rake coverage (or COVERAGE=1 rake test) writes a SimpleCov report to
coverage/. Needs a reachable Postgres (the suites create pl_* and bl_* fixture tables
in graphql_experiment).