Ryan Alyn Porter

Web and mobile software developer

Simple Redis Caching in Ruby

A utility method for caching the result of any block of Ruby code.

Redis is a simple and very fast key-value store, that can be used for all kinds of things. Resque, for example, is a system built on Redis for processing background jobs or even scheduled jobs. Redis can be used for all kinds of different things, and so it has a very generalized API that doesn’t make any assumptions about how you’re going to use it. The Redis API includes simple methods like get and set and expire. And the Ruby gem for Redis is a thin layer over the standard Redis API.

Caching expensive operations with Redis

But when most people use Redis, they tend to use it for caching values in a web application, like you would use memcached. And if you use Redis for caching, then you might find yourself writing the same sort of code structure over and over:

1
2
3
4
5
6
7
8
9
10
11
12
if (something = redis.get(redis_key = "the cache key")).nil?
  redis.set(
    redis_key,
    (something =
      # Some really slow calculation or data access happens here.
    )
  )
  redis.expire redis_key, 300 # seconds = 5 minutes.
end

# Use the "something" value that was calculated (or cached) above.
use(something)

It’s great that Redis#get and Redis#set and Redis#expire are all so simple. But if you’re going to wrap expensive operations in Redis caching frequently, then what you really need is a Redis#cache method.

Monkey patching to the rescue

With Ruby, you can monkey patch anything, so it’s not difficult to add a new convenience method to the Ruby bindings for Redis. We can just open the Redis class and drop in a new method. You can simply add a file called lib/redis_cache.rb to a Ruby project in order to add a cache method ot the Redis API:

lib/redis_cache.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Redis

  def cache(key, expire=nil)
    if (value = get(key)).nil?
      value = yield(self)
      set(key, value)
      expire(key, expire) if expire
      value
    else
      value
    end
  end

end

The new Redis#cache method accepts three things: a key argument, an optional expire argument, and a block of code. First, it checks Redis for a value at the given key. If one exists, then it returns that value immediately. If one doesn’t exist, then it uses the code block to generate a value. Then it sets the Redis key to that value. Then it sets the expiration, in seconds, on that key, if there was an expiration argument provided.

This simple code teaches Redis to speak the language of caching, simplifying your high-level application code. Instead of the code pattern shown in the first code sample, distracting the reader from the problem at hand with caching details, the application code can be all about the values that it wants to calculate, with caching wrapped unobtrusively around the meat of the solution code.

For example, from the simple unit tests for the Redis#cache method:

test/unit/redis_cache_test.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
require File.dirname(__FILE__) + '/../test_helper'
require 'redis'
require 'redis_cache'

class RedisCacheTest < Test::Unit::TestCase

  def test_cache_block_in_redis
    redis = Redis.new
    assert 42, redis.cache('key') { 42 }
    assert 42, redis.cache('key') { assert false,
      'This should never be executed' }
  end

  def test_cache_method_passes_redis_argument_to_block
    redis = Redis.new
    assert Redis, redis.cache('key') {|redis| redis.class }
  end

end

If you have a do_something method that takes a long time to complete, then you can cache that method at the key “key” with redis.cache('key') { do_something }. Simple.

Recalculate every time

You might want to disable caching in development and test modes. You can add support for disabling caching by adding a second optional argument to the Redis#cache method:

lib/redis_cache.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Redis

  def cache(key, expire=nil, recalculate=false)
    if (value = get(key)).nil? || recalculate
      value = yield(self)
      set(key, value)
      expire(key, expire) if expire
      value
    else
      value
    end
  end

end

If recalculate is true, then the code block will be executed every time. So you can make that value true in development and test modes like this:

1
2
3
4
value = redis.cache('key', 60,
  ['test', 'development'].include? Rails.env) do
  "This will always happen."
end

Add timeout and default parameters

Generally when you use this kind of caching, you’re using it to cache the results of some operation that’s really slow. If that operation is really slow because it involves the network, then maybe sometimes it might time out, and you might want to specify a default value to use instead when it times out. It’s easy to add support for a timeout using Ruby’s Timeout class, which is supported all the way back to Ruby 1.8.6.

At this point, it’s definitely time to switch to named parameters, so that the code that calls this method will be more clear and readable.

lib/redis_cache.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Redis

  def cache(params)
    key = params[:key] || raise(":key parameter is required!")
    expire = params[:expire] || nil
    recalculate = params[:recalculate] || nil
    expire = params[:expire] || nil
    timeout = params[:timeout] || 0
    default = params[:default] || nil

    if (value = get(key)).nil? || recalculate

      begin
        value = Timeout::timeout(timeout) { yield(self) }
      rescue Timeout::Error
        value = default
      end

      set(key, value)
      expire(key, expire) if expire
      value
    else
      value
    end
  end

end

Example usage

Here’s an example of using the new Redis#cache method in a Rails app to cache the results of a call to the Twitter API, with a five-sectond timeout. This example will return a default value of nil if the Twitter API times out. But you could also pass a :default => { 'something?' } parameter in other scenarios.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
require 'redis'
require 'twitter'
require 'json'

redis = Redis.new

twitter_user = 'ryanalynporter'

tweet = redis.cache(
    :key => "twitter-user-timeline-#{twitter_user}",
    :expire => 300, # seconds, 5 minutes
    :timeout => 5,  # seconds
    # Recalculate every time when this is true.
    :recalculate => ['test', 'development'].include?(Rails.env)
  ) do
    Twitter.user_timeline(twitter_user, :count => 1).first.to_json
  end

unless tweet.nil?
  tweet = JSON.parse(tweet)

Comments