Friday, June 15, 2007

Proper cache expiry with after_commit

I'm using cache_fu to handle all of my ActiveRecord memcaching these days. It is an amazingly simple and powerful addition to AR to easily use memcached, and I highly recommend it. I've really only had this one problem with it, the suggested cache expiry is:

after_save :expire_cache
after_destroy :expire_cache

I noticed a troubling thing about using after_save to expire caches, after_save is still within the transaction that save is automatically wrapped in. I assume this is to protect against an error during the after_save call, so you can roll back the database to it's previous state. When you're expiring caches though, you need to make sure the underlying data is actually changed before you expire the cache, otherwise you risk caching the old data before the new data is committed. This could go unnoticed, but if you're using optimistic locking, it raises exceptions when you try to save stale data.

To be a little more clear, a normal post.save looks something like this:

SQL (0.000363)   BEGIN
Post Update (0.000572)   UPDATE posts SET "created_at" = '2007-06-13 21:15:40.212720',
"last_edited_at" = NULL, "user_id" = 8, "body" = 'hello world', "updated_at" = '2007-06-16
18:45:24.412415', "topic_id" = 7 WHERE "id" = 11
SQL (0.000893)   COMMIT

Notice how everything is wrapped nicely with a BEGIN and COMMIT.

Now after adding an after_save method that simply logs "Expire Cache!" we can see the order of events:

SQL (0.000363)   BEGIN
Post Update (0.000591)   UPDATE posts SET "created_at" = '2007-06-13 21:15:40.212720',
"last_edited_at" = NULL, "user_id" = 8, "body" = 'hello world', "updated_at" = '2007-06-16
18:49:49.869814', "topic_id" = 7 WHERE "id" = 11
Expire Cache!
SQL (0.000874)   COMMIT

If we imagine that Expire Cache! took 10 seconds to run, we can see that there is a measurable amount of time between Expire Cache! and the COMMIT. Database servers don't want to hand out incomplete or just wrong data, so they will serve the "old" data during this time, switching to the UPDATEd data after the COMMIT. If this post gets requested again during that 10 second window, it will be cached as the "old" data. Now we have a database with one value, and a cache with another, but they both think they have the correct data. We need to move the expire cache operation outside of the commit to remove this problem area.

Enter after_commit

We add a callback right after any save or destroy operation, which does the operation and then calls after_commit. Theoretically this works with update_attribute and any other AcitveRecord write operation, but I haven't fully tested those cases.

module ActiveRecord
  class Base

    class << self


      # Class methods


      def after_commit(*callbacks, &block)
        callbacks << block if block_given?
        write_inheritable_array(:after_commit, callbacks)
      end

    end


    # Instance Methods


    def save_with_after_commit_callback(*args)
      value = save_without_after_commit_callback(args)
      callback(:after_commit)
      return value
    end
    alias_method_chain :save, :after_commit_callback


    def save_with_after_commit_callback!(*args)
      value = save_without_after_commit_callback!
      callback(:after_commit)
      return value
    end
    alias_method_chain :save!, :after_commit_callback


    def destroy_with_after_commit_callback
      value = destroy_without_after_commit_callback
      callback(:after_commit)
      return value
    end
    alias_method_chain :destroy, :after_commit_callback

  end
end

Since we wrapped save and destroy to call after_commit, we need only add one callback to expire caches now:

after_commit :expire_cache

And here is the log:

SQL (0.000365)   BEGIN
Post Update (0.000772)   UPDATE posts SET "created_at" = '2007-06-13 21:15:40.212720',
"last_edited_at" = NULL, "user_id" = 8, "body" = 'hello world', "updated_at" = '2007-06-16
19:05:55.108429', "topic_id" = 7 WHERE "id" = 11
SQL (0.000929)   COMMIT
Expire Cache!

8 comments:

Anonymous said...

Doesn't work for me with Rails 1.2.3. Goes into infinite loops.

Ewout @ YelloYello.com said...

thanks for this solution, it works like a charm (for rails 1.2.3).. there's only one problem with it: in this implementation it's not really an after_commit, it also gets run when the save fails... If you really want this to be an after_commit, change:
callback(:after_commit)
to:
callback(:after_commit) if value
in the first method (the save without exclamation mark).. For save! this isn't necessary (if an exception is thrown, the callback call isn't reached).. For destroy i'm not too sure (i believe it always returns the object that was destroyed, unless some other custom hook-in (through alias_method_chain or anything) screws things up)

Paul Dowman said...

In Rails 2.2 (and maybe earlier?) you can replace the anonymous class block (the whole "class << self...end" with just "define_callbacks :after_commit"

In fact, it didn't work for me until I did this.

Craig said...

To make this easy to use I've made a plugin out of the code:

http://github.com/craigw/callback_after_commit

Elijah Miller said...

If you search for
after_commit on github you will see there is a lot of activity around this small snippet of code.

after_commit has been incorporated into a few projects with slight modifications. A few people have taken the idea even further and really solidified the implementation with tests for multiple record commits and the like.

Anonymous said...
This comment has been removed by a blog administrator.
Anonymous said...
This comment has been removed by a blog administrator.
Anonymous said...
This comment has been removed by a blog administrator.