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
orcache_key_with_version
like an ActiveRecord, it will use that method as key. To see what Rails eventually generates as key, you can useActiveSupport::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 :)