Rack::Cache in Rails 5

Rack::Cache is a middleware which acts as a proxy cache between your client and the app. Initial requests are sent directly to the app, which then gets cached by the proxy. Subsequent requests go only to the proxy and bypass the app completely. It is used to cache assets that don’t change often but can also be used to cache http responses.

Setting up caching in Rails 5.

You need to have gem rack-cache specified in the Gemfile.

Then, run the following task:

1 bin/rails dev:caching

This will create an empty file tmp/caching-dev.txt.

Within config/development.rb, you will see the following code:

 1 # Enable/disable caching. By default caching is disabled.
 2   if Rails.root.join('tmp/caching-dev.txt').exist?
 3     config.action_controller.perform_caching = true
 4     # set below to true to use rack_cache
 5     # also make sure rack-cache gem in gemfile
 6     config.action_dispatch.rack_cache = {
 7       verbose: true,
 8       metastore:   'file:/appdir/tmp',
 9       entitystore: 'file:/appdir/tmp',
10       allow_reload: true
11     }
13     config.cache_store = :memory_store
14     config.public_file_server.headers = {
15       'Cache-Control' => 'public, max-age=172800'
16     }
18   else
19     config.action_controller.perform_caching = false
20     config.cache_store = :null_store
21   end

I enabled Rack::Cache by setting the config.action_dispatch.rack_cache key and passing in a hash of custom options. It can also be set to true and it will use the defaults defined in the Rails middleware stack.

I have also set verbose to true in development mode so we can check if it is working.

Run bin/rails middleware and you should see the following:

1 use Rack::Sendfile
2 use ActionDispatch::Static
3 use Module
4 use ActionDispatch::Executor

The presence of Module under ActionDispatch::Static means Rack::Cache is loaded. You can check the Rails default middleware stack to confirm the ordering.

Workings of Rack::Cache

Underneath the hood, Rack::Cache processes each request through the middleware stack and compares certain http request headers to determine if it should fetch the resource from the cache or to forward it to the application.

There are certain conditions under which requests will bypass the cache and goes completely to the app:

  • A non-GET request is made.

  • allow_reload is set to true in the configuration and client sends a Cache-Control: no-cache header

  • allow_revalidate is set to true in the configuration and client sends a Cache-Control: max-age=0 header

  • if the header contains authorization fields such as Authorization or ‘Cookie’, in which case Rack::Cache considers it to be private and will not cache it.

This means that if you are using http conditionals methods in your controller actions such as stale? method, it might lead to surprising behaviour.

Given an example application with a Users controller which has a conditional:

 1 class UsersController < ApplicationController
 2   def index
 3     @users = User.all
 5     if stale?(etag: @users, last_modified: @users.maximum(:updated_at), public: true)
 6       respond_to do |format|
 7         format.html
 8       end
 9     end
10   end
11 end

The option public must be set to true to allow Rack::Cache to store the content.

The first request will bypass Rack::Cache, hits the application and returns a 200 response but also the additional headers:

1 curl -i http://localhost:3000/users
3 # Response Headers
4 # ...
5 X-Rack-Cache: miss, ignore, store
1 Started GET "/users" for ::1 at 2016-08-09 16:58:36 +0100
2   ActiveRecord::SchemaMigration Load (0.3ms)  SELECT `schema_migrations`.* FROM `schema_migrations`
3 Processing by UsersController#index as */*
4    (0.4ms)  SELECT MAX(`users`.`updated_at`) FROM `users`
5    (0.3ms)  SELECT COUNT(*) AS `size`, MAX(`users`.`updated_at`) AS timestamp FROM `users`
6   Rendering users/index.html.erb within layouts/application
7   User Load (0.3ms)  SELECT `users`.* FROM `users`
8   Rendered users/index.html.erb within layouts/application (9.0ms)
9 Completed 200 OK in 347ms (Views: 321.2ms | ActiveRecord: 4.7ms)

X-Rack-Cache: miss means that the response is not found in the cache store so it is fetched from the application and then stored within the cache.

A further request shows the following:

1 # request
2 curl -i http://localhost:3000/users
4 # part of the response headers
5 # ....
6 X-Rack-Cache: stale, valid, store
1 Started GET "/users" for ::1 at 2016-08-09 16:59:56 +0100
2 Processing by UsersController#index as */*
3    (0.4ms)  SELECT MAX(`users`.`updated_at`) FROM `users`
4    (0.3ms)  SELECT COUNT(*) AS `size`, MAX(`users`.`updated_at`) AS timestamp FROM `users`
5 Completed 304 Not Modified in 3ms (ActiveRecord: 0.7ms)

Note that the application is now returning a 304 response and no rendering has occured on the application side but we still receive a HTML response in the terminal.

This is because Rack::Cache has stored the initial first request and is returning it as the response, bypassing the application completely.

The X-Rack-Cache header indicates that there is a cache hit and it is rendering the content directly from the cache store, which is specified in the entitystore configuration directory.

If you look within this directory, you will see some folders. The X-Content-Digest shows the location of this cached response: the first 2 digits denote the directory and the remaining characters are the filename.

In our example above, we have:

1 # <11> directory
2 # <463e97d33861d8ca81e9507e1d8d2e85cf2368> filename
4 X-Content-Digest: 11463e97d33861d8ca81e9507e1d8d2e85cf2368

which means the cached html fragment is stored in tmp/11/463e97d33861d8ca81e9507e1d8d2e85cf2368. Opening it should show the entire html response.

By sending an ETag or Last-Modified header value that matches, we will not receive any html content back as no rendering has taken place, which is similar to just having http caching on its own.

One point of note is that Rails 5 use weak etags by default, which means you would need to change the curl syntax to get it to work (note the W/ prefix):

1 curl -i -H 'If-None-Match: W/"e2b3d25cf8426f3cc00dcd43f8ac2148"' http://localhost:3000/users

If you have allow_reload or allow_revalidate set, you can always bypass the cache, which is useful for testing:

1 curl -i -H 'Cache-Control: no-cache' http://localhost:3000/users

This will cause the rendering to occur and invalidate the cache.

In Rails 5, ActiveRecord relation objects now return a cache key of the following format:

1 <class of records>/query-<md5 hash>-<nos of records>-<timestamp of most updated record in collection>
3 "users/query-b37955d0f26d583466428665d31ecd71-3-20160809153910000000"

In our UsersController, since we are passing @users to the etag parameter, Rails will automatically use the above to calculate the cache key.

Even if the html template were to be updated, Rack::Cache will keep rendering the cached version.

Only when a single user in the returned relation is updated will it render the new template changes again since the cache key will be invalidated.

This might cause some confusion in development mode but can be easily bypassed by updating the updated_at field of any user in a console:

1 >> User.last.touch

Subsequent request will now render the updated content:

1 curl -i http://localhost:3000/users
3 # renders the updated content and storing it in the cache
4 X-Rack-Cache: stale, invalid, ignore, store

The X-Rack-Cache: stale, invalid, ignore, store indicates that the updated content has been fetched and stored in the cache. Further request will return X-Rack-Cache: stale, valid, store, with a status code of 304 from the application and the cached content from the proxy.

I hope this has helped in understanding and using Rack::Cache in Rails 5.

Stay curious and keep hacking!

Further information