Made Tech Blog

Cache sweepers in Rails: Invalidating caches from outside the controller

The problem

Invoke cache sweeper through a rake task, which will loop over all the products in the database and expire the cache for each one. There is a fair amount of setup required for this to work. I will walk you through how to do this with a series of hacks.

The rake task

Create the rake task /lib/tasks/cache.rake which invokes ProductSweeper and calls the clear_all class method on it.

namespace :cache do 
  desc 'Clears cache' 
  task :clear => :environment do 
    ProductSweeper.clear_all 
    puts 'All caches cleared' 
  end
end

The Product Sweeper

I added the expire_all method to the product sweeper /app/sweepers/product_sweeper.rb, which would be invoked by the rake task.

class ProductSweeper   
  observe Product  

  def after_save(object)    
    clear_cache(object)  
  end  

  def self.expire_all    
    new.clear_all  
  end  

  def clear_all    
    Product.all.each do |product|      
      clear_cache product    
    end  
  end  

  def clear_cache(product)    
    expire_action :controller => '/product',                   
                  :action => :show,                   
                  :product => product    
  end  
end

Testing it out

I ran the task and everything seemed to have worked fine. It printed ‘All caches cleared’, and there were no errors, but I expected to see the output below in the log file:

Expire fragment views/localhost:5000/products/testproduct

I double checked that it had indeed not cleared the caches by looking at the server log output when visiting the product on the front end. It kept on reading from the cache, and it was never invalidated. No errors to diagnose, it just failed silently.

Diving deeper

So I fired up the debugger and followed the code execution path into Rails. You can get the debugger by adding the following to your Gemfile.

gem 'debugger'

Then add add a method call to debug above the expire_cache method in the product sweeper.

Unsurprisingly it turns out that when a call is made to the expire_cache method on a class that inherits from ActionController::Caching::Sweeper, the first thing it hits is a method_missing method.

This source code looks like this looks like this:

def method_missing(method, *arguments, 
  return unless @controller
  @controller.__send__(method, *arguments, &block)
end

Note that it returns unless an instance variable named @controller exists. There’s our problem.

Initial diagnosis

To get this to work, we need to find out what @controller should contain so that we can populate it ourselves. We can find this out by printing @controller while a sweeper is being invoked during normal operation. Meaning through an update, delete or create action on the ProductsController, printing out @controller yields a great wall of text which contains all the values stored in it. The important part is that the object contained in it is ProductsController, and as we know ProductsController inherits from ApplicationController.

Faking it

Now that we know what that method_missing is looking for to send the message to the ApplicationController, we can modify the sweeper to create a new instance of ApplicationController and assign it to the @controller variable. We modify our ProductSweeper to include a new method named instantiate_controller which has the responsibility of populating @controller if it does not exist yet.

class ProductSweeper 
  observe Product  

  def after_save(object)    
    clear_cache(object)  
  end  

  def self.expire_all    
    new.clear_all  
  end  

  def clear_all    
    instantiate_controller    

    Product.all.each do |product|      
      clear_cache product    
    end  
  end  

  def clear_cache(product)    
    expire_action :controller => '/product',          
                  :action => :show,  
                  :product => product    
  end  

  protected    

  def instantiate_controller      
    @controller ||= ApplicationController.new    
  end
end

After implementing this, we get a new error:

NoMethodError (undefined method 'host' for nil:NilClass)

After some research I found that manually populating the @controller.request.host value as demonstrated below will fix the error:

protected

def instantiate_controller @controller ||= ApplicationController.new 
  if @controller.request.nil? 
    @controller.request = ActionDispatch::TestRequest.new 
    @controller.request.host = Rails.application.config.host 
  end
end

Note that we pass in a fake Request object, and then set the value of the host on that.

Victory

With this hack in place, and after restarting the server, we finally get to see that message in the server log that we were looking for:

Expire fragment views/localhost:5000/products/testproduct (0.1ms)

The feature was now fully functional, and I could clear the caches on the command line with:

bundle exec rake cache:clear

This was also built into an ActiveAdmin dashboard, where a button could be pressed which would call the rake task. All of it was also successfully wrapped with Cucumber tests, so mission accomplished.

Resources
Rails guides: Caching
Rails API docs
ActionController::Caching::Fragments
Railscasts Caching

This application was built with Rails 3.2.13

About the Author

Avatar for Emile Swarts

Emile Swarts

Lead Software Engineer at Made Tech

All about big beards, beers and text editors from the seventies.