ruby erb 62 lines · 3 tabs

Deterministic Cache Keys for Collections

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby, erb Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Deterministic Cache Keys for Collections — share card
Link copied