Repository navigation
Released to RubyGems on 2026-09-30.
Breaking
- Requires Ruby >= 3.3 and Rails (Active Record) >= 8.0.
:if/:unlessonupdate_counter_cachenow only gate the create and update callbacks; a
destroy always recounts. Useexcept: [:destroy]to skip it.- Column counters are written with
update_column: recounts no longer save other unsaved
changes on the owner, touch itsupdated_at, run its callbacks or bumplock_version. update_counter_cacheraisesArgumentErrorfor associations other thanbelongs_to
(they have never worked).- Every model that calls
define_counter_cachegets thecountersassociation, not only models
with a counter that has no column. - Defining counters on a table that doesn't exist no longer silently defines nothing.
- Recounts triggered by
update_counter_cachecallbacks run after the saving transaction commits
instead of inside it: once per owner and counter per transaction, not at all on rollback, and
under a short lock on the owner's row. Counting inside the transaction couldn't see a concurrent
save's uncommitted child, so concurrent saves to the same owner left a stale count (on
PostgreSQL, 399 of 400 concurrent test runs). The counter now changes at commit rather than at
save; callupdate_<name>directly to recount immediately inside a transaction. Rails'
transactional tests commit each save within the test transaction, so tests see the recount. - A counter stored in the counters table raises
ArgumentErrorfor a value that isn't a whole
number (e.g.2.5,'many', infinity) instead of silently truncating it withto_i.nil
still means 0, and whole values like4.0orBigDecimal('4')are still accepted. Use a column
of a suitable type orstore: :cachefor fractional values.
Changed
- Whether a counter uses a column or the counters table is decided when it's read or written, so
loading models never queries the database, and a column added after the model has loaded is
picked up. The HerokuDATABASE_URLworkaround is gone. - Counter rows are deleted on destroy only when the model has a counter without a column, so
column-only models never touch the counters table. - A recount that raises after commit is passed to
ActiveSupport.error_reporter.unexpected(with
the owner and counter as context) and logged, instead of propagating: the save has already
committed, and one failure no longer skips the other recounts queued for that commit. In
development and test Rails' debug mode still raises it. Directupdate_<name>calls,
recount_counter_cachesandCustomCounterCache::RecountJobstill raise. - Paranoia's
restorenow recounts the owner for callbacks declared afteracts_as_paranoid; it
didn't before. Addexcept: [:restore]to keep the old behavior.
Added
CustomCounterCache.counter_class_name(default'Counter') sets the model behind the
counters table.define_counter_cache :name, touch: true(ortouch: :column) sets a timestamp on the owner
whenever the counter is recounted. A column counter is written in the same UPDATE, and no
callbacks run.define_counter_cache :name, store: :cache, expires_in:keeps a counter in
CustomCounterCache.cache_store(defaults toRails.cache). It is computed on a miss and
deleted after commit when a child changes, instead of being recounted. Destroying the owner
deletes its keys.update_counter_cache ..., on_change: [:attr]recounts on update only when a listed attribute
or the association's key columns changed. Create and destroy always recount.update_counter_cache [:article, :user], :comments_countfollows a path ofbelongs_to
associations and recounts both the old and the new owner at the end of it (grandparent counters).update_counter_cache ..., recount: :laterrecounts inCustomCounterCache::RecountJob
(Active Job), enqueued after commit, once per owner and counter per transaction.CustomCounterCache.batch { }recounts each owner and counter once after the block ends.CustomCounterCache.skip { }drops recounts inside the block.Model.recount_counter_caches(*names, scope:, batch_size:)recounts every record (or a scope) in
batches, repairing drift fromupdate_all,delete_alland imports.- The
custom_counter_cache:recountrake task (MODEL=,COUNTERS=,BATCH_SIZE=) wraps
recount_counter_caches. - Composite primary keys: owners and
belongs_toassociations with composite keys work with
column and cache counters, reassignment (including a change to only some key columns),
on_change:,recount: :laterandrecount_counter_caches. The counters table can't hold a
composite key, so a counter there raisesArgumentErrornaming the alternatives. only:andexcept:onupdate_counter_cacheaccept:restore, the event fired by Paranoia's
restore.
Upgrading
- If an
:if/:unlesscondition was meant to skip destroys, addexcept: [:destroy]. - If you relied on a recount updating the owner's
updated_at, passtouch: trueto
define_counter_cache. Owner callbacks no longer run on a recount; call them explicitly if needed. - If a migration adds a counter column and backfills it, call
reset_column_informationon the
model before backfilling. - Counters now change when the saving transaction commits. Code that saves a child and reads the
owner's counter inside the same transaction should callupdate_<name>first. - A counters-table counter whose block can return a fractional value (an average, a sum of
decimals) now raises: move it to a column of a suitable type or tostore: :cache. - On a model with a string or UUID primary key, the counters table's
countable_idmust be that
type (t.references :countable, polymorphic: true, type: :uuid).
Full changelog: v0.3.2...v0.4.0