Categories
optimization Performance rails ruby

The dark side of Rails Russian Doll Caching

Rails Russian Doll Caching is super cool. It’s simple, effective and makes caching much easier to reason about.

There’s a dark side to it though. Not in the negative, evil sense. But rather the hidden, unknown, confusing sense.

Fat disclaimer

Before I start, this is one of those blog posts that I sincerely hope to be totally and utterly wrong about, and feel stupid for posting. If I’m being stupid, and miss something obvious, please be my hero and let me know.

Imperfect world

The main problem starts when your model doesn’t fit the perfect symmetry of the Russian Doll.

The textbook example is models with belongs_to relationships. A todo_item belongs to a list, and a list belongs to a project, etc. Perfect. Works like a charm.

But what about other relationships? for example many-to-many? what if I have a club, and it has many members, and each member has many clubs they belong to? It doesn’t sound like something that would be vastly different and particularly difficult to do with Russian Doll caching, but it is.

The view layer might be fine. Let’s say I have a club view with member info. We might use something like this then.

<% cache club do %>
  <%= render club.members %>
<% end %>

But how do we make sure that when a member is updated, it touches its clubs? we’re out of luck.

There’s no way to have something like this

class Member < ApplicationRecord
  has_many :clubs, through: :memberships, touch: true  # doesn't exist
end

Forbidden fruit, Loose couples

Ok, so then maybe we should define a after_save hook (or after_touch), and manually touch the parents? Ah, but wait! clubs also need to touch members when they update. So we’re into a circular dependency hell. Or a stack level too deep situation.

Right, so actually we can use before_commit. This works rather well, surprisingly. You can do something simple like

class Member < ApplicationRecord
  has_many :clubs, through: :memberships
  before_commit -> { clubs.each(&:touch) }
end

Also, Rails is quite clever on how those updates propagate and batches updates cleverly when you update multiple records.

But this also comes with a couple of dark gotchas…

First of all, before_commit is actually not a public API and you shouldn’t use it.

Second — and this is really confusing and not obvious — those touches won’t cascade to other models. For example:

class Member < ApplicationRecord
  has_many :clubs, through: :memberships
  before_commit -> { clubs.each(&:touch) }
end

class Club < ApplicationRecord
  has_many :members, through: :memberships
  has_one :organization, touch: true
  before_commit -> { members.each(&:touch) }
end

If I update, or touch a club, it would also touch the organization. But if I touch a member, it would touch its clubs, but those clubs won’t touch the organization, even though it’s defined.

So if I have a Russian-Doll-like view that looks like this

organization:

<% cache organization do %>
  <%= render organization.clubs %>
<% end %>

club:

<% cache club do %>
  <%= render club.members %>
<% end %>

member:

<% cache member do %>
  <%= render member %>
<% end %>

and I update one of the members, the organization cache key won’t be updated, and therefore I’ll end up with a stale page.

We can work around this by bringing the children keys into the parent, e.g. our cache key would look like this:

organization:

<% cache [organization, organization.clubs] do %>
  <%= render organization.clubs %>
<% end %>

I hope you can see how this breaks our idyllic symmetry and elegance.

Worse performance with caching on?

And if this isn’t enough, here’s another gotcha for you. Actually this looks like a bug, but at least as of now, there’s no sign of it being fixed, so it’s something to be aware of.

Whilst you can pass a relation to the cache view method, and it would generate an efficient cache key for it, it would also (quietly) expand the relation with a to_a. So if, for the example above, you have a huuuuuge list of clubs in an organization, Rails would accidentally cycle through every club when it’s generating the cache key for you. This might completely kill any performance benefit you might get from caching. Ouch.

You can work around it using ActiveSupport::Cache.expand_cache_key for now. i.e. using

<% cache ActiveSupport::Cache.expand_cache_key([organization, organization.clubs]) do %>
  <%= render organization.clubs %>
<% end %>

Another Russian Doll. Courtesy of Frau-Vintage

Leave a Reply

Your email address will not be published. Required fields are marked *