Blog

Create a cache key from method argument values

In my project ProductWidgets I'm using caching heavily and I happen to be in love with Redis and use it for everything I can. I also really like the concept of key-based cache expiration that 37signals recently wrote about.

Now, in a couple of situations I had to add a lot of variables to the cache key to make sure it caches the right thing. Have a look at this example:

def search(category, query, locale, page, per_page)
  cache_key = [
    'searches',
    category,
    query,
    locale,
    page,
    per_page
  ].join(':')

  Rails.cache.fetch cache_key do
    # Heavy lifting
  end
end

It occurred to me that in situations like this, where I basically want to cache the whole method based on the arguments, there must be some automated way of doing this where you don't have to add each argument to the cache key. Imagine some other developer comes along and adds an argument to the method and forgets to add it to the cache key! I do not want to be involved in that bug hunt...

Automate!

So here's what I came up with:

def search(category, query, locale, page, per_page)
  args = method(__method__).parameters.map { |arg| eval arg[1].to_s }
  cache_key = [
    'searches',
    Digest::MD5.hexdigest(args.join),
  ].join(':')

  Rails.cache.fetch cache_key do
    # Heavy lifting
  end
end

What the line args = method(__method__).parameters.map { |arg| eval arg[1].to_s } does is basically referencing the current method (method(__method__)), retrieving the list of method arguments from it (.parameters which returns an array of arrays, with the name of the argument as the last value in the inner array), and calling eval to get the value of the parameter.

You can then use the gererated array of strings to join it, create a digest from it, and include this in your cache key, which is then always guaranteed to be unique for this specific combination of method argument values.

Problems?

If you have any experience in Ruby, you can immediately see the problem/security issue with this approach: there's a big fat eval involved. As you know, eval is pure evil, and its use is highly discouraged and almost never justified. I couldn't find any other way of retrieving the values of local variables, please correct me in the comments if there's a better way of doing it.

I would say that as long as you tightly control what is passed into a method that using this way of caching and never pass in user-generated content (or content from another source that you can't 100% trust) directly, you should be fine.

Gemify!

What immediately came to my mind when writing this was that it would be even more convenient to apply this technique in a way that the (now deprecated) ActiveSupport memoization works:

class Foo
  include CacheMemoizable

  def search(category, query, locale, page, per_page)
    # Heavy lifting
  end
  cache_memoize :search
end

The cache_memoize method would wrap the complete memoization logic and automatically generate a unique cache key for the method and every unique combination of passed in arguments.

I put it on my todo list! :)

Discuss this post on Hacker News

Ideas? Constructive criticism? Think I'm stupid? Let me know in the comments!