action, fragment, russian doll caching

29 Mar 2014 - New York

This is the second post on caching in Rails apps. In the last one, I discussed page caching, which stores the entire output of a request on the web server’s file system.

A limitation of this strategy is that it intercepts requests before they reach your app, and thus doesn’t work well for controllers that expose endpoints requiring a before_action (e.g., as with pages that require authentication).

Action caching

Action caching offers the same level of granularity as page caching (i.e., the entire response is cached), but happens deeper into the request-response cycle: after a request hits the Rails stack and before_actions execute on it.

It’s been removed from core Rails (as of 4.0), and must be installed via the actionpack-action_caching gem.

Setup is mostly the same as with page caching, except you have more cache store options (more on that below).

Fragment caching

Fragment caching allows a portion of a template to be wrapped in a cache block, which will serve the fragment out of the cache store whenever possible.

<% cache(action: 'recent', action_suffix: 'all_products') do %>
  All products:
  <%= render @products %>
<% end %>

To expire a cached fragment, Rails provides the expire_fragment method:

expire_fragment(controller: 'products', action: 'recent', action_suffix: 'all_products')

But it’s better to avoid having to do this manually by setting our cache keys strategically.

Enter memcached

With memcached, we don’t have to expire cached fragments manually. Instead, we continuously add to the cache store, with each new entry keyed by a generated hash (more on that below).

That way, invalidated fragments will just be replaced with valid ones, and we can let the cache store automatically garbage-collect invalidated fragments.

Setting it up is easy:

# config/application.rb (or config/environments/*.rb)
config.cache_store = :mem_cache_store

for non-local environments, you’ll want to specify the addresses of all memcache servers in your cluster.

# config/application.rb (or config/environments/*.rb)
config.cache_store = :mem_cache_store, 'cache1.example.com', 'cache2.example.com'
Other cache stores

memory store (for small apps)

config.cache_store = :memory_store, { size: 64.megabytes }

filestore (the rails default)

config.cache_store = :file_store, "/path/to/cache/directory"

ehcache (for jruby)

config.cache_store = :ehcache_store

null store (i.e., don’t cache. for development)

config.cache_store = :null_store

custom cache store (for plugging in an arbitrary cache store)

config.cache_store = MyCacheStore.new

Cache keys

Hashing for caching

To generate a hash to use as a cache key, define a method that maps from whatever attributes can potentially invalidate the cached fragment to a unique string.

The caching performed above will be invalidated when the number of products changes or whenever a product is updated, so we can define the following helper:

module ProductsHelper
  def cache_key_for_products
    product_count = Product.count
    time_of_most_recent_update = Product.maximum(:updated_at).try(:utc).try(:to_s, :number)
    "products/all-#{product_count}-#{time_of_most_recent_update}"
  end
end

The call to cache above then becomes

<% cache(cache_key_for_products) do %>
  All products:
  <%= render @products %>
<% end %>

In general, a hash function should

ActiveRecord’s cache_key

You can also pass an AR model to cache, and let Rails magic take care of the rest:

  <!-- _product.rb, called from <%= render @products %> -->
  <% cache(product) do %>
    <%= link_to product.name, product_url(product) %>
  <% end %>

Under the hood, cache_key is invoked on the model, producing a cache key composed of the model name, its id, and the updated_at timestamp: products/23-20130109142513.

Note that you can override cache_key or implement it on a PORO as needed.

Russian Doll caching

Russian doll caching is a technique that combines the two approaches to cache key generation above in order to maximize the amount of data that’s cached by nesting cached fragments.

<% cache(cache_key_for_products) do %>
  All products:
  <% render @products %>
<% end %>
<!-- _product.rb -->

<% cache(product) do %>
  <%= link_to product.name, product_url(product) %>
<% end %>

The benefit of this approach is that if only one product is updated, we needn’t re-render the entire set. That invidivual product can be rendered and re-cached, and the set as a whole can be rendered using all the other, still valid, cached fragments.

Low-Level Caching

To manually set or retrieve an item from the Rails cache, use Rails.cache.fetch.

The method works as both a setter when passed a block…

def competing_price
  Rails.cache.fetch('product/competing_price', expires_in: 12.hours) do
    Competitor::API.find_price(id)
  end
end

…and as a getter when passed only a key.

Rails.cache.fetch('product/competing_price')