"Russian-Doll Caching" is great. It embraces the Rails (and Ruby) goal to "make the developer happy". And it does. Not having to worry about cache expiration is superb.
It has its limits, though. If you're trying to avoid any database queries, russian-doll caching will not work for you. If you are trying to represent thousands, or even hundreds, of objects under a single cache fragment, russian-doll caching is not the best option.
We use it whenever it makes sense, but sometimes we just have to bite the bullet and expire a cache fragment manually. When you want to start manually expiring cache on a fairly busy website, you have to start considering race conditions. I recently ran into the following scenario:
class Post < ActiveRecord::Base
after_save :expire_cache
# ...
private
def expire_cache
if self.status_was == UNPUBLISHED && self.status == PUBLISHED
Rails.cache.delete("homepage_posts_list")
end
end
Do you see the problem? Let me walk you through it:
- User publishes Post.
after_save
callback is run, and thehomepage_posts_list
fragment is expired. At this point, the post hasn't been committed to the database. - Visitor loads the homepage. The
homepage_posts_list
fragment gets a MISS, and queries the database for the collection of posts. This happens just quick enough, and at just the right time, that the post still hasn't been committed to the database. - Record is committed to the database.
- Colleague e-mails you complaining that the post on the homepage is still showing the old title.
So, how do we fix this rare edge case? You might be thinking, "Just use after_commit
!". Okay, let's try it out:
class Post < ActiveRecord::Base
after_commit :expire_cache
# ...
private
def expire_cache
if self.status_was == UNPUBLISHED && self.status == PUBLISHED
Rails.cache.delete("homepage_posts_list")
end
end
I'll save you the trouble: This doesn't work either. Why? By the time Rails gets to the after_commit
callback, the record has been reloaded and the Dirty attributes have been cleared out, so self.status_was
returns the current self.status
. The behavior of attribute_was
on a non-dirty record is totally confusing, in my opinion, but that's for another post. So, self.status_was == UNPUBLISHED && self.status == PUBLISHED
cannot possibly be true, and therefore the cache will never expire.
How do we solve this? Let's make a promise to the life cycle of our object. jQuery has a similar API that basically says, "I can't do this right now, but when I am able to do it, I will.". In jQuery, it's used with AJAX requests to ensure that code gets run after the request has returned, since the script doesn't wait for it to return before moving on (that's the whole point of AJAX).
We're going to use both callbacks: after_save
, which gives us access to the dirty object (just before it is reloaded), and after_commit
, which ensures that the data has been saved to the database and will turn up in a query:
class Post < ActiveRecord::Base
after_save :promise_to_expire_cache # Before the data is committed to the dabase
after_commit :expire_cache # After the data is committed
# ...
private
def promise_to_expire_cache
if self.status_was == UNPUBLISHED && self.status == PUBLISHED
# This is the promise. We're saying, "All the conditions were met, so when
# the time is right, we will expire the cache".
@_will_expire_cache = true
end
end
def expire_cache
# At this point, we don't have access to the dirty attributes - all we have
# to go by is the promise. If the promise was never made, then the condition
# will be false and the cache won't be deleted.
if @_will_expire_cache
Rails.cache.delete("homepage_posts_list")
end
# Clear out the promise now that it has been fulfilled.
@_will_expire_cache = nil
end
end
And now you can use your Dirty attributes to expire cache in a way not (as easily) susceptible to race condition!
There are a few different ways you might be able to handle this, but I like this approach. If you have a better suggestion, please let me know.
For some scenarios, using ActiveModel's previous_changes could be useful. For example,
after_commit :send_notification, if: -> { self.previous_changes.key? "billing_status" }