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
class CounterCacheRepairJob
include Sidekiq::Job
sidekiq_options queue: :maintenance, retry: 3
BATCH_SIZE = 500
def perform(batch_size = BATCH_SIZE)
repaired = 0
Post.stale_comment_counts.in_batches(of: batch_size) do |relation|
ids = relation.pluck(:id)
ActiveRecord::Base.transaction do
ids.each do |post_id|
begin
Post.reset_counters(post_id, :comments)
repaired += 1
rescue ActiveRecord::RecordNotFound
next
rescue => e
Rails.logger.error(
"[CounterCacheRepair] post_id=#{post_id} error=#{e.class}: #{e.message}"
)
end
end
end
end
Rails.logger.info("[CounterCacheRepair] repaired=#{repaired}")
repaired
end
end
Sidekiq.configure_server do |config|
config.on(:startup) do
schedule = {
"counter_cache_repair" => {
"cron" => "15 * * * *", # every hour at :15
"class" => "CounterCacheRepairJob",
"queue" => "maintenance",
"description" => "Reconcile posts.comments_count against actual comments"
}
}
Sidekiq::Cron::Job.load_from_hash(schedule)
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.