feat(events): cross-machine event bus via Postgres NOTIFY/LISTEN#117
Merged
Conversation
Replace the in-process EventBus with a Postgres NOTIFY/LISTEN-backed bus. Every emit fires NOTIFY on a single `provider_events` channel, every machine runs a dedicated `LISTEN provider_events` task that republishes incoming notifications to the local in-process bus. The WS handler subscribes via the existing `subscribe(listener)` path. The public bus surface (`emit(event)` + `subscribe(listener) → unsubscribe`) is preserved byte-for-byte so call sites do not change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Provider-platform runs multi-machine on Fly. The previous in-process
EventBusonly delivered events to subscribers on the same machine as the emitter. Fly proxy load-balances HTTP requests across machines, so a bundle POST lands on machine A while the WS subscriber for that PP is held on machine B — A's local emit goes to A's bus only, B's WS subscriber never sees it.Diagnosed in the
add-events-capture-framework-1thread (2026-06-09) with verbatim Fly logs from PRs #115 + #116:Workaround currently in place: testnet's second machine has autostart disabled (
flyctl machine update --autostart=false), forcing single-machine. Mainnet is also single-machine today. This PR makes that workaround unnecessary so capacity scaling can land later.Design
Replace the in-process bus with a Postgres NOTIFY/LISTEN-backed implementation:
provider_eventsfor the entireProviderEventtaxonomy. Receivers deserialize and dispatch bykind. No per-event-type channels, no event persistence table.emit()ONLY firesNOTIFY provider_events <payload>via the existing drizzle Postgres pool. Even local emitters round-trip through Postgres — adds ~5–20 ms latency but means there is one delivery path to debug. No dedup needed, no "did this fire twice" question.startPgListener(deps)boots a separatepostgres()client (NOT the drizzle pool —LISTENreserves its connection for the subscription's lifetime), callssql.listen('provider_events', onNotify, onListen), and on every notification parses the JSON and calls into the bus's internalpublishLocal()— driving local subscribers without re-triggering NOTIFY.payload.length < 7900bytes check at emit time (Postgres NOTIFY cap is 8000). Oversize events are logged + dropped rather than thrown back into the caller's already-committed transaction. Worst-case audited payload (verifier.bundle_completed with 100 sha256-hex bundle IDs) is ~7,060 bytes — under the guard with ~12% headroom. Defensive guard captures whatever future bundle-ID format pay-platform may send.postgres@^3.4.7'ssql.listenships its own reserved-connection reconnect logic. Theonlistencallback fires on every (re-)subscribe so we log each recovery. Start-from-now semantics during disconnect windows — subscribers backfill themselves if needed (out of scope for the transport).pgListenersubscribes first,setNotifier()wires the NOTIFY transport on the bus second, then mempool / event-watcher / HTTP server start. UntilsetNotifier()is called, the bus falls back to in-process loopback — used in tests and during boot before transport is up.emit+subscribesurface is preserved byte-for-byte.emit-helpers.ts,ws-handler.ts, and the integration test only update their import paths.Files
Added:
src/core/service/events/pg-notify-event-bus.ts— new bus impl withemit+subscribe+ internalpublishLocal+setNotifier.src/core/service/events/pg-listener.ts— long-lived LISTEN task with shutdown hook.src/core/service/events/pg-notify-event-bus.test.ts— 9 unit tests covering subscribe/publishLocal, loopback fallback, single-path-delivery invariant, oversize-drop, NOTIFY-rejection swallow, and the simulated cross-machine round-trip.tests/integration/events/cross-machine-events.test.ts— 3 integration tests using PGlite's native LISTEN/NOTIFY API to exercise the same wire path (serialize → NOTIFY → LISTEN → parse → publishLocal → subscriber) across separate connections to a shared backend.Removed:
src/core/service/events/event-bus.ts— direct replacement, no feature flag, no parallel old + new.Modified:
src/core/service/events/index.ts— re-exportPgNotifyEventBus,startPgListener,PROVIDER_EVENTS_CHANNEL.src/core/service/events/emit-helpers.ts,src/http/v1/events/ws-handler.ts,tests/integration/http/events-ws.test.ts— import path only.src/main.ts— boot order:startPgListener→setNotifier(pgClient.notify)→ mempool/event-watcher/HTTP. Shutdown handler awaits listener teardown.src/persistence/drizzle/config.ts— export the rawpostgres-jsclient so the bus's NOTIFY uses the existing pool.Local multi-machine smoke
Ran a narrowed smoke against an isolated Postgres (port 5443, not touching any other local stack) with two
provider-platformprocesses from this branch HEAD (ports 3110 + 3111, sameDATABASE_URL).Boot order on each instance:
Cross-machine delivery via psql
pg_notify:SELECT pg_notify('provider_events', '<mempool.bundle_added JSON>');13:14:59.600 DBG kind: mempool.bundle_added (main.PgNotifyEventBus)13:14:59.600 DBG kind: mempool.bundle_added (main.PgNotifyEventBus)Production transport via
postgres@3.4.7 sql.notify:await sql.notify('provider_events', JSON.stringify(event))against the same DB.13:16:08.085 DBG kind: executor.transaction_submitted13:16:08.084 DBG kind: executor.transaction_submittedClean shutdown via SIGTERM:
Two-machine
local-dev/testnet/run-local.sh allwas NOT run against the full local-dev stack — PM-acked, deferred to post-merge verification.Out of scope
bus.tsdocstring explicitly calls itself "single-instance per env by design". Separate future thread.Test plan
deno fmt --checkdeno lintdeno check src/main.tsdeno task test:unit— 74 passed (9 new on PgNotifyEventBus)deno task test:integration— 99 passed (3 new on cross-machine delivery)deno task test(full) — 116 passedsql.notifyboth deliver to both instances within 1 ms; SIGTERM shuts pgListener cleanly.local-dev/testnet/run-local.sh allagainst the full local-dev stack — PM-deferred to post-merge.