Skip to content

feat: select a wake-up adapter automatically - #77

Merged
cardmagic merged 9 commits into
mainfrom
feat/automatic-wake-up
Sep 22, 2026
Merged

cardmagic merged 9 commits into
mainfrom
feat/automatic-wake-up

Conversation

@cardmagic

@cardmagic cardmagic commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Closes #72.

Why

A commit in a web process did not wake a worker process unless somebody
configured an adapter, so delivery waited out the polling interval, up to the
one-second idle_polling_interval default. The adapters existed and were
measured. Nothing chose between them.

One setting, not two

My first draft added config.wake_up = :automatic beside the existing
config.wake_up_adapter. That was wrong. Rails uses one setting that takes
either a name or an object, never a parallel pair:
config.cache_store accepts
:redis_cache_store or a cache object, and config.active_job.queue_adapter
accepts :sidekiq or an adapter.

So config.wake_up_adapter takes both:

config.wake_up_adapter = :automatic     # default
config.wake_up_adapter = :in_process    # opt out
config.wake_up_adapter = :postgresql    # force one
config.wake_up_adapter = MyAdapter.new  # your own

An unknown name raises ArgumentError naming the accepted values, rather than
quietly polling. configure validates it, so a typo fails at boot rather than
at the first wake-up, which is after a commit. A typo that silently costs a
second of latency is the failure this change exists to remove.

A name that exists but that the environment cannot provide is different. It
polls, logs solid_objects.wake_up.unavailable once, and records the reason in
the capability, so the doctor warns. :postgresql on a database with no
notification channel used to build the adapter anyway and report
crosses_processes: true, so the doctor said PASS while every LISTEN failed.
:redis without SOLID_OBJECTS_REDIS_URL used to reach for the client default
rather than say it had no address.

Selection

  1. A configured adapter or name wins.
  2. SOLID_OBJECTS_REDIS_URL selects Redis, on any database.
  3. PostgreSQL selects LISTEN, unless a probe notification fails to arrive.
  4. Everything else polls.

The probe proves delivery

LISTEN does not survive a transaction pooler such as PgBouncer, so the path is
probed before it is chosen.

My first version set application_name and read it back on the next statement.
Review was right that this proves nothing. A pooler can hand the same backend to
two consecutive autocommit statements, so the read-back succeeds while LISTEN
still has no session affinity, and selection would memoise a notification path
that never fires.

Selection now proves the path end to end. It listens on a probe channel, sends
one NOTIFY from a second connection, and waits up to two seconds for it to
arrive.

def notifications_deliver?
  probe = Postgresql.new(channel: PROBE_CHANNEL)
  return false unless probe.listen
  return false unless notify_probe_channel

  probe.wait(timeout: PROBE_TIMEOUT_SECONDS)
rescue
  false
ensure
  probe&.stop
end

Three points about the shape:

  • Backend reuse is no longer evidence. A pooler that reuses one backend still
    drops the asynchronous notification, because the client connection is not
    bound to the backend that ran LISTEN.
  • The NOTIFY goes through a separate connection rather than the pooled one.
    NOTIFY delivers at commit, so a caller that selects inside an open
    transaction would otherwise never see its own probe.
  • A probe that cannot run is no longer treated as support. Failure falls back to
    polling, which costs latency and not correctness. The probe closes both
    connections, so selection leaves nothing open.
  • The probe has its own channel. On the production channel, every booting
    process would wake every waiting role in the deployment once.

Selection runs once per process

The probe found a second defect, in code this branch did not add.
SolidObjects.wake_up memoised with ||= and no lock. One selection was cheap,
so the race cost nothing visible. A probe that opens connections and waits for a
notification is not cheap. Eight threads that raced for the first use each ran a
full selection, took every free pool connection, and left four callers waiting
out the checkout timeout:

EnqueueTest#test_allocates_unique_sequences_under_concurrent_enqueue
ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the
pool within 5.000 seconds (waited 5.012 seconds); all pooled connections were
in use

Selection now runs under a mutex, so one thread probes and the rest read the
result. A new test holds it: eight threads race, and selection must run once.
Without the lock it failed with Expected: 1, Actual: 8.

Reporting the choice

SolidObjects.wake_up.capability
# => #<data SolidObjects::WakeUpCapability
#      adapter=:postgresql_notify, crosses_processes=true,
#      measured_floor_ms=2.9,
#      reason="a probe notification arrived, so PostgreSQL LISTEN carries the signal between processes">

bin/rails solid_objects:doctor reports the same record: PASS when it crosses
processes, WARN with the reason when it does not.

This also fixed a bug the change would otherwise have introduced. The
polling-only warning guarded on return if configuration.wake_up_adapter, which
is now always truthy, so the warning would have stopped firing everywhere. It
guards on capability.crosses_processes instead, which is what it meant to ask.
An adapter that reports its own capability keeps it. A configured
SolidObjects::WakeUp signals in one process only and says so, so the doctor
warns and the process registry keeps its polling warning, rather than have
selection relabel it as cross-process. An adapter that reports no capability is
somebody else's and is still assumed to cross processes, as before.

Behaviour change

PostgreSQL deployments that configure nothing now use notifications. They
gain cross-process wake-up, and with it a connection per waiting thread outside
the pool and one NOTIFY per enqueue after the commit. The NOTIFY lands after
COMMIT, so it does not widen the lock window, but it is one more round trip
per enqueue. config.wake_up_adapter = :in_process keeps the old behaviour.

Tests

test/integration/wake_up_selection_test.rb is new, 22 tests: an explicit
adapter wins, a configured adapter keeps the capability it reports about itself,
the doctor warns about a configured in-process adapter, an adapter that reports
none is recorded as configured, racing threads select once, a requested
:postgresql polls when the database has no channel, a requested :redis
polls when no URL is set, a listener wakes from a notification sent on another
connection, :in_process opts out, a name selects without
probing, an unknown name is refused when the configuration is validated and again at
selection, a Redis URL wins on any database, PostgreSQL
selects notifications when a probe notification arrives, PostgreSQL polls when
one does not, a pooled session falls back and warns once, a database without a
channel reports its floor, the capability names the adapter actually installed,
an unreachable database degrades to in-process rather than raising, an adapter
without watch is accepted, one that cannot signal is refused, and the doctor
reports it.

configure validates the setting, and the check follows the contract the roles
actually use. Four roles ask respond_to?(:watch) before they call it, and
ADR 0011 records that an adapter without it keeps the fast cadence, so the check
requires signal and wait only.

Each of these failed first. The two that hold the review findings failed for the
stated reason:

WakeUpSelectionTest#test_a_configured_adapter_keeps_the_capability_it_reports_about_itself
Expected: :in_process
  Actual: :configured

WakeUpSelectionTest#test_postgresql_polls_when_a_probe_notification_does_not_arrive
Expected: :polling
  Actual: :postgresql_notify

test/unit/wake_up_adapters_test.rb now stubs the probe in both directions, so
family routing is asserted without depending on which database the suite runs
against.

Two existing tests moved to :in_process rather than changing what they assert.
The polling warning test is about the warning, not about selection. The enqueue
statement count is about the enqueue transaction, not about the NOTIFY a
cross-process adapter adds after it. That count guard is what caught the extra
statement in the first place.

Validation

bundle exec rake passes. Steep reports no type error, Brakeman no warning.

Backend Result
SQLite 716 runs, 0 failures, 39 skips
PostgreSQL 18 716 runs, 0 failures, 29 skips
MySQL 8.4, mysql2 716 runs, 0 failures, 47 skips
MySQL 8.4, Trilogy 716 runs, 0 failures, 47 skips

The positive PostgreSQL case is measured rather than assumed. The selection file
finishes in 0.93 s, so the probe notification arrived rather than ran out its
two-second timeout.

What this does not do

The issue's third part asks for a measurement before choosing a MySQL notifier,
between a tighter poll floor, a signal table, and GET_LOCK. No MySQL
notifier is shipped here
, so no measurement gates this change: MySQL still
polls, and the capability now says so and reports its floor rather than leaving
an operator to infer it. Choosing one of those three still needs the benchmark
the issue describes, and belongs in its own change.

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.
@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the prior findings are resolved and the changes since the previous review introduce no actionable issue.

Summary

This PR automatically selects and reports a wake-up adapter:

  • Prefers configured Redis, then verified PostgreSQL notifications, then in-process polling.
  • Probes PostgreSQL notification delivery before selecting LISTEN.
  • Serializes adapter selection to prevent concurrent initialization.
  • Validates named and custom adapters during configuration.
  • Exposes capability information through the doctor and polling warnings.
  • Updates operational documentation and tests for selection, fallback, and capability reporting.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Resolve wake-up adapter] --> B{Explicit setting?}
  B -->|Custom adapter| C[Use configured adapter]
  B -->|Named adapter| D[Resolve requested adapter]
  B -->|Automatic| E{Redis URL configured?}
  E -->|Yes| F[Select Redis if available]
  E -->|No| G{PostgreSQL?}
  G -->|No| H[Use in-process polling]
  G -->|Yes| I[Probe LISTEN/NOTIFY delivery]
  I -->|Delivered| J[Select PostgreSQL notifications]
  I -->|Not delivered| H
  D --> K{Environment supports it?}
  K -->|Yes| L[Install requested adapter]
  K -->|No| H
Loading

Reviews (5) · Last reviewed commit: "docs: record the downgrade in the roadma..."

Comment thread lib/solid_objects/wake_up_adapters.rb
Comment thread lib/solid_objects/wake_up_adapters.rb Outdated
cardmagic and others added 2 commits September 22, 2026 08:50
Two claims in selection were not supported by what it measured.

`configured` overwrote the capability that an adapter reports about
itself. A configured `SolidObjects::WakeUp` signals in one process only
and says so, but selection relabelled it `crosses_processes: true`, so
the doctor reported PASS and the process registry dropped the warning
that this exact topology needs. Selection now keeps the capability that
an adapter provides and labels only an adapter that provides none.

The PostgreSQL probe set `application_name` and read it back on the next
statement. A transaction pooler can hand the same backend to two
consecutive autocommit statements, so the read-back succeeds while
`LISTEN` still has no session affinity, and selection memoised
notification support that never fires. The probe now listens, sends one
`NOTIFY` from a second connection, and waits up to two seconds for it to
arrive. Nothing but a delivered notification selects notifications, and
a probe that cannot run falls back to polling rather than assume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SolidObjects.wake_up` memoised with `||=` and no lock. One selection was
cheap, so the race cost nothing visible. A probe that opens connections
and waits for a notification is not cheap: eight threads that raced for
the first use each ran a full selection, and on PostgreSQL that took
every free pool connection and left four callers waiting out the
checkout timeout.

`EnqueueTest#test_allocates_unique_sequences_under_concurrent_enqueue`
showed it:

    ActiveRecord::ConnectionTimeoutError: could not obtain a connection
    from the pool within 5.000 seconds

Selection now runs under a mutex, so one thread probes and the rest read
the result. The probe also listens on its own channel rather than the
production one, so a booting process no longer wakes every waiting role
in the deployment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

Pushed 39af457 after the two review fixes.

Writing the probe exposed a second defect, in code this branch did not add. SolidObjects.wake_up memoised with ||= and no lock. One selection was cheap, so the race cost nothing visible. A probe that opens connections and waits for a notification is not cheap. Eight threads that raced for the first use each ran a full selection, took every free pool connection, and left four callers waiting out the checkout timeout:

EnqueueTest#test_allocates_unique_sequences_under_concurrent_enqueue
ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the pool
within 5.000 seconds (waited 5.012 seconds); all pooled connections were in use

I bisected it to the probe rather than assume: dead=4 pool connections after the concurrent round, against dead=1 on the branch point and dead=1 once selection is serialised. Selection now runs under a mutex, and a new test holds it. Without the lock it failed with Expected: 1, Actual: 8.

The probe also listens on its own channel now, so a booting process no longer wakes every waiting role in the deployment.

All four supported clients, 710 runs and 0 failures each: SQLite 38 skips, PostgreSQL 18 28 skips, mysql2 8.4 46 skips, Trilogy 8.4 46 skips. bundle exec rake is clean, including Standard, RuboCop, RBS, Steep, and Brakeman.

`reset!` cleared `@wake_up` directly, which now writes the memo without
the lock that `wake_up` reads it under, and left the once-only pooled
session warning armed. It calls `reset_wake_up!` instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

cardmagic and others added 2 commits September 22, 2026 10:46
`wake_up_adapter = :postgresql` on a database with no notification
channel built the adapter anyway and reported `crosses_processes: true`,
so the doctor said PASS while every `LISTEN` failed. `:redis` without
`SOLID_OBJECTS_REDIS_URL` reached for the client default rather than say
it had no address.

Each case now polls, logs `solid_objects.wake_up.unavailable` once, and
records the reason in the capability, so the doctor warns. That is what
the capability record exists for. Only a name that does not exist is
still refused, because a typo cannot be honoured at all.

`configure` validates the name now. An unknown one raised at the first
wake-up, which is after a commit, rather than at boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TypeScript suite covers this and the Ruby suite did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread lib/solid_objects/configuration.rb Outdated
The new configuration check required `watch`, but four runtime roles ask
`respond_to?(:watch)` before they use it, and ADR 0011 records that an
older adapter without it keeps the fast polling cadence. A supported
adapter would have failed at boot. The check requires `signal` and
`wait`, which every role calls without asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

cardmagic and others added 2 commits September 22, 2026 11:21
Three documents still described the old opt-in behaviour. The realtime
guide told readers to assign `WakeUpAdapters.for` and said Redis is never
selected, the operations guide said the warning fires when no adapter is
configured, and ADR 0011 recorded no decision about choosing one.

Each now describes the setting, automatic selection, the probe, the
downgrade that says why, and where to read the installed capability.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit 588281f into main Sep 22, 2026
41 checks passed
@cardmagic
cardmagic deleted the feat/automatic-wake-up branch September 22, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automatic cross-process wake-up, including MySQL

1 participant