ruby 64 lines · 3 tabs

Counter Cache Repair Job (Consistency Tooling)

Shared by codesnips Jan 2026
3 tabs
class Post < ApplicationRecord
  has_many :comments, dependent: :destroy

  # comments_count is maintained by counter_cache on Comment#belongs_to

  scope :stale_comment_counts, lambda {
    joins(
      "LEFT JOIN (
         SELECT post_id, COUNT(*) AS actual
         FROM comments
         GROUP BY post_id
       ) c ON c.post_id = posts.id"
    ).where(
      "posts.comments_count <> COALESCE(c.actual, 0)"
    )
  }
end
3 files · ruby Explain with highlit

Rails counter_cache columns are a classic denormalization: the number of comments on a post lives in posts.comments_count so the index page doesn't fire N COUNT(*) queries. The trade-off is that these caches drift. A raw SQL delete, a failed transaction, a bulk import, or an unlucky race between two writers can leave the cached number out of sync with reality, and Rails offers no built-in guarantee it will ever heal. This snippet is the tooling that repairs that drift on a schedule.

The Post model declares the association and, importantly, a scope stale_comment_counts that expresses the drift condition directly in SQL. It joins posts to an aggregated subquery of actual comment counts and returns only the rows where the cached value disagrees. Doing the comparison in the database means the job never has to load millions of clean rows just to discover they are fine — it pulls only the offenders.

The CounterCacheRepairJob is the workhorse. It is written to be safe to run repeatedly and safe to run alongside live traffic. Rather than looping in Ruby, it delegates to Post.reset_counters, the official ActiveRecord API that recomputes a counter from the real association and writes it back. Work is chunked with in_batches so a repair over a huge table doesn't hold one giant transaction or balloon memory. Each batch is wrapped in a short transaction, and errors on individual posts are rescued and logged so one bad row can't abort the whole sweep. The job returns a count of what it fixed, which makes it observable in job dashboards.

The RepairSchedule config wires the job into a recurring cron via sidekiq-cron, so consistency is enforced continuously instead of only when someone notices a wrong number.

The key idea is that a counter cache is an optimization, not a source of truth — the associated rows are. This job treats the cache as eventually consistent and periodically reconciles it against the truth. Because reset_counters is idempotent, running the sweep twice is harmless; a second pass over already-correct rows simply writes the same value. The main pitfall to watch is lock contention on very hot rows, which the batching and per-row rescue are designed to contain.


Related snips

Share this code

Here's the card — post it anywhere.

Counter Cache Repair Job (Consistency Tooling) — share card
Link copied