module CollectionCacheKey
extend ActiveSupport::Concern
def cache_key_for(scope)
relation = scope.respond_to?(:all) ? scope.all : scope
model = relation.klass
max_updated_at, size = relation
.reorder(nil)
.pick(Arel.sql("MAX(#{model.quoted_table_name}.updated_at)"), Arel.sql("COUNT(*)"))
timestamp =
if max_updated_at
max_updated_at.utc.to_fs(:usec)
else
"empty"
end
digest = Digest::MD5.hexdigest("#{model.name}-#{size}-#{timestamp}")
"#{model.model_name.cache_key}/collection-#{digest}"
end
end
class ProductsController < ApplicationController
include CollectionCacheKey
def index
@products = Product
.where(published: true)
.order(created_at: :desc)
.includes(:category)
@cache_key = cache_key_for(@products)
# HTTP-level caching reuses the same deterministic signal.
return unless stale?(etag: @cache_key, public: true)
respond_to do |format|
format.html
format.json { render json: @products }
end
end
end
<h1>Products</h1>
<% cache @cache_key, expires_in: 12.hours do %>
<div class="product-grid">
<% if @products.empty? %>
<p class="empty">No products are available right now.</p>
<% else %>
<% @products.each do |product| %>
<%# Russian-doll: each row invalidates independently on touch %>
<% cache product do %>
<article class="product-card" id="product_<%= product.id %>">
<h2><%= product.name %></h2>
<p class="category"><%= product.category.name %></p>
<p class="price"><%= number_to_currency(product.price) %></p>
</article>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
Rendering a list of records and caching the whole fragment is only safe when the cache key changes exactly when the underlying data changes. A naive key like "products/#{products.map(&:id)}" breaks the moment a single row is updated or soft-deleted, because the id set is unchanged while the content is stale. The classic fix is a collection cache key that folds three signals into one string: the model class, the maximum updated_at timestamp across the set, and the row count. If any record is touched, max(updated_at) advances; if a record is added or removed, the count changes. Together they form a deterministic digest that only moves when the visible data moves.
In CollectionCacheKey, the concern exposes cache_key_for(scope) which issues a single aggregate SQL query via pick to fetch MAX(updated_at) and COUNT(*) in one round trip, avoiding loading records just to compute a key. The timestamp is normalized with usec precision so two keys generated from the same data are byte-for-byte identical across requests and processes — that determinism is what lets multiple web servers share one Redis cache. The parts are hashed with Digest::MD5 to keep keys short and bounded regardless of collection size.
In ProductsController, index builds a scope and derives @cache_key before touching the view, so the controller can short-circuit with fresh_when for HTTP caching in addition to fragment caching. The scope is kept as a relation, not an array, so the key query stays a cheap aggregate.
In index.html.erb, the outer cache @cache_key wraps the list while each row uses Russian-doll cache product, so a single edited product only invalidates its own inner fragment and the cheap outer key, leaving sibling fragments warm. A subtle pitfall this design handles: relying on count alone misses in-place edits, and relying on updated_at alone misses deletions, so both are required. Developers reach for this when list pages are read-heavy and expensive to render but change infrequently.
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.