module AtomicRedisCache

Constants

DEFAULT_EXPIRATION
DEFAULT_RACE_TTL
MAX_RETRIES
VERSION

Attributes

redis[W]

Public Class Methods

delete(key) click to toggle source

Delete the cache entry completely, including timer

# File lib/atomic_redis_cache.rb, line 81
def delete(key)
  redis.del(key, timer(key)) == 2
end
fetch(key, opts={}, &blk) click to toggle source

Fetch from cache with fallback, just like ActiveSupport::Cache. The main differences are in the edge cases around expiration.

  • when cache expires, we avoid dogpile/thundering herd from multiple processes recalculating at once

  • when calculation takes too long (i.e., due to network traffic) we return the previously cached value for several attempts

Options: :expires_in - expiry in seconds; defaults to a day :race_condition_ttl - time to lock value for recalculation :max_retries - # of times to retry cache refresh before expiring

# File lib/atomic_redis_cache.rb, line 21
def fetch(key, opts={}, &blk)
  expires_in = opts[:expires_in] || DEFAULT_EXPIRATION
  race_ttl   = opts[:race_condition_ttl] || DEFAULT_RACE_TTL
  retries    = opts[:max_retries] || MAX_RETRIES

  now        = Time.now.to_i
  ttl        = expires_in + retries * race_ttl
  t_key      = timer(key)

  if val = redis.get(key)              # cache hit
    if redis.get(t_key).to_i < now     # expired entry or dne
      redis.set t_key, now + race_ttl  # block other callers for recalc duration
      begin
        Timeout.timeout(race_ttl) do   # if recalc exceeds race_ttl, abort
          val = Marshal.dump(blk.call) # determine new value
          redis.multi do               # atomically cache + mark as valid
            redis.setex key, ttl, val
            redis.set t_key, now + expires_in
          end
        end
      rescue Timeout::Error => e       # eval timed out, use cached val
      end
    end
  else                                 # cache miss
    val = Marshal.dump(blk.call)       # determine new value
    redis.multi do                     # atomically cache + mark as valid
      redis.setex key, ttl, val
      redis.set t_key, now + expires_in
    end
  end

  Marshal.load(val)
end
read(key) click to toggle source

Fetch from the cache atomically; return nil if empty or expired

# File lib/atomic_redis_cache.rb, line 56
def read(key)
  val, exp = redis.mget key, timer(key)
  Marshal.load(val) unless exp.to_i < Time.now.to_i
end
write(key, val, opts={}) click to toggle source

Write to the cache unconditionally, returns success as boolean Accepts the same options and uses the same defaults as .fetch() Note that write() ignores locks, so it can be called multiple times; prefer .fetch() unless absolutely necessary.

# File lib/atomic_redis_cache.rb, line 65
def write(key, val, opts={})
  expires_in = opts[:expires_in] || DEFAULT_EXPIRATION
  race_ttl   = opts[:race_condition_ttl] || DEFAULT_RACE_TTL
  retries    = opts[:max_retries] || MAX_RETRIES
  ttl        = expires_in + retries * race_ttl
  expiry     = Time.now.to_i + expires_in

  response = redis.multi do
    redis.setex key, ttl, Marshal.dump(val)
    redis.set timer(key), expiry
  end

  response.all? { |ret| ret == 'OK' }
end

Private Class Methods

redis() click to toggle source
# File lib/atomic_redis_cache.rb, line 90
def redis
  raise ArgumentError.new('AtomicRedisCache.redis must be set') unless @redis
  @redis.respond_to?(:call) ? @redis.call : @redis
end
timer(key) click to toggle source
# File lib/atomic_redis_cache.rb, line 85
def timer(key)
  "timer:#{key}"
end