Field-level Rails Cache Invalidation: A DSL Solution
Fragment caching in Rails provides an easy yet a powerful way of improving your application’s performance. However, some real-world scenarios do not work quite well with how the Rails cache behaves by default.
In this article, Toptal Ruby on Rails Developer Orban Botond shows how you can implement a small DSL to optimize how the cache for related entities is invalidated to improve template rendering performance.
Fragment caching in Rails provides an easy yet a powerful way of improving your application’s performance. However, some real-world scenarios do not work quite well with how the Rails cache behaves by default.
In this article, Toptal Ruby on Rails Developer Orban Botond shows how you can implement a small DSL to optimize how the cache for related entities is invalidated to improve template rendering performance.
Botond is a skilled developer who enjoys writing readable code. He learnt to program at 13 on a ZX Spectrum-compatible Russian PC.
Expertise
Previously At
In modern web development, caching is a quick and powerful way to speed things up. When done right, caching can bring significant improvements to your application’s overall performance. When done wrong, it will most definitely end in disaster.
Cache invalidation, as you may know, is one of the three hardest problems in computer science—the other two being naming things and off-by-one errors. One easy way out is to invalidate everything, left and right, whenever something changes. But that defeats the purpose of caching. You want to invalidate the cache only when absolutely necessary.
If you want to make the most out of caching, you need to be very particular about what you invalidate and save your application from wasting precious resources on repeated work.
In this blog post, you will learn a technique to have better control over how Rails caches behave: specifically, implementing field-level cache invalidation. This technique relies on Rails ActiveRecord and ActiveSupport::Concern
as well as manipulation of the touch
method behavior.
This blog post is based on based on my recent experiences in a project where we saw significant improvement in performance after implementing field-level cache invalidation. It helped reduce unnecessary cache invalidations and repeated rendering of templates.
Rails, Ruby, and Performance
Ruby isn’t the fastest language, but overall, it is a suitable option where development speed is concerned. Moreover, its metaprogramming and built-in domain-specific language (DSL) capabilities give the developer tremendous flexibility.
There are studies out there like Jakob Nielsen’s study that show us that if a task takes more than 10 seconds, we will lose our focus. And regaining our focus takes time. So this can be unexpectedly costly.
Unfortunately, in Ruby on Rails, it is super easy to exceed that 10-second threshold with template generation. You will not see that happen in any “hello world” app or small-scale pet project, but in real-world projects where a lot of things are loaded onto a single page, believe me, template generation can very easily start to drag.
And, that’s exactly what I had to solve in my project.
Simple Optimizations
But how exactly do you speed things up?
The answer: Benchmark and optimize.
In my project, two very effective steps in optimization were:
- Eliminating N+1 queries
- Introducing a good caching technique for templates
N+1 Queries
Fixing N+1 queries is easy. What you can do is check your log files—whenever you see multiple SQL queries like those below in your logs, eliminate them by replacing them with eager loading:
Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ?
Learning Load (0.3ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ?
Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ?
There is a gem for this which is called bullet to help detect this inefficiency. You can also walk through each of the use cases and, in the meantime, check the logs by inspecting them against the above pattern. By eliminating all the N+1 inefficiencies, you can be confident enough that you won’t overload your database and your time spent on ActiveRecord will drop significantly.
After making these changes, my project was already running more briskly. But I decided to take it to the next level and see if I could get that load time down even further. There was still a fair bit of unnecessary rendering happening in the templates, and ultimately, that’s where fragment caching helped.
Fragment Caching
Fragment caching generally helps reduce template generation time significantly. But the default Rails cache behavior was not cutting it for my project.
The idea behind Rails fragment caching is brilliant. It provides a super simple and effective caching mechanism.
The authors of Ruby On Rails have written a very good article in Signal v. Noise on how fragment caching works.
Let’s say that you have a bit of user interface which shows some fields of an entity.
- On page load, Rails calculates the
cache_key
based on the entity’s class andupdated_at
field. - Using that
cache_key
, it checks to see if there is anything in the cache associated with that key. - If there isn’t anything in the cache, then the HTML code for that fragment is rendered for the view (and the newly rendered content is stored in the cache).
- If there is any existing content in the cache with that key, then the view is rendered with the contents of the cache.
This implies that the cache never needs to be invalidated explicitly. Whenever we change the entity and reload the page, new cache content is rendered for the entity.
Rails, by default, also offers the capability to invalidate the parent entities’ cache in case the child changes:
belongs_to :parent_entity, touch: true
This, when included in a model, will automatically touch the parent when the child is touched. You can learn more about touch
here. With this, Rails provides us a simple and efficient way to invalidate the cache for our parent entities simultaneously with the cache for the child entities.
Caching in Rails
However, caching in Rails is created to serve user interfaces where the HTML fragment representing the parent entity contains HTML fragments representing solely the child entities of the parent. In other words, the HTML fragment representing the child entities in this paradigm cannot contain fields from the parent entity.
But that’s not what happens in the real world. You may very well need to do things in your Rails application that violate this condition.
How would you handle a situation where the user interface shows fields of a parent entity inside the HTML fragment representing the child entity?
If the child contains fields from the parent entity, then you are in trouble with Rails’ default cache invalidation behavior.
Every time those fields presented from the parent entity are modified, you will need to touch all the child entities belonging to that parent. For example, if Parent1
is modified, you will need to make sure that the cache for the Child1
and Child2
views are both invalidated.
Obviously, this can cause a huge performance bottleneck. Touching every child entity whenever a parent has changed would result in a lot of database queries for no good reason.
Another similar scenario is when the entities associated with the has_and_belongs_to
association were presented in the list, and modifying those entities started a cascade of cache invalidation through the association chain.
class Event < ActiveRecord::Base
has_many :participants
has_many :users, through: :participants
end
class Participant < ActiveRecord::Base
belongs_to :event
belongs_to :user
end
class User < ActiveRecord::Base
has_many :participants
has_many :events, through :participants
end
So, for the above user interface, it would be illogical to touch the participant or the event when the user’s location changes. But we should touch both the event and the participant when the user’s name changes, shouldn’t we?
So the techniques in the Signal v. Noise article are inefficient for certain UI/UX instances, as described above.
Although Rails is super effective for simple things, real projects have their own complications.
Field Level Rails Cache Invalidation
In my projects, I have been using a small Ruby DSL for handling situations like the above. It enables you to specify declaratively the fields which will trigger cache invalidation through the associations.
Let’s take a look at a few examples of where it really helps:
Example 1:
class Event < ActiveRecord::Base
include Touchable
...
has_many :tasks
...
touch :tasks, in_case_of_modified_fields: [:name]
...
end
class Task < ActiveRecord::Base
belongs_to :event
end
This snippet leverages the metaprogramming abilities and inner DSL capabilities of Ruby.
To be more specific, only a name change in the event will invalidate the fragment cache of its related tasks. Changing other fields of the event—like purpose or location—won’t invalidate the fragment cache of the task. I would call this field-level fine-grained cache invalidation control.
Example 2:
Let’s take a look at an example which shows cache invalidation through the has_many
association chain.
The user interface fragment shown below shows a task and its owner:
For this user interface, the HTML fragment representing the task should be invalidated only when the task changes or when the name of the owner changes. If all the other fields of the owner (like the time zone or preferences) change, then the task cache should be left intact.
This is achieved using the DSL shown here:
class User < ActiveRecord::Base
include Touchable
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]
...
end
class Task < ActiveRecord::Base
has_one owner, class_name: :User
end
Implementation of the DSL
The main essence of the DSL is the touch
method. Its first argument is an association, and the next argument is a list of fields which triggers the touch
on that association:
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]
This method is provided by the Touchable
module:
module Touchable
extend ActiveSupport::Concern
included do
before_save :check_touchable_entities
after_save :touch_marked_entities
end
module ClassMethods
def touch association, options
@touchable_associations ||= {}
@touchable_associations[association] = options
end
end
end
In this code, the main point is that we store the arguments of the touch
call. Then, before saving the entity, we mark the association dirty if the specified field was modified. We touch the entities in that association after saving if the association was dirty.
Then, the private part of the concern is:
...
private
def klass_level_meta_info
self.class.instance_variable_get('@touchable_associations')
end
def meta_info
@meta_info ||= {}
end
def check_touchable_entities
return unless klass_level_meta_info.present?
klass_level_meta_info.each_pair do |association, change_triggering_fields|
if any_of_the_declared_field_changed?(change_triggering_fields)
meta_info[association] = true
end
end
end
def any_of_the_declared_field_changed?(options)
(options[:in_case_of_modified_fields] & changes.keys.map{|x|x.to_sym}).present?
end
…
In the check_touchable_entities
method, we check if the declared field changed. If so, we mark the association as dirty by setting the meta_info[association]
to true
.
Then, after saving the entity, we check our dirty associations and touch the entities in it if necessary:
…
def touch_marked_entities
return unless klass_level_meta_info.present?
klass_level_meta_info.each_key do |association_key|
if meta_info[association_key]
association = send(association_key)
association.update_all(updated_at: Time.zone.now)
meta_info[association_key] = false
end
end
end
…
And, that is it! Now you can perform field-level cache invalidation in Rails with a simple DSL.
Conclusion
Rails caching promises performance improvements in your application with relative ease. However, real-world applications can be complicated and often pose unique challenges. The default Rails cache behavior works well for most scenarios, but there are certain scenarios where a little more optimization in cache invalidation can go a long way.
Now that you know how to implement field-level cache invalidation in Rails, you can prevent unnecessary invalidations of caches in your application.
Understanding the basics
What does DSL stand for?
DSL stands for “domain-specific language.”
What does the ActiveRecord touch method do?
The touch method sets the updated_at/on field to the current time and saves the record.
Botond Orban
Gheorgheni, Harghita County, Romania
Member since June 4, 2015
About the author
Botond is a skilled developer who enjoys writing readable code. He learnt to program at 13 on a ZX Spectrum-compatible Russian PC.
Expertise
PREVIOUSLY AT