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 attribute
s 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.