Rails' Hidden Gems: ActiveSupport Cache Increment and Decrement

honeybadger_staff

Honeybadger Staff

Posted on June 10, 2022

Rails' Hidden Gems: ActiveSupport Cache Increment and Decrement

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, ...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. The job then creates an instance of this model and updates it as items are processed.
  3. A simple JSON endpoint creates an instance of this model to grab the current value.
  4. 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.

💖 💪 🙅 🚩
honeybadger_staff
Honeybadger Staff

Posted on June 10, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related