/ Tags: RAILS-CODE / Categories: SOLUTIONS

Cache An Expensive Scope In Rails With Automatic Cache Busting On Update

A dashboard query that aggregates across three tables takes 800ms and the underlying data changes a few times an hour. Caching it is obvious; the hard part is invalidating it without a scheduled sweep or a stale-data bug.

Description

Build the cache key from something that changes when the data changes — the collection’s maximum(:updated_at) plus its count. Any insert, update, or delete moves one of those, so the key changes and the cache misses naturally. No manual expiry needed. cache_key_with_version on a relation does exactly this and is the idiomatic form. Cache the computed result, not the relation — a relation is lazy and caching one stores an unexecuted query object. Watch for the thundering herd: when a hot key expires, every request recomputes at once. race_condition_ttl lets one request rebuild while the others serve the stale value.

Sample input:

  Order.revenue_summary_for(account)


Sample Output:

  # First call:  800ms, computed
  # Later calls: <1ms, cached until any order changes

Answer

  class Order < ApplicationRecord
    scope :expensive_summary, -> {
      completed
        .joins(:line_items)
        .group(:currency)
        .select("currency, COUNT(DISTINCT orders.id) AS n, SUM(line_items.subtotal_cents) AS total")
    }

    def self.revenue_summary_for(account)
      scope = where(account_id: account.id)

      Rails.cache.fetch(
        ["revenue-summary", account.id, scope.cache_key_with_version],
        expires_in: 1.hour,
        race_condition_ttl: 10.seconds
      ) do
        scope.expensive_summary.to_a.map(&:attributes)
      end
    end
  end
  # cache_key_with_version derives from count + max(updated_at):
  #   "orders/query-a3f8c2-count-1420-20260712140233"
  # Any insert/update/delete changes it, so the cache busts itself.

  # Hand-rolled equivalent, when you need control over the inputs
  def self.summary_cache_key(account)
    scope = where(account_id: account.id)
    [
      "revenue-summary",
      account.id,
      scope.maximum(:updated_at)&.to_i,
      scope.count
    ].join("/")
  end

  # NOTE: deletes change count, updates change max(updated_at).
  # A hard delete plus an insert in the same second is the one gap —
  # add a counter or a touch on the parent if that matters.

  # Cache the computed value, never the relation
  Rails.cache.fetch(key) { scope.to_a }        # correct
  Rails.cache.fetch(key) { scope }             # wrong — lazy, nothing ran

Learn More

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More