A story of unclear `Rails.cache.fetch` behavior

This might be helpful to you if you don’t understand why your Rails.cache.fetch keeps missing, even though the value is persisted.

Let’s do a quick Friday night story time.

The other day I did this:

value = Rails.cache.fetch([current_user, 'value']) { current_user.get_value }

And later discovered that the cache was always showing a “miss” in my controller (but never in the rails console). What.

Even weirder, if I changed [current_user, 'value'] into [current_user.id, 'value'] the issue was gone. Cache was always a “hit”.

Even weirder still, the cache key in Redis definitely existed, and looked almost identical in both cases: "users/[id]/value" vs "[id]/value", but this initially sent me on a wild goose chase “what is it about users/ that breaks things?”

After a bunch of debugging and messing with the Rails source, I discovered that Rails actually goes through every element of the cache key array, and tries to get its cache_version if such method exists. User model (and all other models) does respond to cache_version, using the updated_at timestamp by default as the version. And because my controller kept updating last_request_at on the user, I kept getting misses.

Mind you, I kinda knew something like that was probably happening. I didn’t know all the details of how “Russian doll caching” is facilitated, but I knew it was something like that.

What threw me completely off guard is that when Rails writes down your value into the cache, it also writes the cache_version alongside your value. In the value, not in the key. It encodes the whole value as a binary that isn’t human-readable, and embeds the version into that binary. Then, when it reads the value, it considers the value missing if there’s a version mismatch, despite the value being stored at that key!

So… beware if you’re using Rails.cache.fetch: it misses every time you update the model that you included in the cache key, even when the generated key string matches and exists in your cache store.

If you want to get a more controlled behavior, use model.id, not model in your cache key array.

I’m almost certain that there’s a good reason why it works this way, but for someone new to Rails this can cause a lot of frustration, so just want to put it somewhere on the internet.

4 Likes

You can override the cache_version method to better suite your needs as well. This can be a double edged sword if you ever do need to cache something based on the record being updated.

https://api.rubyonrails.org/v7.0.8/classes/ActiveRecord/Integration.html#method-i-cache_version

What does it compare the value of cache version with? I understand the cache version is encoded as a part of the value. But what is it compared to?

(I’d guess the cache version is part of the cached data a.k.a bake, so it seems like it’s where it belongs. But it’s just a uneducated guess)

It takes the fresh current_user.cache_version and compares it against the one stored in the cached value, as far as I understand.

1 Like