Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Fix rare race condition causing `NameError` (missing `@dehydrated_*` ivar) when hydrating recursively
embedded associations by synchronizing hydration to be thread-safe.

## 1.6.3

- Split the `with_deferred_parent_expiration` and `with_deferred_parent_expiration`. (#578)
Expand Down
21 changes: 17 additions & 4 deletions lib/identity_cache/cached/recursive/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Association < Cached::Association # :nodoc:
def initialize(name, reflection:)
super
@dehydrated_variable_name = :"@dehydrated_#{name}"
@hydration_mutex = Mutex.new
end

attr_reader :dehydrated_variable_name
Expand All @@ -29,10 +30,7 @@ def read(record)
if record.instance_variable_defined?(records_variable_name)
record.instance_variable_get(records_variable_name)
elsif record.instance_variable_defined?(dehydrated_variable_name)
dehydrated_target = record.instance_variable_get(dehydrated_variable_name)
association_target = hydrate_association_target(assoc.klass, dehydrated_target)
record.remove_instance_variable(dehydrated_variable_name)
set_with_inverse(record, association_target)
hydrate_safely(record, assoc)
else
assoc.load_target
end
Expand Down Expand Up @@ -76,6 +74,21 @@ def embedded_recursively?

private

def hydrate_safely(record, assoc)
@hydration_mutex.synchronize do
if record.instance_variable_defined?(records_variable_name)
return record.instance_variable_get(records_variable_name)
end

dehydrated_target = record.instance_variable_get(dehydrated_variable_name)
association_target = hydrate_association_target(assoc.klass, dehydrated_target)
if record.instance_variable_defined?(dehydrated_variable_name)
record.remove_instance_variable(dehydrated_variable_name)
end
set_with_inverse(record, association_target)
end
end

def set_inverse(record, association_target)
return if association_target.nil?

Expand Down
27 changes: 27 additions & 0 deletions test/cached/recursive/has_many_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ def test_embedded_recursively
def test_embedded_by_reference
refute_predicate(has_many, :embedded_by_reference?)
end

def test_thread_safety_hydration
# Simulate race condition by setting up a dehydrated record
record = AssociatedRecord.new
dehydrated_data = [{ "id" => 1, "name" => "Test" }] # Mock dehydrated data
record.instance_variable_set(has_many.dehydrated_variable_name, dehydrated_data)

# Simulate multiple threads trying to hydrate concurrently
threads = []
errors = []

10.times do
threads << Thread.new do
begin
# Call the cached accessor which triggers hydration
record.fetch_deeply_associated_records
rescue => e
errors << e
end
end
end

threads.each(&:join)

# Assert no NameError occurred (which would indicate the race condition)
assert_empty(errors, "Race condition detected: #{errors.map(&:message).join(', ')}")
end
end
end
end
Expand Down