Skip to content

Commit 6253874

Browse files
byrootwildmaples
andcommitted
Make AbstractAdapter#lock thread local by default
Fix: rails#45994 A semi-common issue since Ruby 3.0.2 is that using a fiber inside a transaction cause a deadlock: ```ruby Post.transaction do enum = Enumerator.new do |y| y.yield Post.first # stuck end enum.next end ``` This is because in https://bugs.ruby-lang.org/issues/17827 Ruby changed Monitor to be owned by the calling Fiber rather than Thread. And since the Active Record connection pool is per Thread, we end up in a situation where a Fiber tries to acquire a lock owned by another fiber with no change to ever resolve. In rails#46519 We've made that lock optional as it's only needed for system tests. Now this PR introduce an alternative lock implementation that behave like Monitor used to up to Ruby 2.7, and we use this one if `ActiveSupport::IsolatedExecutionState.context` is a Thread. If it's a Fiber, we continue to use the implementation derived from the stdlib that is Fiber based. Co-Authored-By: Maple Ong <[email protected]>
1 parent d666a39 commit 6253874

File tree

5 files changed

+93
-13
lines changed

5 files changed

+93
-13
lines changed

activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def lock_thread=(lock_thread)
175175
end
176176

177177
if (active_connection = @thread_cached_conns[connection_cache_key(current_thread)])
178-
active_connection.synchronized = lock_thread
178+
active_connection.lock_thread = @lock_thread
179179
end
180180
end
181181

@@ -357,7 +357,7 @@ def clear_reloadable_connections!
357357
# - ActiveRecord::ConnectionTimeoutError no connection can be obtained from the pool.
358358
def checkout(checkout_timeout = @checkout_timeout)
359359
connection = checkout_and_verify(acquire_connection(checkout_timeout))
360-
connection.synchronized = @lock_thread
360+
connection.lock_thread = @lock_thread
361361
connection
362362
end
363363

@@ -375,6 +375,7 @@ def checkin(conn)
375375
conn.expire
376376
end
377377

378+
conn.lock_thread = nil
378379
@available.add conn
379380
end
380381
end

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca
156156
@idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
157157
@visitor = arel_visitor
158158
@statements = build_statement_pool
159-
self.synchronized = false
159+
self.lock_thread = nil
160160

161161
@prepared_statements = self.class.type_cast_config_to_boolean(
162162
@config.fetch(:prepared_statements) { default_prepared_statements }
@@ -172,8 +172,12 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca
172172
@verified = false
173173
end
174174

175-
def synchronized=(synchronized) # :nodoc:
176-
@lock = if synchronized
175+
def lock_thread=(lock_thread) # :nodoc:
176+
@lock =
177+
case lock_thread
178+
when Thread
179+
ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new
180+
when Fiber
177181
ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new
178182
else
179183
ActiveSupport::Concurrency::NullLock

activerecord/test/cases/connection_pool_test.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,19 @@ def setup
801801
@connection_test_model_class = ThreadConnectionTestModel
802802
end
803803

804+
def test_lock_thread_allow_fiber_reentrency
805+
@pool.lock_thread = true
806+
connection = @pool.checkout
807+
connection.transaction do
808+
enumerator = Enumerator.new do |yielder|
809+
connection.transaction do
810+
yielder.yield 1
811+
end
812+
end
813+
assert_equal 1, enumerator.next
814+
end
815+
end
816+
804817
private
805818
def new_thread(...)
806819
Thread.new(...)

activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44

55
module ActiveSupport
66
module Concurrency
7-
# A monitor that will permit dependency loading while blocked waiting for
8-
# the lock.
9-
class LoadInterlockAwareMonitor < Monitor
7+
module LoadInterlockAwareMonitorMixin # :nodoc:
108
EXCEPTION_NEVER = { Exception => :never }.freeze
119
EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze
1210
private_constant :EXCEPTION_NEVER, :EXCEPTION_IMMEDIATE
@@ -29,5 +27,46 @@ def synchronize(&block)
2927
end
3028
end
3129
end
30+
# A monitor that will permit dependency loading while blocked waiting for
31+
# the lock.
32+
class LoadInterlockAwareMonitor < Monitor
33+
include LoadInterlockAwareMonitorMixin
34+
end
35+
36+
class ThreadLoadInterlockAwareMonitor # :nodoc:
37+
prepend LoadInterlockAwareMonitorMixin
38+
39+
def initialize
40+
@owner = nil
41+
@count = 0
42+
@mutex = Mutex.new
43+
end
44+
45+
private
46+
def mon_try_enter
47+
if @owner != Thread.current
48+
return false unless @mutex.try_lock
49+
@owner = Thread.current
50+
end
51+
@count += 1
52+
end
53+
54+
def mon_enter
55+
@mutex.lock if @owner != Thread.current
56+
@owner = Thread.current
57+
@count += 1
58+
end
59+
60+
def mon_exit
61+
unless @owner == Thread.current
62+
raise ThreadError, "current thread not owner"
63+
end
64+
65+
@count -= 1
66+
return unless @count == 0
67+
@owner = nil
68+
@mutex.unlock
69+
end
70+
end
3271
end
3372
end

activesupport/test/concurrency/load_interlock_aware_monitor_test.rb

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@
66

77
module ActiveSupport
88
module Concurrency
9-
class LoadInterlockAwareMonitorTest < ActiveSupport::TestCase
10-
def setup
11-
@monitor = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new
12-
end
13-
9+
module LoadInterlockAwareMonitorTests
1410
def test_entering_with_no_blocking
1511
assert @monitor.mon_enter
1612
end
@@ -51,5 +47,32 @@ def test_entering_with_blocking
5147
assert able_to_load
5248
end
5349
end
50+
51+
class LoadInterlockAwareMonitorTest < ActiveSupport::TestCase
52+
include LoadInterlockAwareMonitorTests
53+
54+
def setup
55+
@monitor = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new
56+
end
57+
end
58+
59+
class ThreadLoadInterlockAwareMonitorTest < ActiveSupport::TestCase
60+
include LoadInterlockAwareMonitorTests
61+
62+
def setup
63+
@monitor = ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new
64+
end
65+
66+
def test_lock_owned_by_thread
67+
@monitor.synchronize do
68+
enumerator = Enumerator.new do |yielder|
69+
@monitor.synchronize do
70+
yielder.yield 42
71+
end
72+
end
73+
assert_equal 42, enumerator.next
74+
end
75+
end
76+
end
5477
end
5578
end

0 commit comments

Comments
 (0)