Oct 26, 2008

Rails Fragment Caching Slowness With Regex Expiry

Fragment caching in rails works great.  We use it a lot for MomentVille and easily get most of our responses in under 150ms because it dramatically reduces teh number of queries we need to run for our most common actions.

I did notice some slowness recently though, and was quite confused.  The slowness only occurred on our production servers. Our dev, test, and pre-production servers were all still fast.  I tried using new relic rpm service to help pinpoint the problem, and while it does a great job in helping you track things, it didn’t help me narrow down the problem.

Ultimately I discovered that the problem had to do with how we clear the cache. For some actions we have to expire multiple fragments, so we used regex expiration.  Unfortunately, that is very slow.  It seems that regex expiry compares your regex to each fragment stored, even if you think your regex looks like it’s targeting a directory.

Alexander Dymo had a post about regex expiry of fragment caches in rails that outlined a solution for him.  It helped guide me to a solution that works well for us.  OUr fragment caches are actually structured around the data as opposed to the actions, so related fragments are stored within sub directories.  When we need to clear a bunch at once, we just want to wipe out the whole directory.  So, I created a file called fragment_dir_expiration.rb and put it into my /config/initializers folder.  It looks like this:

# For rails 2.0 and lower
module ActionController
  module Caching
    module Fragments

      #dir is the cache path relative to the cache root
      def expire_fragment_dir(dir, options = nil)
        return unless perform_caching
        self.class.benchmark("Expired fragments in dir : #{dir}") do
          ActionController::Base.cache_store.delete_fragment_dir(dir, options)
        end
      end

      class UnthreadedFileStore

        def delete_fragment_dir(dir, options = nil)
          path = @cache_path + dir
          return unless File.exist?(path) #it's ok to not have the cache dir
          search_dir(path) do |f|
            begin
              File.delete(f)
            rescue SystemCallError => e
              # If there's no cache, then there's nothing to complain about
            end
          end
        end

      end
    end
  end
end

When you want to call this you can call it from an observer with a call like this

class WidgetSweeper < ActionController::Caching::Sweeper
  observe Widget
  def after_save(widget)
    # Expire all the fragments for the updated widget
    expire_fragment_dir("/widget/#{widget.id}/")
  end
end

UPDATE: For rails 2.1 and above use the following:

module ActionController
  module Caching
    module Fragments

      #dir is the cache path relative to the cache root
      def expire_fragment_dir(dir, options = nil)
        return unless perform_caching
        self.class.benchmark("Expired fragments in dir : #{dir}") do
          ActionController::Base.cache_store.delete_fragment_dir(dir, options)
        end
      end

    end
  end
end

module ActiveSupport
  module Cache
    class FileStore

      def delete_fragment_dir(dir, options = nil)
        path = @cache_path + dir
        return unless File.exist?(path) #it's ok to not have the cache dir
        search_dir(path) do |f|
          begin
            File.delete(f)
          rescue SystemCallError => e
            # If there's no cache, then there's nothing to complain about
          end
        end
      end
    end
  end
end