/sidekiq_smart_cache

Cache objects in redis, generated with thundering herd prevention and timeounts via sidekiq

Primary LanguageRubyMIT LicenseMIT

SidekiqSmartCache

Generate and cache objects using sidekiq, with thundering herd prevention and client timeouts.

Say you have a resource that's expensive to calculate (a heavy database query) and would like to use it in a web page.

But you'd rather the web page show an empty value (or a "check back later" placeholder) than take too long to render.

And you'd like to ensure that if there are multiple actions requesting that page at once, that the database only has to fill the query once. (You want to prevent a thundering herd problem)

Usage

Say your Widget class has a method do_a_thing that is sometimes quite expensive to calculate, but returns a value you'd like to include in a web page, as long as the value can be made available in five seconds. Once calculated, the value is valid for ten minutes, and all renderings of the page can show that same value.

In the controller:

promise = SidekiqSmartCache::Promise.new(klass: Widget, method: :do_a_thing, expires_in: 10 * 60)
if promise.ready_within?(5.seconds)
  # Render using the generated thing
  @thing = promise.value
else
  # Render some "try again in a bit" presentation
end

If no other workers are currently calculating the value, this will queue up a sidekiq job to call Widget.do_a_thing. If other workers are currently calculating, it will not start another, preventing the thundering herd.

Then it will wait as much as 5 seconds for a value to be returned.

If in the end, value is nil, offer a default or "try again" presentation to the user.

Also supported: passing arguments, calling an instance method on an object in the database, and explicitly naming your cache tag.

SidekiqSmartCache::Promise.new(
  object: widget, method: :do_an_instance_thing, args: ['fun', 12],
  expires_in: 10.minutes
)

If your results are (or contain) other than the YAML.safe_load list (TrueClass, FalseClass, NilClass, Numeric, String, Array, Hash) plus Symbol and Time, configure with an initializer:

SidekiqSmartCache.allowed_classes = [MyFavoriteClass]

Installation

Add this line to your application's Gemfile:

gem 'sidekiq_smart_cache'

And then execute:

$ bundle

Or install it yourself as:

$ gem install sidekiq_smart_cache

Add an initializer:

Rails.configuration.to_prepare do
  SidekiqSmartCache.logger = Rails.logger
  SidekiqSmartCache.redis_pool = Sidekiq.redis_pool
  SidekiqSmartCache.cache_prefix = ENV['RAILS_CACHE_ID']
end

Model mix-in

Let's assume your User class has a active_user_count class method that is expensive to calculate

Declaring make_class_action_cacheable :active_user_count will add:

  • User.active_user_count_without_caching - always performs the full calculation synchronously, doesn't touch the cache.
  • User.refresh_active_user_count - always performs the full calculation synchronously, populating the cache with the new value.
  • User.active_user_count (the original name) - will now fetch from the cache, only recalculating if the cache is absent or stale.
  • User.active_user_count_if_available will now fetch from the cache but not recalculate, returning nil if the cache is absent or stale.
  • User.active_user_count_cache_tag - the cache tag used to store calculated results. Probably not useful to clients.
  • User.active_user_count_promise - returns a Promise object

Call promise.fetch(5.seconds) to wait up to five seconds for a new value, returning nil on timeout.

Call promise.fetch!(5.seconds) to wait up to five seconds for a new value, raising SidekiqSmartCache::TimeoutError on timeout.

Use make_instance_action_cacheable for the equivalent set of instance methods. Your models must respond to to_param with a unique string suitable for constructing a cache key. The class must respond to find and return an object that responds to the method.

Testing

Setup

bundle install
pushd test/dummy
bundle exec rake db:create db:migrate db:test:prepare
popd
bundle exec bin/test

Tidy

The test database is using sqlite, so rm test/dummy/db/*.sqlite3 is a sincere way to reset context.

Debugging

Test a single file:

bin/test test/model_test.rb -n test_cache_tag_generation

Open a SQL shell on a database:

sqlite3 test/dummy/db/test.sqlite3

Test with a specific Sidekiq version:

BUNDLE_GEMFILE=gemfiles/Gemfile-sidekiq7 bundle exec bin/test

Specify a non-default redis for both sidekiq and the cache:

REDIS_PROVIDER=REDIS_URL REDIS_URL=redis://localhost:6380 BUNDLE_GEMFILE=gemfiles/Gemfile-sidekiq7 bundle exec bin/test

Contributing

Contribution directions go here.

License

The gem is available as open source under the terms of the MIT License.