lock_and_cache

Most caching libraries don't do locking, meaning that >1 process can be calculating a cached value at the same time. Since you presumably cache things because they cost CPU, database reads, or money, doesn't it make sense to lock while caching?

134
8
Ruby

LockAndCache

Build Status
Code Climate
Gem Version
Security
Inline docs

Lock and cache using redis!

Most caching libraries don’t do locking, meaning that >1 process can be calculating a cached value at the same time. Since you presumably cache things because they cost CPU, database reads, or money, doesn’t it make sense to lock while caching?

Quickstart

LockAndCache.lock_storage = Redis.new db: 3
LockAndCache.cache_storage = Redis.new db: 4

LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do
  # get yer stock quote
  # if 50 processes call this at the same time, only 1 will call the stock quote service
  # the other 49 will wait on the lock, then get the cached value
  # the value will expire in 10 seconds
  # but if the value you get back is nil, that will expire after 1 second
end

Sponsor

Faraday logo

We use lock_and_cache for B2C customer intelligence at Faraday.

TOC

Theory

lock_and_cache

  1. returns cached value (if exists)
  2. acquires a lock
  3. returns cached value (just in case it was calculated while we were waiting for a lock)
  4. calculates and caches the value
  5. releases the lock
  6. returns the value

As you can see, most caching libraries only take care of (1) and (4) (well, and (5) of course).

If an error is raised during calculation, that error is propagated to all waiters for 1 second.

Practice

Setup

LockAndCache.lock_storage = Redis.new db: 3
LockAndCache.cache_storage = Redis.new db: 4

It will use this redis for both locking and storing cached values.

Locking

Just uses Redis naive locking with NX.

A 32-second heartbeat is used that will clear the lock if a process is killed.

Caching

This gem is a simplified, improved version of https://github.com/seamusabshere/cache_method. In that library, you could only cache a method call.

In this library, you have two options: providing the whole cache key every time (standalone) or letting the library pull information about its context.

# standalone example
LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10) do
  # ...
end

# context example
def stock_price(date)
  lock_and_cache(date, expires: 10) do
    # ...
  end
end
def lock_and_cache_key
  company
end

Standalone mode

LockAndCache.lock_and_cache(:stock_price, company: 'MSFT', date: '2015-05-05') do
  # get yer stock quote
end

You probably want an expiry

LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10) do
  # get yer stock quote
end

Note how we separated options ({expires: 10}) from a hash that is part of the cache key ({company: 'MSFT', date: '2015-05-05'}).

One other crazy thing: nil_expires - for when you want to check more often if the external stock price service returned nil

LockAndCache.lock_and_cache(:stock_price, {company: 'MSFT', date: '2015-05-05'}, expires: 10, nil_expires: 1) do
  # get yer stock quote
end

Clear it with

LockAndCache.clear :stock_price, company: 'MSFT', date: '2015-05-05'

Check locks with

LockAndCache.locked? :stock_price, company: 'MSFT', date: '2015-05-05'

Context mode

“Context mode” simply adds the class name, method name, and context key (the results of #id or #lock_and_cache_key) of the caller to the cache key.

class Stock
  include LockAndCache

  def initialize(company)
    [...]
  end

  def stock_price(date)
    lock_and_cache(date, expires: 10) do
      # the cache key will be StockQuote (the class) + get (the method name) + id (the instance identifier) + date (the arg you specified)
    end
  end

  def lock_and_cache_key # <---------- if you don't define this, it will try to call #id
    company
  end
end

The cache key will be StockQuote (the class) + get (the method name) + id (the instance identifier) + date (the arg you specified).

In other words, it auto-detects the class, method, context key … and you add other args if you want.

Clear it with

blog.lock_and_cache_clear(:get, date)

Special features

Locking of course!

Most caching libraries don’t do locking, meaning that >1 process can be calculating a cached value at the same time. Since you presumably cache things because they cost CPU, database reads, or money, doesn’t it make sense to lock while caching?

Heartbeat

If the process holding the lock dies, we automatically remove the lock so somebody else can do it (using heartbeats).

Context mode

This pulls information about the context of a lock_and_cache block from the surrounding class, method, and object… so that you don’t have to!

Standalone mode is cool too, tho.

nil_expires

You can expire nil values with a different timeout (nil_expires) than other values (expires).

Tunables

  • LockAndCache.lock_storage=[redis]
  • LockAndCache.cache_storage=[redis]
  • ENV['LOCK_AND_CACHE_DEBUG']='true' if you want some debugging output on $stderr

Few dependencies

Known issues

  • In cache keys, can’t distinguish {a: 1} from [[:a, 1]]

Wishlist

  • Convert most tests to use standalone mode, which is easier to understand
  • Check options
  • Lengthen heartbeat so it’s not so sensitive
  • Clarify which options are seconds or milliseconds

Contributing

  1. Fork it ( https://github.com/[my-github-username]/lock_and_cache/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Copyright

Copyright 2015 Seamus Abshere