Builder::RailsCache

Provides a convenient with_cache do ... end wrapper around caching Rails responses, and shared_examples for use in your controller tests.

Contents

[[TOC]]

Example controller method:

  def index
    # Cache the response across all users
    json = with_cache do
      # this code will only be called
      # if the response is already cached,
      # and the cache entry has not expired 
      @records = MyModelClass.where(....)
      # The return value of the block will be 
      # what gets stored in the cache, so it's 
      # simplest to just return the generated JSON
      @records.to_json
    end
    # render whatever we got back from the cache if it was a hit,
    # or whatever the block generated if it was a cache miss
    render json: json, status: :ok
  end

Or to cache a different response for each user :

  def index
    # Cache the response separately for each user:
    json = with_cache(user_id: current_user.id) do
      @records = MyModelClass.where(....)
      @records.to_json
    end
    render json: json, status: :ok
  end

Installation

  1. Add the gem to your Gemfile:
  gem 'builder-rails_cache'
  1. Install the gem
> bundle

NOTE: future versions will automate as much of the following steps as possible - but for now, this must be a manual process

Installing the gem into your application

  1. Include the module in your ApplicationController

(this can either be the global BuilderBase::ApplicationController or just for one specific module if you prefer):

  class ApplicationController < ActionController::Base
    include Builder::RailsCache
  end
  1. Enable caching locally in development mode, if RAILS_CACHE_STORE is given

In config/environments/development.rb, you should see a block something like this, usually around line 18 or so:

  # Enable/disable caching. By default caching is disabled.
  # Run rails dev:cache to toggle caching.
  if Rails.root.join('tmp', 'caching-dev.txt').exist?
    config.cache_store = :memory_store
    config.public_file_server.headers = {
      'Cache-Control' => "public, max-age=#{2.days.to_i}"
    }
  else
    config.action_controller.perform_caching = false
    config.cache_store = :null_store
  end

Add another elsif clause to that block, so that it reads:

  # Enable/disable caching. By default caching is disabled.
  # Run rails dev:cache to toggle caching.
  if Rails.root.join('tmp', 'caching-dev.txt').exist?
    config.cache_store = :memory_store
    config.public_file_server.headers = {
      'Cache-Control' => "public, max-age=#{2.days.to_i}"
    }
  # NEW LINES START HERE ----------------
  elsif ENV['RAILS_CACHE_STORE'].present?
    config.cache_store = ENV['RAILS_CACHE_STORE'].to_sym
  # /END OF NEW LINES -------------------
  else
    config.action_controller.perform_caching = false
    config.cache_store = :null_store
  end
  1. Enable caching in production mode, if RAILS_CACHE_STORE is given

Note: this code will enable caching in all deployed environments (dev, stage, uat, prod etc), but only if the RAILS_CACHE_STORE environment variable is present - so if you're cautious, you can push the code first, and then do a separate MR to provide the config which actually enables the cache in each environment, making it a simple job to revert it if anything goes wrong.

Add these lines to config/environment/production.rb, anywhere inside the Rails.application.configure do ... end block:

  if ENV['RAILS_CACHE_STORE'].present?
    config.cache_store = ENV['RAILS_CACHE_STORE'].to_sym
  end
  1. Include the Builder::RailsCache module in your ApplicationController:
  module BuilderBase
    class ApplicationController
      include Builder::RailsCache
    end
 end

Usage

Adding response-caching to a controller method

For example, assume your controller method looks like this:

  def index
    # Slow query:
    @records = MyModelClass.where(....)
    # maybe some other code is here too

    # ultimately a response is rendered as JSON:
    render json: @records.as_json, status: :ok
  end

You can add a with_cache block around everything involved in generating the JSON - constructing queries, retrieving the data, calling any serializers, etc.

NOTE - The Rails cache ultimately stores strings in Redis (or whatever other cache store you my have configured) So you'll need to split the conversion of the result to JSON, from the rendering of the result back to the caller, like this:

  def index
    json = with_cache do
      @records = MyModelClass.where(....)
      @records.to_json
    end
    render json: json, status: :ok
  end

Options / parameters

with_cache accepts the following parameters, all are optional:

:user_id

This value will form the first part of the cache key

Caching a user-specific response for each user Pass the current users' ID (e.g. current_user.id) to cache a different value for each user. It's important to do this if anything in the code inside the block (including within any serializers called, etc) references the current user

Re-using the same cached value across all users Pass nil (or just don't supply the parameter, as nil is the default) to cache the same value across all users

:cache_time

Determines expiry / TTL of the cache entry. If an entry is in the cache, but older than this value, it will be treated as if there is no cache entry there at all.

Default If no value is given, it will use the value of ENV['RAILS_CACHE_TIMEOUT_SECONDS']. If that environment variable is not defined, or not parseable to an integer, it will default to zero - i.e. effectively disabling the caching

Specifying a number of seconds If the value is an integer, it will use that as the number of seconds for which the cache entry will be considered valid.

Using a particular environment variable If the value is a string or symbol, it will append that string (uppercased) to RAILS_CACHE_TIMEOUT_SECONDS and look for an environment variable with that name to use. For example, if you pass :short or :long this will use the value of the ENV['RAILS_CACHE_TIMEOUT_SECONDS_SHORT'] or ENV['RAILS_CACHE_TIMEOUT_SECONDS_LONG)'] env vars - letting you have different cache timeouts for different methods.

:headers_to_include

An array of header names, the values of which will be included in the key. This might be useful if your application uses, for instance, a language header to render different strings

:key

If a value is given, it will use that value as the cache key. If no value is given (default), it will construct a key using a combination of :

  • the given user_id (optional)
  • the full request URL including all query parameters
  • any headers specified in the given headers_to_include (optional).

If user_id is NOT given, then the same cache key will be used for all users (assuming they pass the same parameters and have the same values of the named headers), so make sure you consider this when introducing caching.

Error handling / returning non-200 status

The contents of the block are not called at all if there's a cache hit - so you may need to put a little thought into how to handle any non-OK status that you might want to return.

For example, this code -

  def show
    @record = MyClass.find(params[:id])
    render json: @record.to_json, status: :ok
  end
  • has a potential edge case in the .find call. It will raise an ActiveRecord::RecordNotFound exception if the ID doesn't exist, which usually gets caught elsewhere and a 404 status returned. For this case, an exception will still bubble out of the block and be handled in the normal way, so this code should not need modifying.

But if your controller method decides on response status without raising an exception, modification may be needed. For example:

  def show
    @record = MyClass.find(params[:id])
    if @record.group_id != current_user.group_id
      render json: { errors: ["You don't have permission to read that record"] }.to_json, status: :forbidden
    else
      render json: @record.to_json, status: :ok
    end
  end

This code may need a slight alteration to handle this case, something like this:

  def show
    result = with_cache do
      @record = MyClass.find(params[:id])
      if @record.group_id != current_user.group_id
        'forbidden'
      else
        @record.to_json
      end
    end

    if result == 'forbidden'
      render json: { errors: ["You don't have permission to read that record"] }.to_json, status: :forbidden
    else
      render json: result, status: :ok
    end
  end

Testing caching behaviour

Shared examples

The gem provides three shared examples, which you can add to your controller tests as follows:

Assuming your controller method looks like the example above, you can use these shared examples in your specs like this:

  describe 'GET :index' do
    # define the object & method which your caching block
    # surrounds:
    let(:object_with_cache_miss_method) { MyModelClass }
    let(:method_called_on_cache_miss) { :where }

    # two user accounts, so that it can test the caching behaviour
    # across different users accessing the same methods
    let(:account_1) { ...user  object 1... }
    let(:account_2) { ...user  object 2... }

    # test the universal cache behaviour
    it_behaves_like 'a cached method'

    # ...and EITHER
    it_behaves_like 'it has a different cache entry for each user'
    # OR
    it_behaves_like 'it has the same cache entry for all users'
  end

That will test for standard caching behaviour, using an expectation like:

expect(object_with_cache_miss_method).to receive(:method_called_on_cache_miss)

to signal a cache miss, and:

expect(object_with_cache_miss_method).not_to receive(:method_called_on_cache_miss)

to signal a cache hit.

You should ultimately get results that look something like this:

      behaves like a cached method
        caching
          when the cache_timeout is more than zero
            hitting the url twice for the same user with the same params
              does not calls the code surrounded by with_cache the second time
              returns the same response each time
              after the cache expiry
                calls the code surrounded by with_cache both times
            hitting the url twice for the same user with different params
              calls the code surrounded by with_cache both times
      behaves like it has a different cache entry for each user
        hitting the url twice for two different users with the same params
          calls the code surrounded by with_cache both times

Development

After checking out the repo, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on Builder's GitLab at https://gitlab.builder.ai/cte/alistair-davidson/builder-rails_cache.