Skip to content

Commit 09e7064

Browse files
committed
feat: select a wake-up adapter automatically
A commit in a web process did not wake a worker process unless somebody configured an adapter, so delivery waited out the polling interval. The pieces existed and were measured. Nothing chose between them. config.wake_up_adapter now takes a name or an adapter, the way config.cache_store and config.active_job.queue_adapter do, rather than one setting for a mode and another for an instance. It defaults to :automatic, which prefers a configured Redis URL, then PostgreSQL notifications, then polling. :in_process opts out. An unknown name raises rather than quietly polling, because a typo that silently costs a second of latency is the failure this is meant to remove. LISTEN does not survive a transaction pooler, so the PostgreSQL session is probed first. The probe reports three outcomes rather than two. A definite false means a pooler took the session, and polling is chosen with a warning. A probe that could not run at all is not evidence of a pooler, so notifications are still chosen and the reason says the session was not probed. Conflating those two would downgrade any deployment whose connection cannot answer the probe. The choice is now readable. SolidObjects.wake_up.capability names the adapter, whether it crosses processes, its measured floor, and why. The doctor reports it. The polling-only warning now fires on what was installed rather than on whether a setting was set, which is what it meant to ask: the old guard returned early for any configured adapter, and :automatic is always configured. Two tests moved to :in_process rather than changing what they assert. The polling warning test is about the warning, not about selection, and the enqueue statement count is about the enqueue transaction, not about the NOTIFY that a cross-process adapter adds after the commit.
1 parent dde241e commit 09e7064

21 files changed

Lines changed: 539 additions & 42 deletions

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
## Unreleased
44

5+
- Select a wake-up adapter automatically. `config.wake_up_adapter` now takes a
6+
name or an adapter, as `config.cache_store` and
7+
`config.active_job.queue_adapter` do, and defaults to `:automatic`. Selection
8+
prefers a configured Redis URL, then PostgreSQL notifications, then polling.
9+
`:in_process` opts out, and an unknown name raises rather than quietly
10+
polling.
11+
- PostgreSQL deployments that configure nothing now use notifications. They gain
12+
cross-process wake-up, a connection per waiting thread outside the pool, and
13+
one `NOTIFY` per enqueue after the commit. Set
14+
`config.wake_up_adapter = :in_process` to keep polling.
15+
- Probe the PostgreSQL session before selecting notifications, because `LISTEN`
16+
does not survive a transaction pooler such as PgBouncer. A session that does
17+
not outlive a statement falls back to polling and warns once. A probe that
18+
cannot run is not treated as a pooler.
19+
- Report the resolved choice. `SolidObjects.wake_up.capability` names the
20+
adapter, whether it crosses processes, its measured floor, and why it was
21+
chosen. The doctor reports it, and the polling-only warning now fires on what
22+
was installed rather than on whether a setting was set.
523
- Read the durable row rather than the query cache in `MessageReference#status`,
624
`MessageReference#result`, and an actor snapshot. A caller that polls holds one
725
query cache for the whole poll, and the worker that finishes the message is

‎docs/roadmap.md‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,21 +122,20 @@
122122

123123
- Wake-up strategy: in-process signaling, durable polling, injection, and
124124
cross-process adapters for PostgreSQL and Redis are implemented and tested.
125-
What is not done is making any of them automatic. In-process signaling cannot
126-
cross process boundaries, so by default a commit in a web process does not
127-
wake a broadcast executor in a worker process and that delivery waits up to
128-
the current adaptive polling interval, up to the one-second
129-
`idle_polling_interval` default. The runtime warns once when it observes this
130-
topology without an adapter. An adapter removes that floor, measured before
131-
adaptive polling at 103.7 ms to 2.9 ms at p50 on PostgreSQL and 103.8 ms to
132-
5.7 ms on Redis, but each stays opt-in for a reason: the PostgreSQL adapter
133-
opens a connection per waiting thread outside the pool and `LISTEN` does not
134-
survive a transaction-pooling proxy such as PgBouncer, and Redis is not a
135-
dependency of this gem.
136-
`WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
137-
default elsewhere; it never selects Redis. An application that configures
138-
nothing keeps polling, and MySQL applications keep polling unless they
139-
configure Redis explicitly.
125+
Selection is automatic. `config.wake_up_adapter` defaults to `:automatic` and
126+
prefers a configured Redis URL, then PostgreSQL notifications, then polling,
127+
so an application that configures nothing no longer polls on PostgreSQL. An
128+
adapter removes the one-second floor, measured before adaptive polling at
129+
103.7 ms to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis. Each
130+
carries a cost that selection now states rather than hides: the PostgreSQL
131+
adapter opens a connection per waiting thread outside the pool and adds one
132+
`NOTIFY` per enqueue, and Redis is not a dependency of this gem.
133+
`LISTEN` does not survive a transaction-pooling proxy such as PgBouncer, so
134+
the session is probed and a pooled one falls back to polling and warns once.
135+
`SolidObjects.wake_up.capability` reports the adapter, whether it crosses
136+
processes, its floor, and why, and the doctor shows the same record.
137+
MySQL still polls. It has no notification channel, and no MySQL notifier has
138+
been measured against polling on the same hardware, so none is shipped.
140139
- Realtime: scalar and dependency-driven keyed ERB component replacement or
141140
morphing, personalized refresh authorization, revision fencing, coalescing,
142141
reconnect convergence, batched refreshes, and personalized state payloads are

‎lib/solid_objects.rb‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
require "solid_objects/actor_channel"
5050
require "solid_objects/action_cable_broadcast_adapter"
5151
require "solid_objects/database_adapter"
52+
require "solid_objects/wake_up_capability"
5253
require "solid_objects/wake_up"
5354
require "solid_objects/wake_up_adapters/postgresql"
5455
require "solid_objects/wake_up_adapters/redis"
@@ -198,9 +199,36 @@ def database_adapter
198199
@database_adapter ||= DatabaseAdapter.for(SolidObjects::Record.connection)
199200
end
200201

201-
# @rbs () -> WakeUp
202+
# @rbs () -> untyped
202203
def wake_up
203-
@wake_up ||= configuration.wake_up_adapter || WakeUp.new
204+
@wake_up ||= resolve_wake_up
205+
end
206+
207+
# @rbs () -> void
208+
def reset_wake_up!
209+
@wake_up = nil
210+
WakeUpAdapters.reset_pooled_warning!
211+
end
212+
213+
# @rbs () -> untyped
214+
def resolve_wake_up
215+
WakeUpAdapters.build(configuration.wake_up_adapter)
216+
rescue ArgumentError
217+
raise
218+
rescue => error
219+
unreachable_wake_up(error)
220+
end
221+
222+
# @rbs (Exception) -> untyped
223+
def unreachable_wake_up(error)
224+
adapter = WakeUp.new
225+
adapter.capability = WakeUpCapability.new(
226+
adapter: :in_process,
227+
crosses_processes: false,
228+
measured_floor_ms: nil,
229+
reason: "the database could not be reached to select an adapter: #{error.class}"
230+
)
231+
adapter
204232
end
205233
end
206234
end

‎lib/solid_objects/configuration.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def initialize
141141
@connects_to = nil
142142
@stream_signing_secret = nil
143143
@broadcast_adapter = nil
144-
@wake_up_adapter = nil
144+
@wake_up_adapter = :automatic
145145
@component_path_resolver = nil
146146
@component_authorization_context = ->(controller:) { controller }
147147
@payload_authorization_context = ->(connection:) { connection }

‎lib/solid_objects/doctor.rb‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ def call
113113
schema_check,
114114
check_authorization,
115115
check_database_server,
116+
check_wake_up,
116117
schema_check.failed? ? skipped_runtime : check_runtime,
117118
ready_for_round_trip?(configuration_check, schema_check) ?
118119
check_sync_round_trip :
@@ -208,6 +209,19 @@ def check_database_server
208209
warn_check(:database_server, "#{error.class}: #{error.message}")
209210
end
210211

212+
# @rbs () -> Check
213+
def check_wake_up
214+
capability = SolidObjects.wake_up.capability
215+
floor = capability.measured_floor_ms
216+
summary = "#{capability.adapter}: #{capability.reason}"
217+
summary += ", floor #{floor} ms" if floor
218+
return pass(:wake_up, summary) if capability.crosses_processes
219+
220+
warn_check(:wake_up, "#{summary}; a commit in one process cannot wake another")
221+
rescue => error
222+
warn_check(:wake_up, "#{error.class}: #{error.message}")
223+
end
224+
211225
# @rbs () -> Check
212226
def check_runtime
213227
cutoff = SolidObjects.database_adapter.database_now -

‎lib/solid_objects/process_registry.rb‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ def deregister(process_record, now: SolidObjects.database_adapter.database_now)
6161

6262
# @rbs () -> void
6363
def warn_if_polling_is_only_cross_process_wake_up
64-
return if SolidObjects.configuration.wake_up_adapter
64+
wake_up = SolidObjects.wake_up
65+
return unless wake_up.respond_to?(:capability)
66+
return if wake_up.capability.crosses_processes
6567

6668
polling_warning_mutex.synchronize do
6769
return if polling_warning_emitted?

‎lib/solid_objects/wake_up.rb‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module SolidObjects
44
class WakeUp
5+
include ReportsWakeUpCapability
6+
57
class Watch
68
# @rbs @wake_up: WakeUp
79
# @rbs @generation: Integer
@@ -29,6 +31,16 @@ def initialize
2931
@generation = 0
3032
end
3133

34+
# @rbs () -> WakeUpCapability
35+
def default_capability
36+
WakeUpCapability.new(
37+
adapter: :in_process,
38+
crosses_processes: false,
39+
measured_floor_ms: nil,
40+
reason: "in-process signalling, which a commit in another process cannot reach"
41+
)
42+
end
43+
3244
# @rbs () -> void
3345
def signal
3446
mutex.synchronize do

‎lib/solid_objects/wake_up_adapters.rb‎

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,159 @@
22

33
module SolidObjects
44
module WakeUpAdapters
5+
POSTGRESQL_FLOOR_MS = 2.9
6+
REDIS_FLOOR_MS = 5.7
7+
REDIS_URL_VARIABLE = "SOLID_OBJECTS_REDIS_URL"
8+
9+
@pooled_warning_mutex = Thread::Mutex.new
10+
@pooled_warning_emitted = false
11+
512
module_function
613

7-
# Returns the best wake-up strategy for a connection: cross-process
8-
# notifications where the database provides them, and the in-process
9-
# default everywhere else.
10-
#
11-
# This is deliberately not the default. A notification adapter opens a
12-
# connection per waiting thread outside the pool, and `LISTEN` does not
13-
# survive a transaction-pooling proxy such as PgBouncer, so adopting it is
14-
# a deployment decision rather than an upgrade side effect.
15-
#
14+
NAMES = %i[automatic in_process postgresql redis].freeze
15+
1616
# @rbs (?untyped) -> untyped
1717
def for(connection = Record.connection)
18-
return Postgresql.new if DatabaseAdapter.family(connection) == :postgresql
18+
select(connection)
19+
end
20+
21+
# @rbs (untyped) -> untyped
22+
def build(setting)
23+
return select if setting.nil? || setting == :automatic
24+
return named(setting) if setting.is_a?(Symbol)
25+
26+
configured(setting)
27+
end
28+
29+
# @rbs (Symbol) -> untyped
30+
def named(name)
31+
case name
32+
when :in_process then labelled(WakeUp.new, :in_process, false, nil, "in-process signalling was requested")
33+
when :postgresql then labelled(Postgresql.new, :postgresql_notify, true, POSTGRESQL_FLOOR_MS, "PostgreSQL LISTEN was requested")
34+
when :redis then labelled(Redis.new(url: redis_url), :redis, true, REDIS_FLOOR_MS, "Redis was requested")
35+
else
36+
raise ArgumentError, "unknown wake_up_adapter #{name.inspect}, expected one of #{NAMES.join(", ")} or an adapter"
37+
end
38+
end
39+
40+
# @rbs (untyped) -> untyped
41+
def configured(adapter)
42+
return adapter unless adapter.respond_to?(:capability=)
43+
44+
labelled(adapter, :configured, true, nil, "an adapter was configured, so selection did not run")
45+
end
46+
47+
# @rbs (untyped, Symbol, bool, Numeric?, String) -> untyped
48+
def labelled(adapter, name, crosses_processes, floor, reason)
49+
adapter.capability = WakeUpCapability.new(
50+
adapter: name,
51+
crosses_processes:,
52+
measured_floor_ms: floor,
53+
reason:
54+
)
55+
adapter
56+
end
57+
58+
# @rbs (?untyped) -> untyped
59+
def select(connection = Record.connection)
60+
url = redis_url
61+
return redis_selection(url) if url
62+
63+
family = DatabaseAdapter.family(connection)
64+
return postgresql_selection(connection) if family == :postgresql
65+
66+
polling_selection(family)
67+
end
68+
69+
# @rbs (untyped) -> bool?
70+
def session_survives_transactions?(connection)
71+
previous = connection.select_value("SELECT current_setting('application_name')")
72+
token = SecureRandom.hex(8)
73+
connection.execute("SET application_name = #{connection.quote(token)}")
74+
connection.select_value("SELECT current_setting('application_name')") == token
75+
rescue
76+
nil
77+
ensure
78+
restore_application_name(connection, previous)
79+
end
80+
81+
# @rbs () -> void
82+
def reset_pooled_warning!
83+
@pooled_warning_mutex.synchronize { @pooled_warning_emitted = false }
84+
end
85+
86+
# @rbs () -> String?
87+
def redis_url
88+
value = ENV[REDIS_URL_VARIABLE].to_s
89+
value.empty? ? nil : value
90+
end
91+
92+
# @rbs (String) -> untyped
93+
def redis_selection(url)
94+
labelled(
95+
Redis.new(url:), :redis, true, REDIS_FLOOR_MS,
96+
"#{REDIS_URL_VARIABLE} is set, so Redis carries the signal between processes"
97+
)
98+
end
99+
100+
# @rbs (untyped) -> untyped
101+
def postgresql_selection(connection)
102+
survives = session_survives_transactions?(connection)
103+
return pooled_selection if survives == false
104+
105+
labelled(
106+
Postgresql.new, :postgresql_notify, true, POSTGRESQL_FLOOR_MS,
107+
survives ? "PostgreSQL LISTEN is available and the session outlives a transaction"
108+
: "PostgreSQL LISTEN was selected without a session probe"
109+
)
110+
end
111+
112+
# @rbs () -> untyped
113+
def pooled_selection
114+
warn_pooled_session_once
115+
polling_adapter(
116+
"the PostgreSQL session does not outlive a transaction, which a transaction " \
117+
"pooler such as PgBouncer causes, so LISTEN would never fire"
118+
)
119+
end
120+
121+
# @rbs (Symbol?) -> untyped
122+
def polling_selection(family)
123+
polling_adapter(
124+
"#{family || "this database"} has no notification channel and " \
125+
"#{REDIS_URL_VARIABLE} is not set"
126+
)
127+
end
128+
129+
# @rbs (String) -> untyped
130+
def polling_adapter(reason)
131+
labelled(
132+
WakeUp.new, :polling, false,
133+
SolidObjects.configuration.idle_polling_interval * 1_000, reason
134+
)
135+
end
136+
137+
# @rbs () -> void
138+
def warn_pooled_session_once
139+
@pooled_warning_mutex.synchronize do
140+
return if @pooled_warning_emitted
141+
142+
SolidObjects.configuration.logger.warn(
143+
event: "solid_objects.wake_up.pooled_session",
144+
reason: "PostgreSQL notifications were not selected because the session " \
145+
"does not outlive a transaction"
146+
)
147+
@pooled_warning_emitted = true
148+
end
149+
end
150+
151+
# @rbs (untyped, untyped) -> void
152+
def restore_application_name(connection, previous)
153+
return if previous.nil?
19154

20-
WakeUp.new
155+
connection.execute("SET application_name = #{connection.quote(previous)}")
156+
rescue
157+
nil
21158
end
22159
end
23160
end

‎lib/solid_objects/wake_up_adapters/postgresql.rb‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ module WakeUpAdapters
1010
# when one is available, so a missed or failed notification costs latency
1111
# rather than correctness.
1212
class Postgresql
13+
include ReportsWakeUpCapability
14+
1315
CHANNEL = "solid_objects_wake_up"
1416
FAILED_WAIT_INTERVAL = 0.05
1517

@@ -19,6 +21,16 @@ class Postgresql
1921

2022
attr_reader :channel
2123

24+
# @rbs () -> WakeUpCapability
25+
def default_capability
26+
WakeUpCapability.new(
27+
adapter: :postgresql_notify,
28+
crosses_processes: true,
29+
measured_floor_ms: 2.9,
30+
reason: "PostgreSQL LISTEN carries the signal between processes"
31+
)
32+
end
33+
2234
# @rbs (?channel: String) -> void
2335
def initialize(channel: CHANNEL)
2436
@channel = channel

0 commit comments

Comments
 (0)