Jens Krämer

Automatic expiration of cached actions

 |  typo, ruby, rails

last.fm offers various Webservices to retrieve data like the most recently played tracks or your personal top ten. Nice I thought, finally there is a way to provide the three people interested in my music listening habits with the information they want :-)

To make it even easier to let people know you’re Britney Spears’ biggest fan, Typo even comes with a sidebar plugin that displays your recent tracks list from last.fm.

Unfortunately, with page caching turned on this is somewhat pointless as the list will be statically rendered into each page until the next blog post, when the page cache is flushed.

So I was looking for another solution and came up with a separate controller containing an action that only renders the recent tracks list you see at the end of the sidebar. This action is embedded into the page with a render_component call in my theme’s default.rhtml. Right after that call there is some Javascript that updates the playlist whenever somebody views a page.

Not very exciting so far. But I didn’t want to query the Audioscrobbler API on every page hit. The average audio track lasts around 3 or 4 minutes, and that’s how long I wanted to stay the cached playlist around, too.

The usual Rails solution to problems like this seems to be a cron job which regularly deletes cache files, triggering regeneration of the content. This is acceptable for wiping out cached pages once a day or every hour, but when it comes to different life times for different cached actions cron doesn’t fit. Imho.

What I wanted in my controller was something like

caches_action_for :audioscrobbler => 3.minutes

Here’s how I reached this goal:

First, implement the caches_action_for method. To use it, a controller must include the ActionCacheFragmentExpiration module.

module ActionCacheFragmentExpiration def self.append_features(base) super base.extend(ClassMethods) end module ClassMethods def max_cache_ages @@max_cache_ages ||= {} end def caches_action_for(actions) return unless perform_caching use_component_workaround = actions.delete(:use_component_workaround) || false before_filter :expire_old_cache_entry, :only => actions.keys if use_component_workaround around_filter(ActionController::Caching::Actions::ComponentActionCacheFilter.new(*actions.keys)) else caches_action *actions.keys end actions.each { |action, minutes| max_cache_ages[action] = minutes } end end def expire_old_cache_entry expire_fragment( { :action => action_name }, :older_than => self.class.max_cache_ages[:"#{action_name}"]) end end

expire_old_cache_entry is called as a before_filter and removes any already cached output of the current action when it is older than the maximum age given to caches_action_for.

Next, extend the fragment cache (which is used for action caching) to allow for conditional deletion of fragments based on their age:

module ActionController module Caching module Fragments class UnthreadedFileStore def delete_matched(matcher, options={}) search_dir(@cache_path) do |f| if f =~ matcher begin File.delete(f) unless options[:older_than] && File.ctime(f) > options[:older_than].ago rescue Object => e # If there's no cache, then there's nothing to complain about end end end end def delete(name, options={}) real_path = real_file_path(name) File.delete(real_path) unless options[:older_than] && File.ctime(real_path) > options[:older_than].ago rescue SystemCallError => e # If there's no cache, then there's nothing to complain about end end end end end

Third, work around a Rails bug concerning action caching not working when using render_component. More concrete, the caching works, but the cache file is named .cache and gets overwritten whenever another cached action is rendered as a component.

That’s what the :use_component_workaround option of caches_action_for is for. Setting this to true enables an alternative ActionCache implementation that deals with this issue:

module ActionController module Caching module Actions class ComponentActionCacheFilter def initialize(*actions) @actions = actions end def before(controller) return unless @actions.include?(controller.action_name.intern) if cache = controller.read_fragment(controller.url_for(:params => controller.params).split("://").last) controller.rendered_action_cache = true controller.send(:render_text, cache) false end end def after(controller) return if !@actions.include?(controller.action_name.intern) || controller.rendered_action_cache controller.write_fragment(controller.url_for(:params => controller.params).split("://").last, controller.response.body) end end end end end

The trick is to hand the params hash to url_for when computing the fragment file name. That way, controller and action name get included into the fragment file name properly.

To use this style of action caching, put that stuff somewhere in your loadpath (Typo has lib/rails_patch/ for things like this) , require the file in environment.rb, and declare your cached actions with caches_action_for like described above.