Rails' Hidden Gems: ActiveSupport Cache Increment and Decrement
Honeybadger Staff
Posted on June 10, 2022
This article was originally written by Jonathan Miles on the Honeybadger Developer Blog.
Rails is a large framework with a lot of handy tools built-in for specific situations. In this series, we'll take a look at some of the lesser-known tools hidden in Rails' large codebase.
In this article, we'll explain the increment
and decrement
methods in Rails.cache
.
The Rails.cache Helper
Rails.cache
is the entryway to interact with the cache in your application. It's also an abstraction, giving you a common API to call regardless of the actual cache "store" being used under the hood. Out of the box, Rails supports the following:
- FileStore
- MemoryStore
- MemCacheStore
- NullStore
- RedisCacheStore
Inspecting Rails.cache
will show which one you are running:
> Rails.cache
=> <#ActiveSupport::Cache::RedisCacheStore options={:namespace=>nil, ...
We won't look at them all in detail, but as a quick summary:
- NullStore does not store anything; reading back from this will always return
nil
. This is the default for a new Rails app. - FileStore stores the cache as files on the hard drive, so they persist even if you restart your Rails server.
- MemoryStore keeps the cache in RAM, so if you stop your Rails server, you will also wipe the cache.
- MemCacheStore and RedisCacheStore use external programs (MemCache and Redis, respectively) to maintain the cache.
For various reasons, the first three here are used most often for development/testing. In production, you would likely use Redis or MemCache.
Because Rails.cache
abstracts away the differences between these services, it is easy to run different versions in different environments without changing the code; for example, you could use NullStore
in development, MemoryStore
in testing, and RedisCacheStore
in production.
Cached Data
Through Rails' Rails.cache.read
, Rails.cache.write
, and Rails.cache.fetch
, we have an easy way to store and retrieve any arbitrary data from the cache. An earlier article covered these in more detail; the important thing to note for this article is that there is no in-built thread-safety on these methods. Let’s say we are updating the cached data from multiple threads to keep a running count; we will need to wrap the read/write operation in some kind of lock to avoid race conditions. Consider this example, assuming we have set things up to use a Redis cache store:
threads = []
# Set initial counter
Rails.cache.write(:test_counter, 0)
4.times do
threads << Thread.new do
100.times do
current_count = Rails.cache.read(:test_counter)
current_count += 1
Rails.cache.write(:test_counter, current_count)
end
end
end
threads.map(&:join)
puts Rails.cache.read(:test_counter)
Here we have four threads, each one incrementing our cached value one hundred times. The result should be 400, but most of the time, it will be much less - 269 on my test run. What's happening here is a race condition. I covered these in more detail in the previous article, but as a quick summary, because the threads are all operating on the same "shared" data, they can get out of sync with each other. For example, one thread might read the value, then another thread takes over and reads the value as well, increments it, and stores it. The first thread then resumes, using its now-out-of-date value.
The common way to solve this problem is to surround the code with a mutually exclusive lock (or Mutex) so that only one thread can execute code within the lock at a time. In our case, though, Rails.cache has some methods to handle this scenario.
Rails Cache Increment and Decrement
The Rails.cache
object has both increment
and decrement
methods for acting directly on cached data like our counter scenario:
threads = []
# Set initial counter
Rails.cache.write(:test_counter, 0, raw: true)
4.times do
threads << Thread.new do
100.times do
Rails.cache.increment(:test_counter)
# repeating the increment just to highlight the thread safety
Rails.cache.decrement(:test_counter)
Rails.cache.increment(:test_counter)
end
end
end
threads.map(&:join)
puts Rails.cache.read(:test_counter, raw: true)
To use increment
and decrement
, we have to tell the cache store it is a 'raw' value (via raw: true
). You have to do this when reading the value back as well; otherwise, you'll get an error. Basically, we are telling the cache we want this value stored as a bare integer so that we can call increment/decrement on it, but you can still use expires_in
and other cache flags at the same time.
The key here is that increment
and decrement
use atomic operations (at least for Redis and MemCache), meaning they are thread safe; there is no way for a thread to pause during an atomic operation.
It's worth noting, although I haven't used it in my example here, that both methods also return the new value. Therefore, if you need to use the new counter value beyond just updating it, you can also do so without an additional read
call.
Real-World Applications
On the face of it, these increment
and decrement
methods seem like low-level helper methods, the kind you probably only care about if you are implementing or maintaining something like a background job processing gem. Once you know about them, though, you may be surprised where they can come in handy.
I've used these in a production application to avoid duplicate scheduled background jobs running simultaneously. In our case, we have various scheduled jobs to update search indices, mark abandoned carts, and so on. Generally, this works fine; the catch is that some jobs (search indices in particular) consume a lot of memory - enough that, if two were to run together, it would exceed our Heroku dyno's limit, and the worker would be killed off. Because we have a few of these jobs, it is not as simple as marking them no-retries or forcing unique jobs; two different (and thus unique) jobs could try to run at the same time and bring down the worker.
To prevent this, we created a base class for the scheduled jobs that keeps a counter of how many are currently running. If the count is too high, the job simply re-enqueues itself and waits.
Another example was on a side-project of mine where a background job (or multiple jobs) do some processing while the user has to wait. This brings up the common issue of communicating the current progress to the user of the background job. While there are numerous ways this could be solved, as an experiment, I tried using Rails.cache.increment
to update a globally available counter. The structure was as follows:
- First, I added a new class in
/app/models
to abstract away the fact that the counter lived in the cache. This is where all access to the value would flow through. Part of this is generating the unique cache key related to the job. - The job then creates an instance of this model and updates it as items are processed.
- A simple JSON endpoint creates an instance of this model to grab the current value.
- The front-end polls this endpoint every few seconds to update the UI. You could, of course, make this fancier with something like ActionCable and push out updates.
Conclusion
Honestly, Rails.cache.increment
is not a tool I would reach for often, as it is not often that I want to update data stored in the cache (which is, by nature, somewhat temporary). As noted above, the times I do reach for it are generally related to background jobs since the jobs are already storing their data in Redis (at least in most apps I've worked on) and are also generally temporary. In such cases, it seems natural to store related data (percentage complete, for example) in the same place with a similar level of short-term persistence.
As with all things "off the beaten path", you should be wary of introducing things like this into your codebase. I'd recommend, at least, adding some comments to explain to future developers why you are using this method that they are probably not familiar with.
Posted on June 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.