chunky_cache: Multi-fetching cache calls in Ruby on Rails

Rails has some support for fetching multiple cache keys at once from memcached/Redis, but we can go further.

by Robert May on Afternoon Robot

Gem

I've built a new Ruby gem: https://gitlab.com/robotmay/chunky_cache

What does it do?

So you're using caching in Rails, and you want to cache a view:

<h1>Something uncachable because it involves a timestamp <%= Time.now.to_s %></h1>

<p>
  <strong>This is <%= %w(very slightly).sample %> important, <%= user.name %>!</strong>
  <oblique>No, really: <%= intense_calculation %></oblique>
</p>

<p>Fin.</p>

Wow that must take so long to render or something.

Anyway, you have a few options for caching this view, each with a downside:

Cache the whole view

This will have to be scoped to each user in the key, resulting in a separate cache record per user, and more intense_calculation! Plus you'll need to figure out what to do about that very annoying timestamp.

Cache the view in multiple pieces

Each section will perform its own network call to the cache, this adds latency which can fluctuate with the network.

Cache the whole view but do some string replacement afterwards

This gets harder to maintain and is a bit messy.

Put the user's name in using JavaScript

I use JS for tricks like this a lot, but it's more to maintain and a bit of a hassle. The navigation on this site is an example of using JS to get around caching.

Enter chunky_cache

chunky_cache allows you to batch network calls to your cache inside the view. We can cache this view, with separate keys for each area, and perform only a single cache request:

<%= chunky_cache(expires_in: 1.day) do %>
  <h1>Something uncachable because it involves a timestamp <%= Time.now.to_s %></h1>

  <p>
    <%= cache_chunk(:message, user) do %>
      <strong>This is <%= %w(very slightly).sample %> important, <%= user.name %>!</strong>
    <% end %>

    <%= cache_chunk(:calculation) do %>
      <oblique>No, really: <%= intense_calculation %></oblique>
    <% end %>
  </p>

  <p>Fin.</p>
<% end %>

In this example we're caching two sections of the view:

  1. The part with the user's name is scoped to the user
  2. The intense_calculation is cached generically for everyone

With normal Rails cache helpers this results in 2 network requests, with chunky_cache this is just 1. Now this example is pretty pointless, but what about if you have 100 sections on a page? chunky_cache still has only 1 network call (there is of course still some lookup cost on the cache side for so many keys).

What about partials?

So Rails does actually support multi-fetching cache keys, but only in one specific scenario with the default helpers:

<%= render partial: "fox", collection: @foxes, cache: true %>

And this is great. It's a reimplementation of https://github.com/n8/multi_fetch_fragments which I used to use everywhere. But what about if those partials have user-specific data in them? Now you're going to have to cache each partial for each user again, oh no!

But chunky_cache lets you do this:

show.html.slim

= chunky_cache(expires_in: 1.hour) do
  = render partial: "fox", collection: @foxes

_fox.html.slim

= chunky_cache(expires_in: 1.hour) do
  = cache_chunk(fox) do |fox|
    = "Hello #{fox} #{intense_calculation(fox)},"

  = "My name is #{user}"

How many network calls is this? 1, again.

In the partial the chunky_cache call is actually not required, but useful if you render it elsewhere without an external call wrapping it.

How does it work?

The helpers use Rails' built-in helper capture to consume the contents of their blocks and turn them into strings. chunky_cache does this immediately, and returns the final output after mixing everything together. But cache_chunk doesn't execute its block, instead storing it in an instance variable established by chunky_cache, and it then returns a cache key string. At this point the template is thus half-complete, with sections missing and only weird strings in their place.

chunky_cache then performs a cache multi-fetch, passing in all the keys it knows about. For any missing keys, the block captured by cache_chunk is executed and returned to the cache. The mix of cached/non-cached chunks are then reinserted into the main block content (and I'm pretty proud of the way it does this, as it's pretty nippy), replacing the key placeholders. A final compiled string is then returned.

Why is this useful?

memcached was originally designed to sit on the same server as your app instances and use spare CPU cycles. Now it tends to be on a separate server (especially when using cloud providers). This introduces variable network latency. Sure, it only takes 1ms to read from your cache for a single call, but for 80 calls that's 80ms. Using multi-fetching allows you to avoid that network penalty for using multiple cache calls in a view.