ActiveRecord observers in gems/plugins
I’ve recently been working on a caching tool for Rails that has a declarative API for invalidating caches when objects of specific types are changed. The idea is that we should be able to tell the caching system that specific types of caches are automatically invalidated for a given type:
cache_config.article_cache :expiration_types => [ :article ]
This requires that the gem be able to set up an ActiveRecord observer in order to do the requested invalidations. There are a couple of non-obvious challenges here that seem worth working through. The first issue is that the observer needs to be added to the list of ActiveRecord::Base.observers in order to be fired correctly. One option is to require the user to set this up in their environment file or in an initializer, the standard places for this sort of thing, but that pretty much ruins any sense of transparency you might have had. A better option is to set it up yourself in the rails initializer of your gem. The three big lessons here are:
- If you create a rails/init.rb file in your gem, it will be executed as the rails app is initialized.
- When this file is executed, the Rails::Configuration object used in environment.rb is in scope, and
- The Rails::Configuration object has some lovely hook methods you can use to initialize your observer
config.after_initialize do
ActiveRecord::Base.observers << CacheAdvance::ActiveRecordSweeper
end
OK, so we’re all set, right? Unfortunately, your awesome observer is just about to fail miserably in development mode. The problem there is that while the observer in your gem isn’t going to get reloaded with every request, the models that it’s observing will, and that will break the observer/observed relationship. The solution is to force the observer to restate what it observes, and fortunately Rails has a callback to help us do just that:
config.after_initialize do
if config.action_controller.perform_caching
ActiveRecord::Base.observers << CacheAdvance::ActiveRecordSweeper
# In development mode, the models we observe get reloaded with each request. Using
# this hook allows us to reload the observer relationships each time as well.
ActionController::Dispatcher.to_prepare(:cache_advance_reload) do
CacheAdvance::ActiveRecordSweeper.instance.reload_sweeper
end
end
end
The to_prepare method in ActionController::Dispatcher allows you to specify a block that will get called before the first request in production mode and before each request in development mode, and we can use that block to restate the observer relationships:
class ActiveRecordSweeper < ::ActiveRecord::Observer
def reload_sweeper
observed_classes.each do |klass|
klass.name.constantize.add_observer(self)
end
end
end
And with that you should be observing like a champ.