Skip to content

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

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

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

Conversation

@cardmagic

@cardmagic cardmagic commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Ports cardmagic/solid-objects-ruby#77 to this runtime.

Why

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

One setting, not two

wakeUp takes a name or an adapter, as config.cache_store accepts
:redis_cache_store or a cache object in Rails:

configure({ database, wakeUp: "automatic" })   // default
configure({ database, wakeUp: "in_process" })  // opt out
configure({ database, wakeUp: "postgresql" })  // force one
configure({ database, wakeUp: myAdapter })     // your own

An unknown name throws a TypeError naming the accepted values, rather than
polling quietly. configure() throws it, so a typo fails at boot. 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 rather than claim a cross-process wake-up
that cannot happen. That covers "postgresql" on a database with no
notification channel, "redis" without SOLID_OBJECTS_REDIS_URL, and a Redis
URL without the redis package.

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. A setting that survives one round trip proves
nothing: a pooler can hand the same backend to two consecutive statements, so
the read-back succeeds while LISTEN still has no session affinity.

async function notificationsDeliver(options: WakeUpSelectionOptions): Promise<boolean> {
  const probe = notificationAdapter(options.database, { channelPrefix: PROBE_CHANNEL_PREFIX })
  try {
    const watch = await probe.watch(PROBE_ROLE)
    await options.database.connection((connection) =>
      connection.run("SELECT pg_notify(?, ?)", [probe.channelFor(PROBE_ROLE), PROBE_ROLE]),
    )
    return (await watch.wait({ timeoutMilliseconds })) === true
  } catch {
    return false
  } finally {
    await closeQuietly(probe)
  }
}

Three points about the shape:

  • The NOTIFY comes from the database pool, not from the listening client.
    Notifying through the listener would pass whenever a pooler reused one
    backend, which is the false positive the probe exists to catch.
  • The probe has its own channel. On a role channel, every booting process would
    wake every waiting role in the deployment once.
  • A probe that cannot run is not treated as support. Failure falls back to
    polling, which costs latency and not correctness. The probe closes its
    connection, so selection leaves nothing open.

Reporting the choice

await runtime.wakeUpCapability()
// {
//   adapter: "postgresql_notify",
//   crossesProcesses: true,
//   measuredFloorMilliseconds: 2.9,
//   reason: "a probe notification arrived, so PostgreSQL LISTEN carries the signal between processes",
// }

The doctor reports the same record as a wakeUp check: pass when it crosses
processes, warn with the reason when it does not.

An adapter that declares its own capability keeps it. A configured
InProcessWakeUpAdapter reports in_process and does not cross processes, so
the doctor warns and the polling-only warning still fires. That warning now
guards on capability.crossesProcesses rather than on whether wakeUp was set,
which is what it meant to ask. An adapter that declares no capability is
somebody else's and is still assumed to cross processes, as before.

Selection runs once

runtime.wakeUpAdapter() memoises the selection promise, assigned before the
first await, so callers that race for the first use share one probe. The Ruby
port needed a mutex for the same reason; here the promise is the lock. Without
it, eight racing callers ran eight probes and opened sixteen connections.

Behaviour change

PostgreSQL applications that configure nothing now use notifications. They
gain cross-process wake-up, one dedicated listening client outside the pool, and
one NOTIFY per commit. wakeUp: "in_process" keeps the old behaviour.

Repository and EffectRecoveryCoordinator now take a wakeUpAdapter
resolver, because the adapter is no longer known when they are constructed.

The selection survives runtime.close(). Clearing it would let a late notify
select again and open a PostgreSQL client that nothing would ever close. Every
adapter ignores a notify after its own close, so a late caller is a no-op.
resetForTesting() still discards it, because a reset must select again.

Tests

test/wake-up-selection.test.ts is new, 24 tests: a configured adapter wins and
keeps its own capability, an adapter that reports none is recorded as
configured, an unknown name throws, a database without a channel reports its
floor, PostgreSQL selects notifications when a probe arrives and polls when it
does not, the probe notifies from the database rather than the listener and
closes itself, the pooled fallback warns once, racing callers select once, a closed
runtime does not select again, selection works where no process global exists,
and the doctor reports the installed adapter.

Each one failed first. Reverting each fix gave the stated failure:

× keeps the capability a configured adapter reports about itself
  expected 'configured' to be 'in_process'
× selects once when callers race for the adapter
  expected [ ProbeWakeUpAdapter{...}, ...(15) ] to have a length of 2 but got 16
× warns in the doctor about a configured in-process adapter
  expected 'pass' to be 'warn'
× polls when a probe notification does not arrive
  expected 'postgresql_notify' to be 'polling'

test/polling-loop.test.ts gains the other half of that: a configured
InProcessWakeUpAdapter must still warn. Under the old wakeUpConfigured
guard it failed, because any configured adapter suppressed the warning.

test/postgresql.test.ts gains two tests against a real server: automatic
selection reports postgresql_notify with the probe reason, and a listener
wakes from a NOTIFY sent on a second connection, which a pooled session could
not do.

Validation

Gate Result
vitest run 450 passed, 32 skipped, 57 files
PostgreSQL 18 52 passed
MySQL 8.4 39 passed, 7 skipped
Cloudflare Workers 51 passed
Browser, Playwright Chromium 9 passed
prettier --check clean
check:parameters, check:documentation, check:browser-imports clean
tsc for tsconfig.json, .examples, .cloudflare, .build no errors

The browser has no process

The first version read process.env directly for SOLID_OBJECTS_REDIS_URL.
Node defines that global and a browser does not, so the browser runtime threw
before it could choose polling, and every wake-up stopped. Two browser tests
caught it: the outbox drained nothing and live signals never updated.

The read goes through globalThis.process?.env now, which is undefined in a
browser rather than a throw. A test stubs the global away and holds that.
Without the guard it fails with Cannot read properties of undefined (reading 'env'), and the browser suite fails with Received: 0.

This is the one place where the Ruby port gave no warning. ENV is always there
in Ruby, so the Ruby code reads it plainly.

Parity with the Ruby pull request

Same scope: one setting taking a name or an adapter, the same four names, the
same selection order, the same delivery probe on its own channel, the same
capability record, the same doctor check, the same warning guard, and one
selection per process.

The two suites now cover the same cases. Three details differ, and
docs/parity.md records each. The pooled-session warning is emitted once per
runtime here and once per process in Ruby, because this runtime supports several
runtimes in one process. A configured adapter must implement watch, notify,
and close here, while Ruby requires signal and wait and treats watch and
stop as optional, which is each runtime's own adapter contract. A selection
that a later edit of the settings makes impossible is reported once here and
raised in Ruby; both refuse an unknown name when the configuration is built, so
this only reaches code that changes the setting afterwards.

Ruby needs a mutex to select once; here the memoised promise is the lock.

What this does not do

No MySQL notifier is shipped. MySQL has no LISTEN/NOTIFY equivalent in any
release, 8.4 LTS and 9.x included. GET_LOCK hands the lock to one waiter
rather than broadcasting, and binlog tailing is separate infrastructure rather
than a session primitive. MySQL still polls, and the capability now says so and
reports its floor rather than leaving an operator to infer it.

🤖 Generated with Claude Code

cardmagic and others added 3 commits September 22, 2026 09:59
A commit in one process did not wake a worker process unless somebody
configured an adapter, so delivery waited out the polling interval. The
adapters existed and were measured. Nothing chose between them.

`wakeUp` now takes a name or an adapter, as `config.cache_store` does in
Rails, and defaults to `"automatic"`. Selection prefers a configured
Redis URL, then PostgreSQL notifications, then polling. An unknown name
throws rather than polls quietly.

Selection proves the PostgreSQL path before it chooses it. It listens on
a probe channel, sends one `NOTIFY` from a second connection, and waits
for it to arrive. A setting that survives one round trip proves nothing,
because a transaction pooler can hand the same backend to two
consecutive statements, so only a delivered notification counts.

`runtime.wakeUpCapability()` reports what was installed, whether it
crosses processes, its measured floor, and why. The doctor reports the
same record, and the polling-only warning now fires on what was
installed rather than on whether a setting was set. An adapter that
declares its own capability keeps it, so a configured
`InProcessWakeUpAdapter` still warns.

Selection runs once per runtime. The probe opens connections and waits,
so callers that race for the first use share one promise rather than run
one probe each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`close()` cleared the memoised selection, so a late notify would select
again and open a PostgreSQL client that nothing would ever close. The
selection now survives `close()`. Every adapter ignores a notify after
its own close, so a late caller is a no-op rather than a new connection.
`resetForTesting()` still discards it, because a reset must select
again.

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

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable new issues remain, and all previous findings are resolved.

Summary

The PR adds automatic wake-up adapter selection and reports the resulting capability throughout the runtime and operational tooling.

  • Selects Redis, PostgreSQL notifications, or polling according to configuration and runtime capabilities.
  • Probes PostgreSQL notification delivery before relying on it across processes.
  • Memoizes adapter selection, preserves cleanup behavior, and exposes the result through the doctor.
  • Adds coverage for configured adapters, unavailable services, concurrent selection, browser environments, and PostgreSQL delivery.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Resolve wake-up adapter] --> B{Configured adapter?}
    B -->|Yes| C[Use configured adapter and capability]
    B -->|No| D{Named setting?}
    D -->|in_process| E[Use in-process adapter]
    D -->|redis| F{Redis URL and package available?}
    D -->|postgresql| G{Notification channel available?}
    D -->|automatic| H{Redis URL configured?}
    H -->|Yes| F
    H -->|No| I{PostgreSQL notification channel?}
    I -->|No| J[Use polling fallback]
    I -->|Yes| K[Probe LISTEN/NOTIFY delivery]
    K -->|Delivered| L[Use PostgreSQL notifications]
    K -->|Not delivered| J
    F -->|Yes| M[Use Redis]
    F -->|No| J
    G -->|Yes| L
    G -->|No| J
Loading

Reviews (5) · Last reviewed commit: "docs: record the three wake-up parity di..."

Comment thread src/configuration.ts
Comment thread test/wake-up-selection.test.ts Outdated
cardmagic and others added 2 commits September 22, 2026 10:09
Selection reached for `process.env` directly. Node defines that global
and a browser does not, so the browser runtime threw before it could
choose polling, and every wake-up stopped. Two browser tests caught it:
the outbox drained nothing and live signals never updated.

The read now goes through `globalThis.process?.env`, which is undefined
in a browser rather than a throw. A test stubs the global away and holds
that: without the guard it fails with "Cannot read properties of
undefined (reading 'env')".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`validateWakeUp` nested the adapter check inside its non-string branch.
The adapter check moves to its own function, so each branch is a guard
clause.

The selection test helper typed its setting as `unknown` and cast it
away with `as never`, which removed the type from every caller. It takes
a `WakeUpSetting` now, and the one test that supplies an invalid name
casts at that call alone.

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

Copy link
Copy Markdown
Owner Author

@greptileai review

`capability` meant two things: what an adapter reports about itself and
what selection installed. Polling shows the difference, because the
adapter is an `InProcessWakeUpAdapter` while the installed capability is
`polling` with a floor. An adapter declares `defaultCapability` now, and
`runtime.wakeUpCapability()` stays the installed record. Ruby uses the
same two names.

`polling` also takes the interval it needs rather than the whole
selection options.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/runtime.ts Outdated
A selection that cannot run is memoised, so the failure repeated on
every commit. `wakeUp: "redis"` without a URL, or `"postgresql"` on a
database that offers no notification channel, wrote one error per
message. It writes one error per runtime now, under its own event name,
because a failure to select is not the same as a failure to notify.

The notify path is a try/catch rather than a promise chain, so the
caught error carries its own type and no annotation widens it.

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

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread test/wake-up-selection.test.ts Outdated
cardmagic and others added 3 commits September 22, 2026 10:36
A requested adapter that the environment cannot provide claimed to cross
processes. `wakeUp: "postgresql"` on a database with no notification
channel threw, and a Redis URL without the `redis` package threw, so the
failure surfaced as a repeated error after each commit rather than as a
choice the operator could read.

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.

Test types no longer annotate values as `unknown`. The intercepting
connection takes its parameter types from `DatabaseConnection`, and the
warning collectors name the shape they read.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Ruby suite covers in_process opting out, a Redis URL winning on any
database, a database that cannot answer, and a name refused when the
configuration is built. The TypeScript suite did not.

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:02
The Ruby suite covers the matching case and the TypeScript suite did
not.

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 5a2df55 into main Sep 22, 2026
27 of 28 checks passed
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.

1 participant