Skip to content

Commit 588281f

Browse files
authored
Merge pull request #77 from cardmagic/feat/automatic-wake-up
feat: select a wake-up adapter automatically
2 parents dde241e + 967d830 commit 588281f

26 files changed

Lines changed: 922 additions & 82 deletions

‎CHANGELOG.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,39 @@
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+
- Prove the PostgreSQL notification path before selecting it, because `LISTEN`
16+
does not survive a transaction pooler such as PgBouncer. Selection listens on
17+
a probe channel, sends one `NOTIFY` from a second connection, and waits up to
18+
two seconds for it to arrive. A probe that does not deliver falls back to
19+
polling and warns once.
20+
- Select the wake-up adapter once per process. `SolidObjects.wake_up` memoised
21+
without a lock, so threads that raced for the first use each ran a full
22+
selection.
23+
- Keep the capability that a configured adapter reports about itself. A
24+
configured `SolidObjects::WakeUp` now reports `:in_process` and warns, rather
25+
than claim that it crosses processes.
26+
- Poll rather than pretend when a requested adapter cannot be built.
27+
`wake_up_adapter = :postgresql` on a database with no notification channel,
28+
`:redis` without `SOLID_OBJECTS_REDIS_URL`, and a Redis URL without the redis
29+
gem each log `solid_objects.wake_up.unavailable` once and record the reason in
30+
the capability, so the doctor warns rather than claim a cross-process wake-up
31+
that cannot happen.
32+
- Validate `wake_up_adapter` in `configure`. An unknown name raised at the first
33+
wake-up, which is after a commit, rather than at boot.
34+
- Report the resolved choice. `SolidObjects.wake_up.capability` names the
35+
adapter, whether it crosses processes, its measured floor, and why it was
36+
chosen. The doctor reports it, and the polling-only warning now fires on what
37+
was installed rather than on whether a setting was set.
538
- Read the durable row rather than the query cache in `MessageReference#status`,
639
`MessageReference#result`, and an actor snapshot. A caller that polls holds one
740
query cache for the whole poll, and the worker that finishes the message is

‎docs/adr/0011-wake-up-strategy.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ The interface supports:
2525

2626
MySQL uses polling or optional Redis. SQLite uses polling plus the in-process signal; multi-host SQLite is outside its supported operating model.
2727

28+
Selection is automatic. `wake_up_adapter` takes a name or an adapter and
29+
defaults to `:automatic`, which prefers a configured Redis URL, then PostgreSQL
30+
notifications, then polling. PostgreSQL is chosen only after a probe
31+
notification arrives, because `LISTEN` does not survive a transaction pooler. A
32+
requested adapter that the environment cannot provide polls and records why,
33+
rather than claim a wake-up it cannot deliver. `SolidObjects.wake_up.capability`
34+
reports the choice, and the doctor reports the same record.
35+
2836
The synchronous caller first attempts to claim and execute the actor locally,
2937
so the normal path has no worker polling leg. When another process owns the
3038
activation, coordination overhead from completion commit until the caller's
@@ -45,5 +53,7 @@ Timeout does not cancel durable work.
4553
- Redis loss only increases latency and never loses durable work.
4654
- Every adapter retains periodic polling to close startup, reconnect, and missed-message races.
4755
- A process that returns `false` from a timed wait participates in backoff; an older custom adapter that returns `nil` keeps the fast cadence.
48-
- A multi-process deployment without an adapter trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
56+
- A multi-process deployment whose installed adapter cannot cross processes trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
57+
- A PostgreSQL deployment that configures nothing now pays one `NOTIFY` per enqueue after the commit and one listening connection per waiting thread, outside the pool.
58+
- Selection runs once per process, under a lock, because the probe opens connections and waits.
4959
- Notification payloads never contain actor arguments or results.

‎docs/operations.md‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,27 @@ to `idle_polling_interval`, which defaults to one second. Actor workers clamp
245245
the ceiling to `lease_renewal_interval` while they may hold cached activations.
246246
Set the fast and idle values equal for a fixed cadence.
247247

248-
The default wake-up interrupts waits only in the current Ruby process. When a
249-
live process record shows that the database is shared across processes and no
250-
adapter is configured, the runtime logs
251-
`solid_objects.polling_only_cross_process_wake_up` once. Configure
252-
`WakeUpAdapters::Postgresql` or `WakeUpAdapters::Redis` when separate processes
253-
need prompt delivery. Without one, newly committed work can wait up to the
254-
current idle polling interval.
248+
Solid Objects selects a wake-up adapter on first use. `wake_up_adapter` defaults
249+
to `:automatic`, which prefers `SOLID_OBJECTS_REDIS_URL`, then PostgreSQL
250+
notifications, then polling. `SolidObjects.wake_up.capability` and the
251+
`wake_up` doctor check report what was installed, whether it crosses processes,
252+
its measured floor, and why.
253+
254+
The in-process signal interrupts waits only in the current Ruby process. When a
255+
live process record shows that the database is shared across processes and the
256+
installed adapter does not cross them, the runtime logs
257+
`solid_objects.polling_only_cross_process_wake_up` once. Newly committed work
258+
can then wait up to the current idle polling interval.
259+
260+
On PostgreSQL, selection proves the path first: it listens on a probe channel,
261+
notifies it from a second connection, and waits for the notification. A probe
262+
that does not arrive logs `solid_objects.wake_up.pooled_session` once and falls
263+
back to polling, because `LISTEN` does not survive a transaction pooler such as
264+
PgBouncer. A requested adapter that the environment cannot provide, such as
265+
`:postgresql` on MySQL or `:redis` without `SOLID_OBJECTS_REDIS_URL`, logs
266+
`solid_objects.wake_up.unavailable` once and polls rather than claim a
267+
cross-process wake-up that cannot happen. Only an unknown name is refused, and
268+
`configure` refuses it at boot.
255269

256270
The warning excludes process rows with the current hostname and PID. It can
257271
therefore appear during a rolling deployment or restart overlap when an older

‎docs/realtime.md‎

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -140,46 +140,52 @@ explicitly serve the module. Turbo's normal morph rules still apply; use
140140

141141
## Cross-process wake-up
142142

143-
Runtime roles poll for work and are woken early by an in-process signal. That
144-
signal cannot cross process boundaries, so a commit in a Puma process does not
145-
wake a broadcast executor in a worker process, and delivery waits out
143+
Runtime roles poll for work and are woken early by a signal. An in-process
144+
signal cannot cross process boundaries, so a commit in a Puma process would not
145+
wake a broadcast executor in a worker process, and delivery would wait out
146146
`polling_interval`, 100 ms by default.
147147

148-
On PostgreSQL, install the notification adapter to remove that delay:
148+
Solid Objects selects the adapter for you. `wake_up_adapter` defaults to
149+
`:automatic`, which prefers a configured Redis URL, then PostgreSQL
150+
notifications, then polling:
149151

150152
```ruby
151153
# config/initializers/solid_objects.rb
152-
configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
154+
configuration.wake_up_adapter = :automatic # the default
155+
configuration.wake_up_adapter = :in_process # opt out
156+
configuration.wake_up_adapter = :postgresql # force one
157+
configuration.wake_up_adapter = MyAdapter.new # your own
153158
```
154159

155-
`WakeUpAdapters.for` returns notifications on PostgreSQL and the in-process
156-
default on SQLite and MySQL, so the same line is safe across adapters. Name
157-
`SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it.
158-
159-
MySQL has no notification primitive. MySQL applications either keep polling and
160-
tune `polling_interval`, or configure the Redis adapter:
161-
162-
```ruby
163-
configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new(
164-
url: ENV["REDIS_URL"]
165-
)
166-
```
167-
168-
Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
169-
p50. The `redis` gem is not a dependency of this gem, so applications add it
170-
themselves. One background subscription per process fans out to every waiting
171-
role in memory, rather than one connection per thread, and `WakeUpAdapters.for`
172-
does not select it: Redis is infrastructure this gem otherwise does not require,
173-
so choosing it is explicit.
160+
`SolidObjects.wake_up.capability` reports what was installed, whether it crosses
161+
processes, its measured floor, and why. `bin/rails solid_objects:doctor` reports
162+
the same record.
174163

164+
On PostgreSQL, selection proves the path before it chooses it. It listens on a
165+
probe channel, sends one `NOTIFY` from a second connection, and waits for it to
166+
arrive, because `LISTEN` does not survive a transaction pooler such as
167+
PgBouncer. A probe that does not deliver falls back to polling and warns once.
175168
Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
176169
p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
177170
notification costs latency, never correctness, and signalling never raises into
178171
the caller that committed. `LISTEN` needs its own connection, so the adapter
179172
opens one outside the pool and releases it on `stop`.
180173

181-
Applications on SQLite or MySQL, or that do not configure the adapter, keep the
182-
existing polling behaviour.
174+
MySQL has no notification primitive, so MySQL applications either keep polling
175+
and tune `polling_interval`, or set `SOLID_OBJECTS_REDIS_URL`, which selects
176+
Redis on any database:
177+
178+
```bash
179+
SOLID_OBJECTS_REDIS_URL=redis://localhost:6379/0
180+
```
181+
182+
Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
183+
p50. The `redis` gem is not a dependency of this gem, so applications add it
184+
themselves, and selection polls and says so when the gem is missing. One
185+
background subscription per process fans out to every waiting role in memory,
186+
rather than one connection per thread. Name
187+
`SolidObjects::WakeUpAdapters::Redis.new(url:)` directly for a URL that does not
188+
come from the environment.
183189

184190
## Batched component refreshes
185191

‎docs/roadmap.md‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,21 +122,23 @@
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+
selection listens, sends one `NOTIFY` from a second connection, and waits for
135+
it to arrive. A probe that does not deliver falls back to polling and warns
136+
once, as does a requested adapter the environment cannot provide, so a
137+
downgrade is recorded rather than hidden.
138+
`SolidObjects.wake_up.capability` reports the adapter, whether it crosses
139+
processes, its floor, and why, and the doctor shows the same record.
140+
MySQL still polls. It has no notification channel, and no MySQL notifier has
141+
been measured against polling on the same hardware, so none is shipped.
140142
- Realtime: scalar and dependency-driven keyed ERB component replacement or
141143
morphing, personalized refresh authorization, revision fencing, coalescing,
142144
reconnect convergence, batched refreshes, and personalized state payloads are

‎lib/solid_objects.rb‎

Lines changed: 33 additions & 3 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"
@@ -78,6 +79,8 @@
7879
module SolidObjects
7980
extend Instrumentation
8081

82+
@wake_up_mutex = Thread::Mutex.new
83+
8184
class << self
8285
# @rbs () -> Configuration
8386
def configuration
@@ -180,11 +183,11 @@ def mutable_copy(value)
180183
# @rbs () -> void
181184
def reset!
182185
ProcessRegistry.reset_polling_warning! if defined?(ProcessRegistry)
186+
reset_wake_up!
183187
@configuration = Configuration.new
184188
@registry = ActorRegistry.new
185189
@client = nil
186190
@database_adapter = nil
187-
@wake_up = nil
188191
@caller_process = nil
189192
@effect_registry = EffectRegistry.new
190193
@commit_action_registry = CommitActionRegistry.new
@@ -198,9 +201,36 @@ def database_adapter
198201
@database_adapter ||= DatabaseAdapter.for(SolidObjects::Record.connection)
199202
end
200203

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

‎lib/solid_objects/configuration.rb‎

Lines changed: 22 additions & 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 }
@@ -237,6 +237,7 @@ def validate!
237237
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
238238
raise ArgumentError, "instance retention must be positive" unless retention.positive?
239239
end
240+
validate_wake_up_adapter!
240241
unless component_path_resolver.nil? || component_path_resolver.respond_to?(:call)
241242
raise ArgumentError, "component_path_resolver must respond to call"
242243
end
@@ -252,6 +253,26 @@ def validate!
252253

253254
private
254255

256+
# @rbs () -> void
257+
def validate_wake_up_adapter!
258+
return if wake_up_adapter.nil?
259+
return validate_wake_up_object! unless wake_up_adapter.is_a?(Symbol)
260+
return if WakeUpAdapters::NAMES.include?(wake_up_adapter)
261+
262+
raise ArgumentError,
263+
"unknown wake_up_adapter #{wake_up_adapter.inspect}, " \
264+
"expected one of #{WakeUpAdapters::NAMES.join(", ")} or an adapter"
265+
end
266+
267+
# @rbs () -> void
268+
def validate_wake_up_object!
269+
%i[signal wait].each do |method_name|
270+
next if wake_up_adapter.respond_to?(method_name)
271+
272+
raise ArgumentError, "wake_up_adapter must respond to #{method_name}"
273+
end
274+
end
275+
255276
# @rbs () -> Hash[Symbol, Numeric]
256277
def positive_values
257278
{

‎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

0 commit comments

Comments
 (0)