Some Elegance with Rails Caching
I was recently reviewing a pull request (PR) for a Rails project that was introducing Rails caching. Specifically, low-level caching for results of a known slow operation that needed to be called frequently. The initial code looked like the typical caching implementation I've seen in other languages: read
from the cache, if the value is already there, use it, if not, call the slow operation, write
the results to the cache, then use the results.
However, a read through the Rails Guides on Caching revealed a more elegant way of doing this. There's a fetch
method that allows you to retrieve data from the cache, and provide an optional block to handle populating the cache if the value isn't there. Let's see how this works.
Read & Write
Suppose the Product
model has a method to retrieve prices from competitors sites so that it can compare if its own price is better. The call to find the competitors prices makes some external API requests, so its slow. In the example below, it's simulated with a sleep(3)
, i.e. sleep for 3 seconds before returning some hard-coded results, for this simple demo:
# app/models/product.rb
class Product < ApplicationRecord
validates :name, :description, :price, presence: true
# Calls slow operation `find_competing_prices` every time
def better_than_competition?
competitors_prices = find_competing_prices
competitors_prices.all? { |_, competitor_price| price < competitor_price }
end
# Here we simulate a slow operation with `sleep(3)`.
# In a real app, this would be in a service that's responsible
# for making an external API call and returning the result.
def find_competing_prices
logger.info("Looking up competing prices for product #{id}...")
sleep(3)
{
competitor_a: 29.99,
competitor_b: 31.49,
competitor_c: 29.55
}
end
end
Now let's introduce caching for the results of the slow operation find_competing_prices
. Using a typical approach, which is to make calls to first read
from the cache, and then write
to the cache if the value isn't already cached. The cache_key
method will be explained shortly.
# app/models/product.rb
class Product < ApplicationRecord
validates :name, :description, :price, presence: true
def better_than_competition?
# Check if the competing prices for this product have already been cached
competitors_prices = Rails.cache.read("#{cache_key}/competing_prices")
# If not, call the slow operation, and then save results to cache
if competitors_prices.blank?
competitors_prices = find_competing_prices
Rails.cache.write("#{cache_key}/competing_prices", competitors_prices)
end
# Now we can use the results, whether from cache, or just retrieved
competitors_prices.all? { |_, competitor_price| price < competitor_price }
end
# Slow operation
def find_competing_prices
# ...
end
end
The above code works, but Rails provides a more elegant method to handle caching.
Cache Fetch
In addition to read
and write
methods, Rails provides a fetch
method on the cache. Here's how it works:
result = Rails.cache.fetch('my_key') do
expensive_data_fetching_method
end
The fetch
method first checks if my_key
is in the cache. If it's not, it automatically executes the block provided, which is the expensive_data_fetching_method
in the above example. The result is then stored in the cache under the my_key
key. If my_key
is already in the cache, then fetch
simply returns the cached result, making it an elegant way to handle caching in Rails.
Let's update the better_than_competition?
method in the Product
model to use fetch
rather than making separate calls to read
and write
:
# app/models/product.rb
class Product < ApplicationRecord
validates :name, :description, :price, presence: true
def better_than_competition?
# Read from or write to cache
competitors_prices = Rails.cache.fetch("#{cache_key}/competing_prices") do
find_competing_prices
end
competitors_prices.all? { |_, competitor_price| price < competitor_price }
end
# Slow operation
def find_competing_prices
# ...
end
end
Cache Keys
Another nice feature that Rails provides is automatic construction of the cache keys for models. All the previous examples referenced a cache_key
method, for example:
# In any model method
Rails.cache.fetch("#{cache_key}/competing_prices")
But we didn't have to define cache_key
in the model class. The cache_key method is part of ActiveRecord. It returns a concatenation of the model name and its primary key. We can explore this in the Rails console bin/rails c
:
product = Product.first
=> #<Product:0x00000001080c9618 id: 1, name: "whatever"...
product.cache_key
=> "products/1"
So when any caching code runs such as:
# In any instance method in Product class
Rails.cache.fetch("#{cache_key}/competing_prices")
What its doing is looking in the cache for a key like products/1/competing_prices
.
Cache Invalidation
You've probably heard that cache invalidation is one of the hard things in computer science (jokes). Rails can help with this problem. In addition to the cache_key
method, it also provides the cache_key_with_version method, which incorporates the model's updated_at
timestamp.
Let's see how this works in the Rails console:
Product.select(:id, :name, :updated_at).first
=> #<Product:0x0000000113bb3848 id: 6, name: "whatever", updated_at: Sun, 12 Nov 2023 16:35:53.983721000 UTC +00:00>
# Notice this incorporates both the product id and updated_at timestamp
product.cache_key_with_version
=> "products/6-20231112163553983721"
# Updating the price also updates the model's `updated_at`
product.update!(price: 32.99)
=> true
# Now we have a different cache key because updated_at has changed
product.updated_at
=> Tue, 04 Jun 2024 12:00:49.561587000 UTC +00:00
product.cache_key_with_version
=> "products/6-20240604120049561587"
This is useful if you need the cache to be populated with fresh data, anytime a model gets updated, then looked up in the cache again. In this case, our caching could be updated to use the cache_key_with_version
method instead of cache_key
:
def better_than_competition?
# Read from or write to cache using a timestamped key
# If the product has been updated since the last time it was cached,
# this will be considered a cache miss and a new entry will be
# populated in the cache.
competitors_prices = Rails.cache.fetch("#{cache_key_with_version}/competing_prices") do
find_competing_prices
end
competitors_prices.all? { |_, competitor_price| price < competitor_price }
end
Conclusion
The Rails cache fetch
method and automatic cache key construction are just a few examples of the many developer niceties that Rails offers. It's thoughtful details such as these that make Rails a go-to choice for web developers looking to be productive and enjoy their work. For those who regard this as too much "magic", there's always the classic read
and write
methods. You can also define your own cache key/version methods if you need different behaviour than what Rails provides. See the Rails Guides: Caching and the API Docs for more details.
If you'd like to explore the code examples in this post, they are available on GitHub. Feel free to check out the repository and try out the examples for product caching.