-- Product.active.touch_all(:cache_synced_at) for catalog 42
UPDATE "products"
SET "updated_at" = '2024-05-01 12:00:00.123456',
"cache_synced_at" = '2024-05-01 12:00:00.123456'
WHERE "products"."catalog_id" = 42
AND "products"."status" = 'active';
-- one statement, no per-row callbacks, cache_version bumps for every row
class RepriceProductsJob < ApplicationJob
queue_as :catalog
retry_on ActiveRecord::Deadlocked, wait: :polynomially_longer, attempts: 3
def perform(catalog_id, multiplier)
catalog = Catalog.find(catalog_id)
count = catalog.reprice_products!(multiplier.to_f)
Rails.logger.info(
"repriced catalog=#{catalog.id} products=#{count} multiplier=#{multiplier}"
)
end
end
class Catalog < ApplicationRecord
has_many :products, dependent: :destroy
def bump_products_cache!
products.active.touch_all(:cache_synced_at)
end
def reprice_products!(multiplier)
updated = products.active.update_all([
"price_cents = (price_cents * ?)::integer", multiplier
])
bump_products_cache!
updated
end
end
class CatalogsController < ApplicationController
before_action :set_catalog, only: :enqueue_reprice
def enqueue_reprice
multiplier = params.require(:multiplier)
RepriceProductsJob.perform_later(@catalog.id, multiplier)
head :accepted
end
private
def set_catalog
@catalog = Catalog.find(params[:id])
end
end
The touch_all method on an ActiveRecord relation issues a single UPDATE that sets updated_at (and optionally other timestamp columns) across every matching row, instead of loading records and calling touch one by one. This matters because per-record touching triggers N callbacks, N queries, and N cache-key regenerations, which is disastrous when a parent change should invalidate thousands of children. The trade-off is deliberate: touch_all skips callbacks, validations, and updated_at on associated records, so it is only appropriate when the goal is purely to bump timestamps for cache busting or staleness tracking, not to run domain logic.
In Catalog model, the bump_products_cache! method scopes to products.active and calls touch_all, passing an extra :cache_synced_at column so both the standard updated_at and a domain-specific timestamp move together in one statement. Because Rails cache keys derive from updated_at via cache_version, a single touch invalidates every fragment cache and Russian-doll cache tied to those products.
The RepriceProductsJob shows the realistic trigger: after a bulk price recalculation with update_all (which does not touch timestamps), the job explicitly calls bump_products_cache! so views and API ETags recompute. Using update_all plus touch_all keeps the whole operation to two round-trips regardless of catalog size, and find_each is intentionally avoided here since no per-row Ruby work is needed.
In CatalogsController, enqueue_reprice fires the job and responds with 202 Accepted, keeping the request fast while the heavy write happens off the request cycle. The emitted UPDATE tab shows the actual SQL Rails generates: one UPDATE ... SET updated_at = ?, cache_synced_at = ? WHERE ..., confirming there is no per-row overhead.
A key pitfall is that touch_all will not cascade belongs_to ... touch: true associations, so a parent whose own caches depend on children still needs its own touch. It also bypasses optimistic locking, so records under heavy concurrent edits may lose a lock_version bump. When the intent is fast, callback-free timestamp invalidation across a set, touch_all is the right tool.
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.