Skip to content

Commit 9a6ca78

Browse files
committed
fix: listen on a connection per waiting thread
The supervisor memoizes one adapter and shares it across runtime roles, so every waiter used one cached connection and called wait_for_notify on it concurrently. A notification was consumed by whichever waiter won, leaving the other roles asleep until their polling timeout, and concurrent use of one connection is unsafe besides. Open and track a listening connection per thread, and disconnect all of them on stop. The earlier test hid this by giving each waiter its own adapter, and passed even against the shared connection because it could not distinguish waking on a notification from returning at the timeout; it now asserts on latency.
1 parent 33694bc commit 9a6ca78

3 files changed

Lines changed: 81 additions & 33 deletions

File tree

lib/solid_objects/wake_up_adapters/postgresql.rb

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ class Postgresql
1515

1616
# @rbs @channel: String
1717
# @rbs @mutex: Thread::Mutex
18-
# @rbs @connection: untyped
18+
# @rbs @connections: Array[untyped]
1919

2020
attr_reader :channel
2121

2222
# @rbs (?channel: String) -> void
2323
def initialize(channel: CHANNEL)
2424
@channel = channel
2525
@mutex = Thread::Mutex.new
26-
@connection = nil
26+
@connections = []
2727
end
2828

2929
# @rbs () -> bool
@@ -58,21 +58,19 @@ def listen
5858

5959
# @rbs () -> bool
6060
def stop
61-
mutex.synchronize do
62-
connection = @connection
63-
@connection = nil
64-
return false unless connection
65-
66-
connection.disconnect!
67-
true
61+
open = mutex.synchronize do
62+
listening = connections.dup
63+
connections.clear
64+
listening
6865
end
69-
rescue
70-
false
66+
Thread.current[thread_key] = nil
67+
open.each { |connection| disconnect(connection) }
68+
open.any?
7169
end
7270

7371
private
7472

75-
attr_reader :mutex
73+
attr_reader :mutex, :connections
7674

7775
# @rbs () -> void
7876
def notify_channel
@@ -81,17 +79,38 @@ def notify_channel
8179
end
8280
end
8381

84-
# A listening connection is dedicated: `LISTEN` is per connection, and a
85-
# blocking wait must not hold a connection the rest of the runtime needs.
82+
# A listening connection is dedicated and per thread. `LISTEN` is per
83+
# connection, a blocking wait must not hold a connection the rest of the
84+
# runtime needs, and one connection cannot serve concurrent waiters: the
85+
# supervisor shares one adapter across roles, and a notification consumed
86+
# by one waiter would leave the others asleep until their poll expired.
8687
# @rbs () -> untyped
8788
def listening_connection
88-
mutex.synchronize do
89-
return @connection if @connection&.active?
89+
connection = Thread.current[thread_key]
90+
return connection if connection&.active?
9091

91-
@connection = Record.connection_pool.send(:new_connection)
92-
@connection.execute("LISTEN #{@connection.quote_table_name(channel)}")
93-
@connection
94-
end
92+
open_listening_connection
93+
end
94+
95+
# @rbs () -> untyped
96+
def open_listening_connection
97+
connection = Record.connection_pool.send(:new_connection)
98+
connection.execute("LISTEN #{connection.quote_table_name(channel)}")
99+
Thread.current[thread_key] = connection
100+
mutex.synchronize { connections << connection }
101+
connection
102+
end
103+
104+
# @rbs () -> Symbol
105+
def thread_key
106+
:"solid_objects_wake_up_#{object_id}"
107+
end
108+
109+
# @rbs (untyped) -> void
110+
def disconnect(connection)
111+
connection.disconnect!
112+
rescue
113+
nil
95114
end
96115

97116
# @rbs (Numeric) -> void

sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ module SolidObjects
1414

1515
FAILED_WAIT_INTERVAL: ::Float
1616

17-
@connection: untyped
17+
@connections: Array[untyped]
1818

1919
@mutex: Thread::Mutex
2020

@@ -43,14 +43,28 @@ module SolidObjects
4343

4444
attr_reader mutex: untyped
4545

46+
attr_reader connections: untyped
47+
4648
# @rbs () -> void
4749
def notify_channel: () -> void
4850

49-
# A listening connection is dedicated: `LISTEN` is per connection, and a
50-
# blocking wait must not hold a connection the rest of the runtime needs.
51+
# A listening connection is dedicated and per thread. `LISTEN` is per
52+
# connection, a blocking wait must not hold a connection the rest of the
53+
# runtime needs, and one connection cannot serve concurrent waiters: the
54+
# supervisor shares one adapter across roles, and a notification consumed
55+
# by one waiter would leave the others asleep until their poll expired.
5156
# @rbs () -> untyped
5257
def listening_connection: () -> untyped
5358

59+
# @rbs () -> untyped
60+
def open_listening_connection: () -> untyped
61+
62+
# @rbs () -> Symbol
63+
def thread_key: () -> Symbol
64+
65+
# @rbs (untyped) -> void
66+
def disconnect: (untyped) -> void
67+
5468
# @rbs (Numeric) -> void
5569
def pace_after_failure: (Numeric) -> void
5670

test/integration/postgresql_wake_up_test.rb

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,28 +43,43 @@ class PostgresqlWakeUpTest < ActiveSupport::TestCase
4343
assert_operator elapsed, :<, 2.0
4444
end
4545

46-
test "every waiter wakes on one signal" do
46+
# The supervisor memoizes one adapter and shares it across runtime roles, so
47+
# concurrent waiters go through a single adapter instance.
48+
test "every waiter on one shared adapter wakes on one signal" do
4749
waiters = 3
4850
waiting = Queue.new
4951
woken = Queue.new
50-
adapters = Array.new(waiters) { SolidObjects::WakeUpAdapters::Postgresql.new }
51-
threads = adapters.map do |adapter|
52+
threads = Array.new(waiters) do
5253
Thread.new do
53-
adapter.listen
54+
@adapter.listen
5455
waiting << true
55-
adapter.wait(timeout: 5)
56-
woken << true
56+
started = monotonic_now
57+
@adapter.wait(timeout: 5)
58+
woken << monotonic_now - started
5759
end
5860
end
5961
waiters.times { Timeout.timeout(5) { waiting.pop } }
6062

6163
signal_from_another_connection
6264

63-
waiters.times { Timeout.timeout(5) { woken.pop } }
65+
elapsed = waiters.times.map { Timeout.timeout(10) { woken.pop } }
66+
threads.each { |thread| thread.join(6) }
67+
# Waking on the notification, not falling through to the 5 second timeout.
68+
assert_operator elapsed.max, :<, 1.0,
69+
"every waiter should wake on the notification, not time out"
70+
end
71+
72+
test "each waiting thread listens on its own connection" do
73+
connections = Queue.new
74+
threads = Array.new(2) do
75+
Thread.new do
76+
@adapter.listen
77+
connections << Thread.current[:"solid_objects_wake_up_#{@adapter.object_id}"].object_id
78+
end
79+
end
6480
threads.each { |thread| thread.join(5) }
65-
assert_equal 0, woken.size
66-
ensure
67-
adapters&.each(&:stop)
81+
82+
assert_equal 2, connections.size.times.map { connections.pop }.uniq.length
6883
end
6984

7085
test "signalling never raises into the caller" do

0 commit comments

Comments
 (0)