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