Skip to content

Commit 33694bc

Browse files
committed
feat: add PostgreSQL cross-process wake-up
In-process signalling cannot reach another process, so a commit in a web process left a worker waiting out polling_interval and every reactive update paid up to 100ms before delivery began. The optional adapter listens on a dedicated connection and notifies through the pool, taking measured cross-process wake-up latency from 103.7ms to 2.9ms at p50 against PostgreSQL 17. Polling remains the upper bound, so a missed or failed notification costs latency rather than correctness, and neither signalling nor waiting raises into its caller.
1 parent a6b8e08 commit 33694bc

7 files changed

Lines changed: 343 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add `SolidObjects::WakeUpAdapters::Postgresql`, an optional cross-process
6+
wake-up using PostgreSQL notifications. In-process signalling cannot reach a
7+
worker process, so reactive delivery waited out `polling_interval`. With the
8+
adapter configured, measured cross-process wake-up latency drops from 103.7 ms
9+
to 2.9 ms at p50. The polling interval remains the upper bound, and neither
10+
signalling nor waiting raises into its caller.
11+
312
## 0.7.3 - 2026-08-09
413

514
- Coordinate batched component refreshes by revision as well as scope and batch

docs/benchmarks.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ Cable delivery, or browser rendering, which dominate wall-clock time in a real
7676
deployment and make the request-count difference matter more than it appears
7777
here. End-to-end latency against a deployed application has not been measured.
7878

79+
## Cross-process wake-up
80+
81+
Measured 2026-08-09 against PostgreSQL 17, 30 samples, with `polling_interval`
82+
at its 100 ms default and a signal sent 2 ms after the waiter began.
83+
84+
| Wake-up strategy | p50 | p95 |
85+
| --- | ---: | ---: |
86+
| In-process `WakeUp` | 103.7 ms | 105.1 ms |
87+
| `WakeUpAdapters::Postgresql` | 2.9 ms | 5.1 ms |
88+
89+
The in-process wake-up cannot reach another process, so a worker waits out the
90+
full polling interval no matter how quickly the web process committed. The
91+
notification adapter removes that floor rather than shrinking it, and the
92+
polling interval remains the upper bound if a notification is missed.
93+
7994
## Durable row growth
8095

8196
The storage cost is deterministic even when latency is not:

docs/realtime.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,29 @@ applications discover the namespaced engine asset. Applications created with
110110
explicitly serve the module. Turbo's normal morph rules still apply; use
111111
`data-turbo-permanent` for elements that must never be changed.
112112

113+
## Cross-process wake-up
114+
115+
Runtime roles poll for work and are woken early by an in-process signal. That
116+
signal cannot cross process boundaries, so a commit in a Puma process does not
117+
wake a broadcast executor in a worker process, and delivery waits out
118+
`polling_interval`, 100 ms by default.
119+
120+
On PostgreSQL, install the notification adapter to remove that delay:
121+
122+
```ruby
123+
# config/initializers/solid_objects.rb
124+
configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Postgresql.new
125+
```
126+
127+
Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
128+
p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
129+
notification costs latency, never correctness, and signalling never raises into
130+
the caller that committed. `LISTEN` needs its own connection, so the adapter
131+
opens one outside the pool and releases it on `stop`.
132+
133+
Applications on SQLite or MySQL, or that do not configure the adapter, keep the
134+
existing polling behaviour.
135+
113136
## Batched component refreshes
114137

115138
A component refresh costs one browser request. When one actor mutation changes

lib/solid_objects.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
require "solid_objects/actor_channel"
4747
require "solid_objects/action_cable_broadcast_adapter"
4848
require "solid_objects/wake_up"
49+
require "solid_objects/wake_up_adapters/postgresql"
4950
require "solid_objects/effect_registry"
5051
require "solid_objects/commit_action_registry"
5152
require "solid_objects/lease"
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# rbs_inline: enabled
2+
3+
module SolidObjects
4+
module WakeUpAdapters
5+
# Wakes runtime roles across processes using PostgreSQL notifications.
6+
#
7+
# The in-process wake-up cannot reach another process, so a commit in a web
8+
# process leaves a worker waiting out its polling interval. This adapter
9+
# keeps that polling interval as the upper bound and delivers a notification
10+
# when one is available, so a missed or failed notification costs latency
11+
# rather than correctness.
12+
class Postgresql
13+
CHANNEL = "solid_objects_wake_up"
14+
FAILED_WAIT_INTERVAL = 0.05
15+
16+
# @rbs @channel: String
17+
# @rbs @mutex: Thread::Mutex
18+
# @rbs @connection: untyped
19+
20+
attr_reader :channel
21+
22+
# @rbs (?channel: String) -> void
23+
def initialize(channel: CHANNEL)
24+
@channel = channel
25+
@mutex = Thread::Mutex.new
26+
@connection = nil
27+
end
28+
29+
# @rbs () -> bool
30+
def signal
31+
notify_channel
32+
true
33+
rescue => error
34+
instrument_failure(:signal, error)
35+
false
36+
end
37+
38+
# @rbs (timeout: Numeric) -> bool
39+
def wait(timeout:)
40+
connection = listening_connection
41+
!connection.raw_connection.wait_for_notify(timeout.to_f).nil?
42+
rescue => error
43+
instrument_failure(:wait, error)
44+
pace_after_failure(timeout)
45+
false
46+
end
47+
48+
# Starts listening before a caller blocks, so a notification sent between
49+
# startup and the first wait is not missed.
50+
# @rbs () -> bool
51+
def listen
52+
listening_connection
53+
true
54+
rescue => error
55+
instrument_failure(:listen, error)
56+
false
57+
end
58+
59+
# @rbs () -> bool
60+
def stop
61+
mutex.synchronize do
62+
connection = @connection
63+
@connection = nil
64+
return false unless connection
65+
66+
connection.disconnect!
67+
true
68+
end
69+
rescue
70+
false
71+
end
72+
73+
private
74+
75+
attr_reader :mutex
76+
77+
# @rbs () -> void
78+
def notify_channel
79+
Record.connection_pool.with_connection do |connection|
80+
connection.execute("NOTIFY #{connection.quote_table_name(channel)}")
81+
end
82+
end
83+
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.
86+
# @rbs () -> untyped
87+
def listening_connection
88+
mutex.synchronize do
89+
return @connection if @connection&.active?
90+
91+
@connection = Record.connection_pool.send(:new_connection)
92+
@connection.execute("LISTEN #{@connection.quote_table_name(channel)}")
93+
@connection
94+
end
95+
end
96+
97+
# @rbs (Numeric) -> void
98+
def pace_after_failure(timeout)
99+
interval = [ timeout.to_f, FAILED_WAIT_INTERVAL ].min
100+
return unless interval.positive?
101+
102+
sleep interval
103+
end
104+
105+
# @rbs (Symbol, Exception) -> void
106+
def instrument_failure(operation, error)
107+
SolidObjects.instrument(
108+
:"wake_up.failed",
109+
adapter: "postgresql",
110+
operation: operation.to_s,
111+
error_class: error.class.name,
112+
error_message: error.message
113+
)
114+
end
115+
end
116+
end
117+
end
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Generated from lib/solid_objects/wake_up_adapters/postgresql.rb with RBS::Inline
2+
3+
module SolidObjects
4+
module WakeUpAdapters
5+
# Wakes runtime roles across processes using PostgreSQL notifications.
6+
#
7+
# The in-process wake-up cannot reach another process, so a commit in a web
8+
# process leaves a worker waiting out its polling interval. This adapter
9+
# keeps that polling interval as the upper bound and delivers a notification
10+
# when one is available, so a missed or failed notification costs latency
11+
# rather than correctness.
12+
class Postgresql
13+
CHANNEL: ::String
14+
15+
FAILED_WAIT_INTERVAL: ::Float
16+
17+
@connection: untyped
18+
19+
@mutex: Thread::Mutex
20+
21+
@channel: String
22+
23+
attr_reader channel: untyped
24+
25+
# @rbs (?channel: String) -> void
26+
def initialize: (?channel: String) -> void
27+
28+
# @rbs () -> bool
29+
def signal: () -> bool
30+
31+
# @rbs (timeout: Numeric) -> bool
32+
def wait: (timeout: Numeric) -> bool
33+
34+
# Starts listening before a caller blocks, so a notification sent between
35+
# startup and the first wait is not missed.
36+
# @rbs () -> bool
37+
def listen: () -> bool
38+
39+
# @rbs () -> bool
40+
def stop: () -> bool
41+
42+
private
43+
44+
attr_reader mutex: untyped
45+
46+
# @rbs () -> void
47+
def notify_channel: () -> void
48+
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+
# @rbs () -> untyped
52+
def listening_connection: () -> untyped
53+
54+
# @rbs (Numeric) -> void
55+
def pace_after_failure: (Numeric) -> void
56+
57+
# @rbs (Symbol, Exception) -> void
58+
def instrument_failure: (Symbol, Exception) -> void
59+
end
60+
end
61+
end
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# frozen_string_literal: true
2+
3+
require "database_test_helper"
4+
require "timeout"
5+
6+
class PostgresqlWakeUpTest < ActiveSupport::TestCase
7+
setup do
8+
skip unless postgresql?
9+
10+
@adapter = SolidObjects::WakeUpAdapters::Postgresql.new
11+
end
12+
13+
teardown { @adapter&.stop }
14+
15+
test "a signal from another connection wakes a waiter" do
16+
waiting = Queue.new
17+
woken = Queue.new
18+
waiter = Thread.new do
19+
@adapter.listen
20+
waiting << true
21+
started = monotonic_now
22+
@adapter.wait(timeout: 5)
23+
woken << monotonic_now - started
24+
end
25+
Timeout.timeout(5) { waiting.pop }
26+
27+
signal_from_another_connection
28+
29+
elapsed = Timeout.timeout(5) { woken.pop }
30+
waiter.join(5)
31+
assert_operator elapsed, :<, 1.0,
32+
"a cross-connection signal should wake the waiter well before the timeout"
33+
end
34+
35+
test "waiting returns after the timeout when nothing signals" do
36+
@adapter.listen
37+
started = monotonic_now
38+
39+
@adapter.wait(timeout: 0.2)
40+
41+
elapsed = monotonic_now - started
42+
assert_operator elapsed, :>=, 0.15
43+
assert_operator elapsed, :<, 2.0
44+
end
45+
46+
test "every waiter wakes on one signal" do
47+
waiters = 3
48+
waiting = Queue.new
49+
woken = Queue.new
50+
adapters = Array.new(waiters) { SolidObjects::WakeUpAdapters::Postgresql.new }
51+
threads = adapters.map do |adapter|
52+
Thread.new do
53+
adapter.listen
54+
waiting << true
55+
adapter.wait(timeout: 5)
56+
woken << true
57+
end
58+
end
59+
waiters.times { Timeout.timeout(5) { waiting.pop } }
60+
61+
signal_from_another_connection
62+
63+
waiters.times { Timeout.timeout(5) { woken.pop } }
64+
threads.each { |thread| thread.join(5) }
65+
assert_equal 0, woken.size
66+
ensure
67+
adapters&.each(&:stop)
68+
end
69+
70+
test "signalling never raises into the caller" do
71+
broken = SolidObjects::WakeUpAdapters::Postgresql.new(channel: "solid_objects_missing")
72+
broken.define_singleton_method(:notify_channel) { raise "boom" }
73+
74+
assert_nothing_raised { broken.signal }
75+
ensure
76+
broken&.stop
77+
end
78+
79+
test "waiting never raises into the caller" do
80+
broken = SolidObjects::WakeUpAdapters::Postgresql.new
81+
broken.define_singleton_method(:listening_connection) { raise "boom" }
82+
started = monotonic_now
83+
84+
assert_nothing_raised { broken.wait(timeout: 0.1) }
85+
86+
assert_operator monotonic_now - started, :>=, 0.05,
87+
"a failed wait must still pace itself rather than spin"
88+
ensure
89+
broken&.stop
90+
end
91+
92+
test "the adapter satisfies the wake-up contract" do
93+
assert_respond_to @adapter, :signal
94+
assert_respond_to @adapter, :wait
95+
SolidObjects.configuration.wake_up_adapter = @adapter
96+
97+
assert_same @adapter, SolidObjects.wake_up
98+
ensure
99+
SolidObjects.configuration.wake_up_adapter = nil
100+
SolidObjects.instance_variable_set(:@wake_up, nil)
101+
end
102+
103+
private
104+
105+
def postgresql?
106+
SolidObjects::Record.connection.adapter_name.match?(/postgres/i)
107+
end
108+
109+
def monotonic_now
110+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
111+
end
112+
113+
# Models a commit in a different process: a plain NOTIFY on its own connection.
114+
def signal_from_another_connection
115+
Thread.new { SolidObjects::WakeUpAdapters::Postgresql.new.signal }.join(5)
116+
end
117+
end

0 commit comments

Comments
 (0)