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 Pipeloader routes every SELECT in 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_fuse collapses each level's per-record lookups into one WHERE key = ANY($1), dropping query count to DataLoader's.
  • Pipeloader::Batch brings 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

  1. use GraphQL::Dataloader runs resolution in fibers, so a synchronous-looking post.author can yield instead of blocking and sibling queries gather before anything hits the wire.
  2. A monkey-patch on select_all hands 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 as self.
  3. 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_mode to pipeline_sync), and returns an ActiveRecord::Result per 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):

authors.each { |a| a.books.where(published: true).to_a }   # filter pushed down, one query
authors.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 RuntimeError at 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 :fiber isolation 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 or count/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 a use Pipeloader schema, 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 existing GraphQL::Dataloader sources 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), the selects: hatch includes its columns, and opaque fields fall back to a whole-row SELECT *.
  • test/auto_fuse_test.rb: a fused result is byte-identical to the un-fused path, fusion collapses each level into one ANY($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_test covers every has_many-proxy variant: where (hash, string, range, not, chained, rewhere), order (asc, desc, multi-column, reorder, SQL string), per-group limit/offset, select/distinct/pluck/joins, the materializers, scope caching, and write-through. With it: batch_singular_test (belongs_to including optional and a non-PK key, has_one), batch_aggregate_test (count/sum/avg/min/max and defaults), batch_through_test (:through and polymorphic), batch_custom_test (the batch macro), batch_test for the basics, and batch_context_test for 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).