A thread-safety gotcha with CurrentAttributes

My team recently encountered a subtle, thread-safety bug in our Rails app which stemmed from a misunderstanding of how ActiveSupport::CurrentAttributes works. It looked like memoization—caching an object for the duration of a request—but it ended up leaking user data across requests.

The bug

We had a method like this in app/models/current.rb

class Current < ActiveSupport::CurrentAttributes
  attribute :user
  # ...
  private

  def feature_flags
    @_feature_flags ||= FeatureFlags.new(user)
  end
end

We memoized (or cached) this because instantiating FeatureFlags.new was a little involved, and we wanted to do it just once per request. This resulted in a bug where the feature flags for the first user to login would persist for all subsequent users. It didn’t reset for each request like we expected.

After some digging, we realized that CurrentAttributes only resets the values of explicitly defined attributes between requests—not any other instance variables you define. Thus to fix our issue, we converted feature_flags from a method to an attribute.

class Current < ActiveSupport::CurrentAttributes
  attribute :feature_flags
  attribute :user
end

# in some controller
Current.user = user
Current.feature_flags = FeatureFlags.new(user)

Now, CurrentAttributes knows to reset it around each request, and we have no more thread safety bugs.

Memoizing and singletons

We’re used to using memoization in our controllers to cache expensive calls, so we had assumed that memoization would work the same in Current. For instance it’s not at all uncommon to see code like this

class ResourcesController < ApplicationController
  # ...
  private

  def resource
    @_resource ||= Resource.find(params[:id])
  end
end

This works because Rails gives you a new instance of a controller for each request, so any memoization you do will get wiped out after each request. There are no thread safety issues with this form of memoization.

CurrentAttributes works differently. It’s implemented as a thread-local singleton, meaning there’s one shared instance per thread—not per request. The first time it’s accessed, it will be instantiated, and then all subsequent requests use the same instance. Singletons are a more common design pattern in other languages, but don’t often make an appearance in Ruby.

CurrentAttributes handles this by manually resetting its attributes back to their default values around each request. Because we had cached feature_flags as an instance variable rather than an attribute, however, CurrentAttributes didn’t know it needed to reset it.

Rails to the rescue

After investigating and fixing our issue, we were happy to find that a Rails PR to fix this issue was recently merged. If we had waited until it was released, our issue would have been fixed without us having to do anything. Still, it was interesting to debug an issue with a singleton, since they’re pretty uncommon in Ruby.

Caching is scary

Forms of caching, such as memoization, are frequently subject to weird bugs like this. It’s very easy to introduce bugs that don’t occur locally, either because caching is disabled completely in development, or because you don’t have enough concurrent requests for a bug to appear.

It’s not a bad idea to avoid caching until you’re sure you need it.

In cases where you do need caching, always consider the lifetime of the cached object. If you’re memoizing in a controller, the lifetime of your cache is a single request. But outside of that, it might not be what you expect.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.