Back-end7 minute read

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.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

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.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Botond Orban
Verified Expert in Engineering

Botond is a skilled developer who enjoys writing readable code. He learnt to program at 13 on a ZX Spectrum-compatible Russian PC.

Read More

PREVIOUSLY AT

Epam
Share

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.

Field-level Rails cache invalidation

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 and updated_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?

Fragments for child entities referring to fields of parent entities

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.

"Has and Belongs to" Association

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.

Fragment for an event entity with only the name field

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:

Fragment for an event entity with the event owner's name

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.

Hire a Toptal expert on this topic.
Hire Now
Botond Orban

Botond Orban

Verified Expert in Engineering

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.

Read More
authors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

PREVIOUSLY AT

Epam

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Join the Toptal® community.