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, ittouch
es itsclubs
? 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 endForbidden fruit, Loose couples
Ok, so then maybe we should define a
after_save
hook (orafter_touch
), and manuallytouch
the parents? Ah, but wait!clubs
also need to touchmembers
when they update. So we’re into a circular dependency hell. Or astack level too deep
situation.Right, so actually we can use
before_commit
. This works rather well, surprisingly. You can do something simple likeclass Member < ApplicationRecord has_many :clubs, through: :memberships before_commit -> { clubs.each(&:touch) } endAlso, 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
touch
es 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) } endIf I update, or touch a
club
, it would also touch theorganization
. But if I touch amember
, it would touch itsclubs
, but those clubs won’t touch theorganization
, 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 ato_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 %>