Skip to content

Instantly share code, notes, and snippets.

@bricker
Last active March 14, 2018 00:33
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save bricker/6255064 to your computer and use it in GitHub Desktop.
Save bricker/6255064 to your computer and use it in GitHub Desktop.
Promises in Rails callbacks, using after_save and after_commit together.

"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:

  1. User publishes Post. after_save callback is run, and the homepage_posts_list fragment is expired. At this point, the post hasn't been committed to the database.
  2. 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.
  3. Record is committed to the database.
  4. 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.

@jonathan-mui
Copy link

Nice simple solution!

@johnc219
Copy link

For some scenarios, using ActiveModel's previous_changes could be useful. For example, after_commit :send_notification, if: -> { self.previous_changes.key? "billing_status" }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment