activerecord-precompiled
assets:precompile for your ActiveRecord queries, on SQLite. You write normal
ActiveRecord; at deploy time, on production only, the query chains in your
source are rewritten into precompiled SQL that runs straight on the sqlite3
handle: no Arel, no relation objects. Logs, APM and the query cache see them as
usual.
# what you write (and commit)
def self.named(name) = User.find_by(name: name)
# what runs in production after `rails ar_precompile:build`
def self.named(name) = ::ActiveRecordPrecompiled::Sites::S9802ca3c9c_0.call(name) { |__xs0| User.find_by(name: __xs0) }
BEGIN { ::ActiveRecordPrecompiled::Site.define(:S9802ca3c9c_0, model: "User", kind: :one, sql: "SELECT \"users\".* FROM \"users\" WHERE \"users\".\"name\" = ? LIMIT ?", binds: [[:arg, 0, "name"], [:val, 1, "LIMIT"]], schema: "e79cff1769c2ac6f") } # activerecord-precompiled v0.1.0Same idea as ruby-next's nextify (source-to-source at build time) and
bootsnap (do the expensive work once, ahead of time), applied to queries.
How it works
- SQL comes from ActiveRecord itself. For each chain, the build runs the real ActiveRecord method with placeholder arguments and intercepts the query at the adapter. The SQL is byte-for-byte what ActiveRecord would send.
-
Binds are cast like ActiveRecord does it:
type_for_attribute(col).serialize, thenconnection.type_cast, so enums, booleans, times, and decimals behave the same. -
Execution goes through
with_raw_connection, using the adapter's own prepared statement pool. Locking, lazy transactions, and reconnects behave as they do in ActiveRecord. -
The original chain is kept as a fallback block. It runs for
nil, Range, Relation, or record arguments (and arrays ActiveRecord wouldn't turn into a plain IN list), insidescoping/unscopedblocks, on non-SQLite connections, whenActiveRecordPrecompiled.enabled = false, and to raiseRecordNotFoundforfind/find_by!/first!/take!misses. -
Line numbers are preserved: site definitions go into one
BEGIN {}line at the end of the file. - Schema guard. Each site records a fingerprint of its table's columns, including their order. If the runtime schema differs, the site logs one warning and uses ActiveRecord from then on.
Supported
Chains rooted at a model constant:
all, where(col: v, ...), where.not(col: v, ...), order(literals), group(:cols), limit(int), offset(int),
includes(literals), preload(literals), static scopes,
followed by to_a, first, last, take, first!, take!, find, find_by, find_by!, count,
exists?, exists?(col: v, ...), exists?(id), pluck(:cols), sum/maximum/minimum/average(:col), or one of each, map, flat_map, filter_map,
each_with_index, each_with_object, index_by, group_by with a block.
order also takes Arel.sql("...") with a plain string. Calculations return what ActiveRecord does
(the column's type; average of an integer or decimal column is a BigDecimal;
sum of nothing is 0). With group, count and the calculations return
ActiveRecord's Hash (keys cast with the columns' types, an Array key for
several columns); having is left to ActiveRecord. exists?(id) takes the
fast path for an Integer or String id; false, nil, Arrays, Hashes and
records mean something else to ActiveRecord and go to it.
includes/preload run the main query on the fast path and then preload,
as Relation#load does. Each association's query is precompiled
(ActiveRecord's SQL for key = ? and key IN (?, ...), up to 100 keys) for
associations whose scope is a fixed where/order. For has_many/has_one :through and has_and_belongs_to_many that is the two queries the Preloader
runs for them (owners to the through records, then to the targets), each
checked at runtime against the scope ActiveRecord builds for it.
When every association in an includes tree has one, the Site preloads the
tree without building the Preloader's objects or the association scopes. It
follows what Preloader::Batch does, in the same order: rounds of runnable
loaders, loaders deferred while another branch will still load their table,
loaders with equal queries loaded by one query (so, as with ActiveRecord, a
comment's author can be the very object that is its post.user), grouping
by owner, inverses and already-loaded targets. A through association takes
this path when its two inner preloads assign what they load (no conditions,
order or distinct on it). Otherwise the Preloader runs as usual and only
its queries are precompiled. A through query that ActiveRecord joins (a
where on the through association, source_type), nested through
associations, polymorphic ones and scopes that read the clock stay with
ActiveRecord. If includes would JOIN instead (conditions or order on the
included table), or with pluck, the chain is left to ActiveRecord.
A condition value may be an Array at runtime: where(id: ids) runs
ActiveRecord's id IN (?, ...) (NOT IN for where.not) for any length up
to 100, and a one-element array the scalar SQL, as ActiveRecord does. The
build checks the IN form against ActiveRecord for each argument. Empty arrays,
arrays with nil, ranges or records, and longer ones go to ActiveRecord.
Chains can also start at a collection association (has_many, has_many :through, has_and_belongs_to_many): user.posts.where(title: t).first,
@trip.trip_days.count, or photos.maximum(:position) inside a model. The
receiver's class isn't known at build time, so each model with an association
of that name whose scopes are static gets its own Site, with the owner's key
as a bind; at runtime the CollectionProxy tells which owner it is. Records
point back to the owner (inverse) and take its strict_loading mode, as
ActiveRecord's AssociationRelation does. ActiveRecord runs the chain for a
new or strict-loading owner, an owner class the build didn't see, and, when
the terminator is called on the association itself (posts.first), when the
association is loaded or holds unsaved records, since ActiveRecord may answer
from memory then. posts.to_a/posts.each (which load the association into
the owner) are left alone.
A condition key can also be a belongs_to association: where(site: site)
binds site.id to site_id, as ActiveRecord does (an id works too).
Polymorphic associations are left to ActiveRecord.
Model.friendly.find(id) from friendly_id
compiles too, for :slugged and :history setups with an integer primary key
and FriendlyId's own finders: the slug, old-slug, and primary-key lookups are
each precompiled and tried in FriendlyId's order. A miss runs the original
chain, so it raises FriendlyId's own RecordNotFound.
Deterministic encrypted attributes (encrypts :email, deterministic: true)
work as condition keys: the argument is encrypted at runtime with the
attribute's type, like ActiveRecord does. With extend_queries, the extra
values it adds (the clear text, previous schemes) are bound too. A value that
isn't a String, or an encryption setup that differs from the build's, goes to
ActiveRecord. Constants compared to an encrypted attribute aren't compiled,
since their ciphertext would be made with the build machine's keys.
With ar_lazy_preload's
auto_preload on, fast-path records get the same lazy-preload context that
Relation#load gives them, so their associations still load in batches.
Scopes are compiled when their body is static: one chain of all/where/
where.not/order/limit/offset/includes/preload and other static scopes, with
literals or the scope's own parameters as values (ranges included). Scopes
Rails generates (enum values, with_rich_text_*, with_attached_*) count as
static too:
scope :adults, -> { where(age: 18..) } # compiled
scope :named, ->(n) { where(name: n) } # compiled
scope :born_since, ->(t) { where(born_at: t..) } # compiled
scope :recent, -> { where(created_at: 1.week.ago..) } # not: evaluated per callTo see which scopes compile and how to fix the rest:
bin/rails ar_precompile:scopes # or: bundle exec ar-precompile scopes✅ User.adults test/test_helper.rb:58
❌ User.recent app/models/user.rb:12
`1.week.ago` in `created_at:` is computed when the scope runs; make it a parameter:
->(x) { where(created_at: x..) } and pass the value from the caller
activerecord-precompiled: 14 of 19 scopes can be compiled (plus 6 enum scopes)
Bare constants resolve the way Ruby does: inside module Admin, User means
Admin::User if it exists.
Everything else is left alone: non-static scopes, joins, eager_load (and includes
that would JOIN), string conditions, chains on a relation held in a variable,
models with default_scope, keys that are neither columns nor belongs_to
associations, and arguments that are ranges or heredocs. STI subclasses work.
Deploy
gem "activerecord-precompiled"Run once per release, after db:migrate and before the app boots:
RAILS_ENV=production bin/rails ar_precompile:build # DRY_RUN=1 to previewor bundle exec ar-precompile [--dry-run] [--force] [paths] from the Rails root.
Paths default to app and lib; for the rake task, set AR_PRECOMPILE_PATHS=app,lib/foo.
It refuses to run outside production unless FORCE=1 / --force, it prints every
skipped call site with the reason, and running it again changes nothing. It never
writes a file that doesn't parse. Never commit the rewritten files.
Building without the database (docker build)
The build needs the production schema (columns, in order, with their types), never data. Dump it where the production database is, and commit the file:
RAILS_ENV=production bin/rails ar_precompile:schema:dump # or: bundle exec ar-precompile schema-dump
# writes config/ar_precompile_schema_cache.yml (ActiveRecord's schema cache format)Then the build runs anywhere, the image build included:
RUN SECRET_KEY_BASE_DUMMY=1 RAILS_ENV=production bin/rails ar_precompile:buildWhen a SQLite database's file is missing, the build uses its dump on an empty
in-memory database, so it writes no database file into the image. The output
is the same as a build against the production database. A database file with
no tables counts as missing, and the build removes it: that is what an
initializer or gem that connects while the app loads leaves behind, and the
app would otherwise start on it in production. When the dump is older than your newest
migrations, the build recreates its tables (columns in order, keys, indexes)
and runs those migrations on them, as the deploy will on production: SQLite
adds a column last, so the order comes out the same. Dump again once they
have run on production. A migration that can't run on empty tables (one that
needs data, or a view) leaves the dump as it is, with a warning; the schema
guard then notices at runtime, and the queries it changes run on ActiveRecord. Other databases (Solid Cache,
Queue, Cable) don't need a dump: without one, queries on them are left to
ActiveRecord. AR_PRECOMPILE_SCHEMA=dump uses the dumps even when the
database is there, and =database never uses them.
The dump is kept apart from ActiveRecord's own db/schema_cache.yml on
purpose: the app would load that one at boot, and the schema guard has to see
the real database.
Trade-offs
- Fast-path queries are instrumented like ActiveRecord's own: a
sql.active_recordevent with the same payload (sql,name,bindsas named attributes,type_casted_binds,connection,row_count, ...) andprecompiled: true. So they show up in the Rails log (withfilter_attributesapplied), APM, query counts (assert_queries_count) and the request's "ActiveRecord: ...ms (N queries)". The event costs about as much as ActiveRecord's (a few microseconds; Rails always has a subscriber). To skip it:ActiveRecordPrecompiled.active_record_events = false. They are also published assql.active_record_precompiled(payloadsql,name,binds,row_count), which costs nothing when nobody subscribes. - With ActiveRecord's query cache on (as in every request), fast-path results
are cached in the connection's own cache store, so they live exactly as
long as ActiveRecord's (any write clears them;
uncachedskips them). Entries aren't shared with uncompiled calls of the same query. A hit is logged and counted asCACHE, as ActiveRecord's are. - SQL is fixed at build time, so build against the production schema: after
db:migrateon the production database, or from its dump. Column order matters, and it is the order of the database's own migration history; a database loaded fromschema.rbhas the order of whichever database dumped it. The schema guard catches a mismatch, but those sites then run on ActiveRecord. - Works with
query_log_tags_enabled, which turns prepared statements off: the build turns them on just while capturing SQL, and the runtime always binds. Fast-path queries don't carry the query-log comment.
Results on a real app (golf-buddy, 720 Ruby files)
- 407 call sites rewritten in 183 files (about 190 of them on associations).
- Every rewritten site with data in the dev database (172 of them) returned
results identical to ActiveRecord, preloaded associations included. Query
cache off on both sides, fast-path queries instrumented as
sql.active_record: median 1.98× faster, geomean 2.17× (2.33× and 2.61× withactive_record_events = false). Medians by kind: associationcount/exists?3.0–3.5×,exists?2.6×,friendly.find2.5×,count2.3×, single-record lookups about 1.8× (loading the record dominates on wide models). Given five-element arrays instead, the 62 sites with IN lists matched too. - The app's test suite (4317 runs, same seed, one worker) gives the same results before and after the rewrite: the same 3 failures, which are in the clean baseline too.
- Whole requests (12 public pages, production mode, dev data, fragment cache
off, query cache on): 1.6% less time in total. Pages run 15–175 queries, of
which the fast path takes 1–19; venue pages (4–6 fast-path queries) are 4–6%
faster, the rest within noise. Most remaining queries are lazy association
loads (
venue.venue_photos,photo.imageinside loops), which no rewritten call site covers.
Requirements
Ruby 3.4+, ActiveRecord 8.0–8.2 (8.2 tested against Rails main, see
Gemfile.edge; 8.0 with Gemfile.rails80), the sqlite3 adapter (the
sqlite3 gem, or on JRuby activerecord-jdbc's sqlite3 adapter). JRuby 10 works
with Rails 8.1 and activerecord-jdbc's master branch, which is the only
activerecord-jdbc for Rails 8 so far (Gemfile.jruby): the fast path runs the
precompiled SQL on its JDBC connection, about 1.3–2.2× faster than ActiveRecord
there.
The build checks that it can still capture queries before touching any file.
If a Rails change breaks that, it stops with ActiveRecordPrecompiled::Capture::Broken
instead of finishing without rewriting anything.
Development
bundle exec rake # differential tests: every case runs through AR and the rewrite
bundle exec rake test:edge # the same against Rails main
BUNDLE_GEMFILE=Gemfile.rails80 bundle exec rake test # ActiveRecord 8.0
BUNDLE_GEMFILE=Gemfile.jruby bundle exec rake test # JRuby (see Gemfile.jruby)
bundle exec ruby bench/bench.rb