Blog

Rails Low-Level Caching Tips

Allan Andal portrait
Allan Andal
April 2, 2023

Ruby On Rails provides numerous ways to cache data, particularly built-in page, action, and fragment caching, but these are unlikely to be applicable when dealing with API endpoints such as GraphQL. Here, we’ll concentrate on low-level caching, which gives you more power and control over your caching strategy. Low-level caching can be an effective tool for improving application performance; however, careful consideration of cache keys, expiration times, and cache invalidation is required to ensure that cached data remains current and accurate.

Cache Key

It is critical to use unique and stable cache keys. Ideally the keys should be name-spaced properly and readable. This helps avoid cache collision which happen when a process that is supposed to give a different result is using a common cache key. For example below:


def fetch_report_data(options)
  Rails.cache.fetch([:user_aggregated_data, user], expires_in: 1.hour) do
    UserDataAggregator.call!(user: user, **options)
  end
end

The cache key used above is not unique enough - this will return same cached result regardless of passed options. We can simply add the options argument to the key to fix this.


  Rails.cache.fetch([:user_aggregated_data, user, options], expires_in: 1.hour)

Above example can be simple and obvious but there can be more subtle bugs that are caused by poor naming of cache keys.

Tip:

You can pass any object as cache key, Rails eventually converts this into string. If the object responds to cache_key or cache_key_with_version like an ActiveRecord, it will use that method as key. To see what Rails eventually generates as key, you can use ActiveSupport::Cache.expand_cache_key method.


> ActiveSupport::Cache.expand_cache_key([:some_prefix, User.first, { test: 'hey', foo: :bar } , :one, "two"])
"some_prefix/users/1-20220303235501912820/test/hey/foo/bar/one/two"

Tip:

If you think your generated cache key will get too big and potentially go over storage limit (check memcached or Redis key limit), you can create a hash digest of the generated cached key (don’t forget to add a prefix so you can still identify this key). Sample below:


long_key = ActiveSupport::Cache.expand_cache_key(very_big_params_hash)
hash_key = Digest::SHA256.hexdigest(long_key)
Rails.cache.fetch([:some_identifying_prefix, hash_key]) do
  # cache data
end

Invalidate Cache Data

When we call Rails.cache.fetch the block only gets executed when data is missing from cache storage (aka cache missed). This can be because it’s a new key or data has expired. To add expiration to our cached data, we can simply add expires_in or expires_at option.


Rails.cache.fetch(cache_key, expires_in: 1.hour) # 1 hour duration
# or
Rails.cache.fetch(cache_key, expires_at: Time.current.end_of_day) 
# exact time something like Sun, 02 Apr 2023 23:59:59.999999999 UTC +00:00

There are a few rules to follow when configuring cache expiration:

  • Consider the data’s volatility. How often does the data change? Set a shorter expiration time if the data changes frequently to ensure that the cache remains accurate. You can set a longer expiration time if the data changes infrequently.

  • Consider the significance of the data. How significant is the data? If the data is critical to your application’s operation, you should set a shorter expiration time to ensure that the cache remains accurate. You can set a longer expiration time if the data is less critical.

  • Consider the consequences of stale data. What are the consequences of serving stale data? If serving stale data could result in serious consequences, set a shorter expiration time to reduce the risk of serving stale data. You can set a longer expiration time if the impact of serving stale data is minimal.

  • Consider the effect of cache refreshes on performance. Refreshing the cache can be a time-consuming process. Excessive cache refreshes and decreased performance are possible if the expiration time is set too low. You risk serving stale data if you set the expiration time too long. Find a happy medium that ensures accuracy while reducing the impact on performance.

Remember that there is no one-size-fits-all solution for cache expiration. It is critical to consider the specific requirements.

Bonus Gotcha

Caching an object instead of the actual data


def user_summary
  Rails.cache.fetch(cache_key) do
    User.blog_post_summaries
  end
end

Assuming we have a model called User which has a scope named blog_post_summaries, the above code will actually return an unloaded ActiveRecord::Relation object. This is because ActiveRecord::Relation is lazily loaded, we are not actually caching data here but instead the unloaded ActiveRecord::Relation object. The heavy process, which is the database query, was not cached. To fix this, ensure to add load or to_a method to the scope eg:


User.blog_post_summaries.to_a # returns Array
# or
User.blog_post_summaries.load # returns ActiveRecord::Relation with loaded data

Thats it for now, happy coding :)