Recyclable cache keys or cache versioning was introduced in Rails 5.2. Large applications frequently need to invalidate their cache because cache store has limited memory. We can optimize cache storage and minimize cache miss using recyclable cache keys.

Recyclable cache keys is supported by all cache stores that ship with Rails.

Before Rails 5.2, cache_key’s format was {model_name}/{id}-{update_at}. Here model_name and id are always constant for an object and updated_at changes on every update.

Rails 5.1

>> post = Post.last

>> post.cache_key
=> "posts/1-20190522104553296111"

# Update post
>> post.touch

>> post.cache_key
=> "posts/1-20190525102103422069" # cache_key changed

In Rails 5.2, #cache_key returns {model_name}/{id} and new method #cache_version returns {updated_at}.

Rails 5.2

>> ActiveRecord::Base.cache_versioning = true

>> post = Post.last

>> post.cache_key
=> "posts/1"

>> post.cache_version
=> "20190522070715422750"

>> post.cache_key_with_version
=> "posts/1-20190522070715422750"

Let’s update post instance and check cache_key and cache_version’s behaviour.

>> post.touch

>> post.cache_key
=> "posts/1" # cache_key remains same

>> post.cache_version
=> "20190527062249879829" # cache_version changed

To use cache versioning feature, we have to enable ActiveRecord::Base.cache_versioning configuration. By default cache_versioning config is set to false for backward compatibility.

We can enable cache versioning configuration globally as shown below.

ActiveRecord::Base.cache_versioning = true
# or
config.active_record.cache_versioning = true

Cache versioning config can be applied at model level.

class Post < ActiveRecord::Base
  self.cache_versioning = true
end

# Or, when setting `#cache_versioning` outside the model -

Post.cache_versioning = true

Let’s understand the problem step by step with cache keys before Rails 5.2.

Rails 5.1 (without cache versioning)

1. Write post instance to cache using fetch api.

>> before_update_cache_key = post.cache_key
=> "posts/1-20190527062249879829"

>> Rails.cache.fetch(before_update_cache_key) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">

2. Update post instance using touch.

>> post.touch
   (0.1ms)  begin transaction
  Post Update (1.6ms)  UPDATE "posts" SET "updated_at" = ? WHERE "posts"."id" = ?  [["updated_at", "2019-05-27 08:01:52.975653"], ["id", 1]]
   (1.2ms)  commit transaction
=> true

3. Verify stale cache_key in cache store.

>> Rails.cache.fetch(before_update_cache_key)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">

4. Write updated post instance to cache using new cache_key.

>> after_update_cache_key = post.cache_key
=> "posts/1-20190527080152975653"

>> Rails.cache.fetch(after_update_cache_key) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">

5. Cache store now has two copies of post instance.

>> Rails.cache.fetch(before_update_cache_key)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">

>> Rails.cache.fetch(after_update_cache_key)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">

cache_key and its associated instance becomes irrelevant as soon as an instance is updated. But it stays in cache store until it is manually invalidated.

This sometimes result in overflowing cache store with stale keys and data. In applications that extensively use cache store, a huge chunk of cache store gets filled with stale data frequently.

Now let’s take a look at the same example. This time with cache versioning to understand how recyclable cache keys help optimize cache storage.

Rails 5.2 (cache versioning)

1. Write post instance to cache store with version option.

>> ActiveRecord::Base.cache_versioning = true

>> post = Post.last

>> cache_key = post.cache_key
=> "posts/1"

>> before_update_cache_version = post.cache_version
=> "20190527080152975653"

>> Rails.cache.fetch(cache_key, version: before_update_cache_version) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">

2. Update post instance.

>> post.touch
   (0.1ms)  begin transaction
  Post Update (0.4ms)  UPDATE "posts" SET "updated_at" = ? WHERE "posts"."id" = ?  [["updated_at", "2019-05-27 09:09:15.651029"], ["id", 1]]
   (0.7ms)  commit transaction
=> true

3. Verify stale cache_version in cache store.

>> Rails.cache.fetch(cache_key, version: before_update_cache_version)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">

4. Write updated post instance to cache.

>> after_update_cache_version = post.cache_version
=> "20190527090915651029"

>> Rails.cache.fetch(cache_key, version: after_update_cache_version) { post }
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 09:09:15">

5. Cache store has replaced old copy of post with new version automatically.

>> Rails.cache.fetch(cache_key, version: before_update_cache_version)
=> nil

>> Rails.cache.fetch(cache_key, version: after_update_cache_version)
=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 09:09:15">

Above example shows how recyclable cache keys maintains single, latest copy of an instance. Stale versions are removed automatically when new version is added to cache store.

Rails 6 added #cache_versioning for ActiveRecord::Relation.

ActiveRecord::Base.collection_cache_versioning configuration should be enabled to use cache versioning feature on collections. It is set to false by default.

We can enable this configuration as shown below.

ActiveRecord::Base.collection_cache_versioning = true
# or
config.active_record.collection_cache_versioning = true

Before Rails 6, ActiveRecord::Relation had cache_key in format {table_name}/query-{query-hash}-{count}-{max(updated_at)}.

In Rails 6, cache_key is split in stable part cache_key - {table_name}/query-{query-hash} and volatile part cache_version - {count}-{max(updated_at)}.

For more information, check out blog on ActiveRecord::Relation#cache_key in Rails 5.

Rails 5.2

>> posts = Post.all

>> posts.cache_key
=> "posts/query-00644b6a00f2ed4b925407d06501c8fb-3-20190522172326885804"

Rails 6

>> ActiveRecord::Base.collection_cache_versioning = true

>> posts = Post.all

>> posts.cache_key
=> "posts/query-00644b6a00f2ed4b925407d06501c8fb"

>> posts.cache_version
=> "3-20190522172326885804"

Cache versioning works similarly for ActiveRecord::Relation as ActiveRecord::Base.

In case of ActiveRecord::Relation, if number of records change and/or record(s) are updated, then same cache_key is written to cache store with new cache_version and updated records.

Conclusion

Previously, cache invalidation had to be done manually either by deleting cache or setting cache expire duration. Cache versioning invalidates stale data automatically and keeps latest copy of data, saving on storage and performance drastically.

Check out the pull request and commit for more details.