A DSL Solution for Invalidation of Rails Cache at the Field Level

In the world of modern web development, caching stands out as a potent tool for enhancing speed. When implemented effectively, it can significantly boost your application’s overall performance. However, incorrect implementation can lead to disastrous consequences.

Cache invalidation is widely recognized as one of the most challenging problems in computer science, alongside naming things and off-by-one errors. A straightforward, albeit inefficient, solution is to invalidate everything whenever any change occurs. However, this approach undermines the very purpose of caching. The key is to invalidate the cache only when absolutely essential.

To fully leverage caching, you must exercise precision in what you invalidate, preventing your application from squandering valuable resources on redundant tasks.

Field-level Rails cache invalidation

This blog post will guide you through a technique for achieving greater control over Rails cache behavior, specifically implementing field-level cache invalidation. This technique relies on Rails ActiveRecord and ActiveSupport::Concern, along with modifications to the touch method’s behavior.

The insights shared in this post stem from my recent project experiences, where implementing field-level cache invalidation led to substantial performance gains by reducing unnecessary cache invalidations and redundant template rendering.

Rails, Ruby, and Performance

While not the fastest language, Ruby excels in development speed. Its metaprogramming capabilities and built-in domain-specific language (DSL) features empower developers with remarkable flexibility.

Studies like Jakob Nielsen’s study highlight that tasks exceeding 10 seconds lead to a loss of focus, and regaining focus consumes time, ultimately incurring unexpected costs.

Unfortunately, Ruby on Rails makes it surprisingly easy to surpass this 10-second threshold during template generation. While this might not be evident in simple applications or small-scale projects, real-world projects with complex pages can experience significant slowdowns due to template generation.

This was precisely the challenge I faced in my project.

Straightforward Optimizations

So, how do we accelerate things? The answer lies in benchmarking and optimization.

Two highly effective optimization steps in my project were:

  • Eliminating N+1 queries
  • Implementing an efficient caching mechanism for templates

N+1 Queries

Resolving N+1 queries is relatively straightforward. By analyzing your log files for repetitive SQL queries like the ones shown below, you can replace them with eager loading:

1
2
3
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' = ?

A gem called bullet can assist in identifying these inefficiencies. Alternatively, you can manually review use cases and inspect logs for the aforementioned pattern. Eliminating N+1 inefficiencies ensures you don’t overload your database, significantly reducing the time spent on ActiveRecord operations.

These changes noticeably improved my project’s speed. However, I aimed to further reduce the loading time. There was still room for optimization in template rendering, which is where fragment caching came into play.

Fragment Caching

Fragment caching significantly reduces template rendering time. However, the default Rails cache behavior wasn’t sufficient for my project’s requirements.

Rails fragment caching is based on a brilliant concept, providing a simple yet effective caching mechanism.

The creators of Ruby On Rails have authored an insightful article in Signal v. Noise on how fragment caching works.

Imagine a section of your user interface displaying fields from an entity.

  • Upon page load, Rails generates a cache_key based on the entity’s class and updated_at field.
  • It then checks the cache for content associated with this key.
  • If no cached content exists, the HTML fragment for that section is rendered and stored in the cache.
  • If cached content exists, the view is rendered using that content.

This approach eliminates the need for explicit cache invalidation. When the entity is modified, reloading the page triggers the rendering and caching of new content for that entity.

Rails also offers a convenient way to invalidate parent entity caches when a child entity changes:

1
belongs_to :parent_entity, touch: true

Including this in a model automatically “touches” the parent when the child is “touched.” More information about the touch method can be found here. This provides a simple and efficient way to invalidate caches for both parent and child entities simultaneously.

Caching Challenges in Rails

However, Rails caching is primarily designed for user interfaces where the HTML fragment representing the parent entity solely contains fragments representing its child entities. In other words, child entity fragments in this paradigm cannot include fields from the parent entity.

Real-world applications often deviate from this structure, requiring you to implement solutions that go beyond this limitation.

Consider a scenario where your user interface displays fields from a parent entity within the HTML fragment representing a child entity.

Fragments for child entities referring to fields of parent entities

If the child entity’s fragment includes fields from the parent, Rails’ default cache invalidation behavior becomes problematic.

Modifying these parent entity fields necessitates touching all child entities associated with that parent to invalidate their caches. For instance, modifying Parent1 would require invalidating the caches for both Child1 and Child2 views.

This can create a significant performance bottleneck, as touching every child entity upon parent modification leads to numerous unnecessary database queries.

A similar issue arises when entities associated through a has_and_belongs_to association are displayed in a list, and modifying these entities triggers a cascade of cache invalidations across the association chain.

"Has and Belongs to" Association
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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

In this user interface, it wouldn’t make sense to touch the participant or event when the user’s location changes. However, modifying the user’s name should logically touch both the event and the participant.

Therefore, the techniques discussed in the Signal v. Noise article prove inefficient for certain UI/UX scenarios, as illustrated above.

While Rails excels in simplicity, real-world projects often present unique complexities.

Field-Level Cache Invalidation in Rails

To address these challenges, I’ve been utilizing a concise Ruby DSL in my projects. This DSL enables declarative specification of fields that trigger cache invalidation through associations.

Let’s explore some examples where this approach proves beneficial:

Example 1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 Ruby’s metaprogramming and inner DSL capabilities.

Specifically, only changes to the event’s name will invalidate the fragment cache of its associated tasks. Modifying other event fields, such as purpose or location, won’t affect the task’s fragment cache. This is what I refer to as “field-level fine-grained cache invalidation control.”

Fragment for an event entity with only the name field

Example 2:

Consider an example involving cache invalidation across a has_many association chain.

The user interface fragment below displays a task and its owner:

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

In this scenario, the task’s HTML fragment should only be invalidated when the task itself changes or when the owner’s name is modified. Changes to other owner fields, such as time zone or preferences, shouldn’t impact the task’s cache.

This can be achieved using the following DSL:

1
2
3
4
5
6
7
8
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

Implementing the DSL

The core of this DSL lies in the touch method. Its first argument is an association, and the second argument is an array of fields that trigger a “touch” on that association:

1
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]

This method is provided by the Touchable module:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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

The key aspect here is storing the touch call’s arguments. Before saving the entity, we mark the association as dirty if a specified field has been modified. After saving, we “touch” entities within dirty associations.

The private section of the concern looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
...
  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

The check_touchable_entities method checks if a declared field has changed. If so, it marks the association as dirty by setting meta_info[association] to true.

After saving the entity, we iterate through dirty associations, touching their associated entities if necessary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

  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’s it! You can now implement field-level cache invalidation in Rails using a simple DSL.

Conclusion

Rails caching offers a relatively easy way to enhance application performance. However, real-world applications can present unique complexities. While the default Rails cache behavior works effectively in most cases, certain scenarios benefit from fine-tuning cache invalidation.

Armed with the knowledge of implementing field-level cache invalidation in Rails, you can now prevent unnecessary cache invalidations in your application, optimizing its performance for various scenarios.

Licensed under CC BY-NC-SA 4.0