Close and Go BackBack to Viget

Named Scope Caching

Brian Landau
Brian Landau, Web Developer, August 07, 2008 7

When working on high-traffic Rails sites, it often becomes necessary to find ways to improve performance with caching. One place we’ve found this is most convenient and easy-to-do is by caching an ActiveRecord result set for models that change rarely or not at all. An easy example of this is a Category model.

Often times, you have a categorization hierarchy that will never or rarely change over the life of an application. Ideally you would fetch the results once from the database and never have to again. So how do we go about caching this? First let’s look at our model and create a named_scope for it:

class Category < ActiveRecord::Base
  acts_as_tree
  named_scope :find_top_level, :conditions => 'categories.parent_id IS NULL',
                              :order      => 'categories.name'
end

Next, we need to create create a method that fetches the results for our new scope and caches it in a class variable. It should also only do caching if in production environment (alternatively or additionally, we could use the ActionController.perform_caching config value), as this can cause problems in tests.

def self.top_level
  unless ('production' == RAILS_ENV) && ActionController.perform_caching
    @@top_level_cache = self.find_top_level
  else
    @@top_level_cache ||= self.find_top_level
  end
end

Finally, we need to create a method to invalidate our cache when records are saved or deleted. Since we know this isn’t happening often (if at all), this should rarely be performed but is a good safeguard so we know our cache is current.

after_save :reset_cached_finder
after_destroy :reset_cached_finder

def reset_cached_finder
  @@top_level_cache = nil
end

This is something that we could easily see doing in a number of models for a number of finders. Since this involves a lot of similar code, it would be great if we could create some meta code that would allow us to define these caches with a simple one liner.

Maybe with syntax like cache_scopes :cached_method_name => :scope_name.
For example:

cache_scopes :top_level => :find_top_level

Well here’s the code that does that:

Suggestions for improvement are encouraged, which is easily done with GitHub’s new gist.

Enjoy and have fun caching!

Tom-Eric said on 08/08 at 03:24 AM

Does chaining of named scopes still works when you use caching?

Brian Landau said on 08/08 at 10:44 AM

In my quick testing of this it does still work if you chain items after the cache name:

Category.top_level.other_scope

But important to note is this will still make a call to the database, it will not take advantage of the cache. Of course the actual scope, in this case find_top_level is unchanged and so you can still do any chaining with that, which also of course won’t use the cache.

As a final note though if you’re needing to do much chaining, caching in this way may not be best for your particular situation. The idea of the cache is if you need to retrieve the exact same result set over and over again, and it rarely changes you shouldn’t have to hit the database.

Lenart Rudel said on 05/25 at 04:15 AM

I’ve been looking for similar solution. I wanted to use Rails.cache instead of class variable. I’ve noticed that caching named_scope does not work! Does anyone else have similar experience?

Here’s the code: http://pastie.org/488836

Adam said on 06/04 at 05:16 PM

I’ve been looking for an elegant way to solve this problem for awhile and you nailed it!  I had a lot of constants defined in various models throughout my app that hit the database to retrieve their values.  Ideally, I just wanted to run these queries once (when the app was initialized) and then have them cached on subsequent calls.  Needless to say, I ran into lots of problems running migrations because the queries would try execute before the tables were created.  Even tried config.after_initialize with no luck.  It was hard to find a ‘best practice’ for handling this type of situation until I came across your blog.

What’s the best way to use this in my app?  As a plugin or just drop it in /lib?

Thanks!

Andrew Chase said on 06/05 at 12:27 PM

I’ve used a similar approach to this in the past, but as soon as you deploy the app across across multiple mongrels (or whatever other app container you’re running) the cache invalidation logic fails. One mongrel may receive a save/create/delete that triggers local cache invalidation, but meanwhile all the other instance still think their cache data is legit. I agree that there are times when caching this type of data can be useful, but the data being cached should either never change or a more sophisticated (shared) caching layer should be used (e.g. memcache).

Cheers,
Andrew

Brian Landau said on 06/05 at 01:15 PM

Adam:
Glad, you’ve found this useful. And yes you can just throw drop that gist into your /lib directory.

Andrew:
You’re absolutely correct. If you’re running more than one instance of the app this method will fail. In that case instead of caching to a class variable you would cache to memcache or it’s equivalent.

Adam said on 06/05 at 03:58 PM

Thanks for pointing out the multi-app caching gotcha with this technique.  I’ll be running more than one instance of my app in the future so I’ll probably use memcache.  Your code example definitely helped me figure out how to handle this type of stuff in Rails though!

Name:

Email:

URL:

Not a robot? Prove it by entering the word below.


Some HTML (strong, a, em) is allowed.

Notify me of follow-up comments?

We're the Developers

at Viget Labs. We write about web development trends, tips, best practices, industry events, and our projects — all with an emphasis on Ruby on Rails.

Recent Comments

For translating strings you can use Rails I18n backend instead of using inflectors.

The `typus_human_name` is a patch to fix a problem in `human_name` [1].

[1] https://rails.lighthouseapp.com/projects/8994/tickets/2120-humanize-and-human_name-dont-separate-words

Contact Us

Have any questions, comments, ideas, or secrets to share? Let us know.


Sorry, you need to have Javascript enabled to use this form. (Don't blame us, blame the spammers!) If you'd like to contact us, please visit our Contact page.