We write about Ruby on Rails, React.js, React Native, remote work, open source, engineering and design.
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.
1>> post = Post.last
2
3>> post.cache_key
4=> "posts/1-20190522104553296111"
5
6# Update post
7>> post.touch
8
9>> post.cache_key
10=> "posts/1-20190525102103422069" # cache_key changed
11
In Rails 5.2,
#cache_key
returns
{model_name}/{id}
and new method #cache_version
returns
{updated_at}.
1
2>> ActiveRecord::Base.cache_versioning = true
3
4>> post = Post.last
5
6>> post.cache_key
7=> "posts/1"
8
9>> post.cache_version
10=> "20190522070715422750"
11
12>> post.cache_key_with_version
13=> "posts/1-20190522070715422750"
14
Let's update post
instance
and check cache_key
and cache_version
's behaviour.
1
2>> post.touch
3
4>> post.cache_key
5=> "posts/1" # cache_key remains same
6
7>> post.cache_version
8=> "20190527062249879829" # cache_version changed
9
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.
1
2ActiveRecord::Base.cache_versioning = true
3# or
4config.active_record.cache_versioning = true
5
Cache versioning config can be applied at model level.
1
2class Post < ActiveRecord::Base
3 self.cache_versioning = true
4end
5
6# Or, when setting `#cache_versioning` outside the model -
7
8Post.cache_versioning = true
9
Let's understand the problem step by step with cache keys before Rails 5.2.
1. Write post
instance to cache using fetch
api.
1
2>> before_update_cache_key = post.cache_key
3=> "posts/1-20190527062249879829"
4
5>> Rails.cache.fetch(before_update_cache_key) { post }
6=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">
7
2. Update post
instance using touch
.
1
2>> post.touch
3 (0.1ms) begin transaction
4 Post Update (1.6ms) UPDATE "posts" SET "updated_at" = ? WHERE "posts"."id" = ? [["updated_at", "2019-05-27 08:01:52.975653"], ["id", 1]]
5 (1.2ms) commit transaction
6=> true
7
3. Verify stale cache_key
in cache store.
1
2>> Rails.cache.fetch(before_update_cache_key)
3=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">
4
4. Write updated post
instance to cache using new cache_key
.
1>> after_update_cache_key = post.cache_key
2=> "posts/1-20190527080152975653"
3
4>> Rails.cache.fetch(after_update_cache_key) { post }
5=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
6
5. Cache store now has two copies of post
instance.
1
2>> Rails.cache.fetch(before_update_cache_key)
3=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 06:22:49">
4
5>> Rails.cache.fetch(after_update_cache_key)
6=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
7
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.
1. Write post
instance to cache store
with version
option.
1
2>> ActiveRecord::Base.cache_versioning = true
3
4>> post = Post.last
5
6>> cache_key = post.cache_key
7=> "posts/1"
8
9>> before_update_cache_version = post.cache_version
10=> "20190527080152975653"
11
12>> Rails.cache.fetch(cache_key, version: before_update_cache_version) { post }
13=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
14
2. Update post
instance.
1
2>> post.touch
3 (0.1ms) begin transaction
4 Post Update (0.4ms) UPDATE "posts" SET "updated_at" = ? WHERE "posts"."id" = ? [["updated_at", "2019-05-27 09:09:15.651029"], ["id", 1]]
5 (0.7ms) commit transaction
6=> true
7
3. Verify stale cache_version
in cache store.
1
2>> Rails.cache.fetch(cache_key, version: before_update_cache_version)
3=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 08:01:52">
4
4. Write updated post
instance to cache.
1
2>> after_update_cache_version = post.cache_version
3=> "20190527090915651029"
4
5>> Rails.cache.fetch(cache_key, version: after_update_cache_version) { post }
6=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 09:09:15">
7
5. Cache store has
replaced old copy of post
with
new version automatically.
1
2>> Rails.cache.fetch(cache_key, version: before_update_cache_version)
3=> nil
4
5>> Rails.cache.fetch(cache_key, version: after_update_cache_version)
6=> #<Post id: 1, title: "First Post", created_at: "2019-05-22 17:23:22", updated_at: "2019-05-27 09:09:15">
7
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.
1
2ActiveRecord::Base.collection_cache_versioning = true
3# or
4config.active_record.collection_cache_versioning = true
5
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.
1
2>> posts = Post.all
3
4>> posts.cache_key
5=> "posts/query-00644b6a00f2ed4b925407d06501c8fb-3-20190522172326885804"
6
1
2>> ActiveRecord::Base.collection_cache_versioning = true
3
4>> posts = Post.all
5
6>> posts.cache_key
7=> "posts/query-00644b6a00f2ed4b925407d06501c8fb"
8
9>> posts.cache_version
10=> "3-20190522172326885804"
11
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.
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.