Time Dependent Cache Keys

Note: This article was written by Andreas Garnæs of Subsis, one of our awesome partners.

The default ActiveRecord cache key implementation only takes the model's attributes into account, not what the current time is. If your model changes with the passing of time -- e.g. a deal that expires -- using the default #cache_key implementation wlll not do. This blog post explains how to implement #cache_key correctly to handle models that will change state automatically at a future time. Let's say you have a Rails application that uses fragment caching and the rendering of one of your models is dependent on the current time, in addition to the stored attribute values in the database. As an example let's say we are selling deals, and our deal expires after 24 hours. The rendering of a deal is expensive so we want use fragment caching:

 - cache ["v1", @deal] do
   = render @deal

Without changing Deal#cache_key, this partial will not be updated even when the deal expires. We can solve this by defining a custom #cache_key method in the Deal model. The default #cache_key method in Rails includes the model name and the #updated_at timestamp, so the only thing we need to add is a time-dependent component of the cache key:

class Deal < ActiveRecord::Base
   def cache_key
    "#{super}-#{expired?}"
  end

  def expired?
      Time.now < expires_at
  end
end

We can now render the partial correctly with the passing of time. Simple. Now consider the case of rendering a collection of such deals. We might have a category page, showing a list of all deals in the category:

%h1= @category.title

- @category.deals.each do |deal|
  = render deal

Again we can start by doing simple fragment caching:

%h1= @category.title

- @category.deals.each do |deal|
  - cache ["v1", "deal_in_category", deal] do
    = render deal

This works, but every time we render a category, we have to stitch the deal fragments together and there could be a lot of them. Let's try to add fragment caching to the rendering of the category as well:

- cache ["v1", @category] do
  %h1= @category.title

  - @category.deals.each do |deal|
    - cache ["v1", "deal_in_category", deal] do
      = render deal

Our first problem is that the view is not refreshed when a deal gets updated. To get automatic invalidation of the fragment cache for categories when a deal is updated, we let the Deal model touch its category's updated_at timestamp:

class Deal < ActiveRecord::Base
  belongs_to :category, touch: true

  # cache key code...
end

When we update a deal, the category page is now updated accordingly, but when a deal expires, the view is stale -- we haven't solved the time dependent part of the cache scheme. One solution would be to inflate all the deals and mangle the cache keys into a single composite key, which is appended to the category's key. But to be useful, cache keys should be fast to calculate and the that scheme is slow and memory consuming. There is a faster solution:

class Category < ActiveRecord::Base
  has_many :deals

  def cache_key
    expiration_sum = deals.where("deals.expires_at < ?", Time.now).sum(:expires_at)

    "#{super}-#{expiration_sum}"
  end
end

Basically, we fetch all the deal-timestamps that the category depends on, discard all timestamps in the past, and sum the remaining timestamps to form the second half of the cache key for the category. That makes the category's cache key dependent on all deals and the current time. This solution only requires 2 database queries (one for the deal timestamps and one for the category's updated_at timestamp). All calculations are done in the database, which is very efficient. More complex scenarios may require you to do some calculations in Ruby, however.

Note, that it's sound to sum the timestamps as we are guaranteed that the set of timestamps is constant for unchanged database values for the category and its associated deals. Otherwise, the first half of the cache key would have changed. With the above, we are able to cache the category-partial and render it in no time. The above observations can be generalized to other kinds of time dependencies: use touch: true for has_many-associations, and override the cache_key method to include time dependencies.