Skip to content

feat: add WakeSocketServer/WakeSocketClient — cross-process wake (same machine)#21

Merged
rodrigobnogueira merged 1 commit into
mainfrom
feat/wake-socket
Jul 19, 2026
Merged

feat: add WakeSocketServer/WakeSocketClient — cross-process wake (same machine)#21
rodrigobnogueira merged 1 commit into
mainfrom
feat/wake-socket

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Contributor

What

Completes the wake story from #20 for multiple processes on the same machine — the classic deployment where the HTTP app and the outbox worker (start:worker) are separate processes sharing one database. OutboxWaker.notify() is in-memory and can't cross that boundary; this PR bridges it over a unix domain socket (Windows: a \\.\pipe\… name), built on node:net alone — zero new dependencies, dialect-agnostic.

// worker process
const waker = new OutboxWaker();
await new WakeSocketServer({ path, waker }).listen();
runWorkerLoop(claimer, { pollIntervalMs: 2_000, waker, signal });

// app process — after the enqueueing transaction commits
new WakeSocketClient({ path }).notify(); // fire-and-forget, never throws

Design

  • The connection IS the signal — no payload protocol to version.
  • Best-effort by contract: notify() never throws into the request path; a failed wake (worker down, path missing) goes to onError and only costs one poll interval — polling stays the delivery backstop, correctness never depends on the wake.
  • Crash recovery: listen() distinguishes a stale socket path left by a crashed predecessor (bind refused + probe connect refused → unlink + rebind) from a live conflict (probe connects → throws).
  • WakeSignal interface ({ notify(): void }): producers depend on the shape, so switching topology — single process (OutboxWaker) ↔ app + worker (WakeSocketClient) — never touches domain code.
  • SQLite completeness: processes sharing a SQLite file are on one machine by definition, so this covers every supported deployment of the SQLite store.
  • Cross-machine wake (Postgres LISTEN/NOTIFY) remains the planned follow-up.

Tests / gates

  • 9 new specs over real sockets: round-trip wake, the latched no-lost-wakeup path (notify lands before the worker parks), stale-path recovery, live-conflict refusal, non-EADDRINUSE rethrow, idempotent/robust close(), accepted-socket error guard, client error paths (with and without onError).
  • Coverage: 100% on wake-socket.ts and outbox-waker.ts (114 hermetic tests pass); complexity ≤15; core typecheck clean.
  • Gated I/O ran (Docker up): infra:up && test:full → 114/114 hermetic and real Postgres + MySQL specs 3/3, 0 skipped.

Note: the Release and security checks job is expected red on security:audit:docs — pre-existing docusaurus website advisories (websocket-driver/uuid, advisory-DB drift since 2026-07-13), unrelated to the package; the published-tarball audit is green. Per the guidelines, website advisories don't block.

No version bump; lands on main alongside #20 for the next release (0.4.0).

…e machine)

The OutboxWaker's notify() is in-memory, so it can't reach a worker running as a
separate process — the classic app + start:worker split sharing one database.
This bridges it over a unix domain socket (Windows: \\.\pipe\… name) using
node:net alone: the worker listens and feeds incoming connections into its
OutboxWaker; producers hold a WakeSocketClient on the same path and notify()
after the enqueueing transaction commits.

The connection itself is the signal — no payload protocol to version. notify()
is fire-and-forget and never throws into the request path; polling remains the
backstop, so a failed or missed wake only costs one poll interval. The server
recovers a stale socket path left by a crashed predecessor (bind refused + probe
refused → unlink + rebind) and refuses a path a LIVE server owns. The shared
WakeSignal interface lets producers swap OutboxWaker ↔ WakeSocketClient without
touching domain code.

Dialect-agnostic and zero new dependencies. For the SQLite store this covers
every supported deployment — processes sharing a SQLite file are on one machine
by definition. A cross-MACHINE wake (Postgres LISTEN/NOTIFY) stays the planned
follow-up.

- wake-socket.ts: server (listen/stale-recovery/conflict/close) + client, 100% covered
- outbox-waker.ts: WakeSignal interface; OutboxWaker implements it
- 9 new specs incl. the latched no-lost-wakeup path over a real socket
- README: two-tier wake story + env-shared-path recipe; CHANGELOG entry
@github-actions

Copy link
Copy Markdown

📊 Coverage Coverage Report

Metric PR Base Diff
Statements ████████████████████ 1841/1841 (100%) 1677/1677 (100%) ⚪ 0%
Branches ████████████████████ 236/236 (100%) 205/205 (100%) ⚪ 0%
Functions ████████████████████ 78/78 (100%) 67/67 (100%) ⚪ 0%
Lines ████████████████████ 1841/1841 (100%) 1677/1677 (100%) ⚪ 0%
🧾 Changed files
File Statements Branches Diff
messaging/outbox-waker.ts ████████████████████ 84/84 (100%) 13/13 (100%) ⚪ 0%
messaging/wake-socket.ts ████████████████████ 154/154 (100%) 31/31 (100%) 🆕 new

Updated for 7e631ac | Compared against base branch

@github-actions

Copy link
Copy Markdown

⏱️ Performance Report

✅ Tests 114 passed, 0 failed, 0 skipped
🧪 Suites 29
⏱️ Total step time 236.00s 🔴 +59.00s
⚙️ Test execution 228.48s 🔴 +57.76s
🐢 Slowest test suites
# Suite Tests Duration vs Base
1 runWorkerLoop 13 24.95s 🔴 +5.65s
2 WakeSocketServer + WakeSocketClient 9 17.16s -
3 OutboxClaimer (publish outcomes) 9 16.99s 🔴 +3.16s
4 SqliteOutboxStore 8 14.40s 🔴 +2.41s
5 KafkaInboxConsumer 8 14.24s 🔴 +2.94s
6 OutboxWaker 7 12.91s 🔴 +2.37s
7 MysqlOutboxStore 7 12.88s 🔴 +3.04s
8 InProcessOutboxTransport 5 9.42s 🔴 +1.98s
9 SqliteInboxStore 5 9.40s 🔴 +1.94s
10 PostgresOutboxStore 4 7.53s 🔴 +1.58s
🐌 Slowest individual tests
# Suite Test Duration vs Base
1 OutboxProducer (atomic enqueue) OutboxProducer (atomic enqueue) enqueue commits the outbox row with the business write 2.74s 🔴 +545ms
2 runWorkerLoop runWorkerLoop an abort during the idle sleep wakes it and removes the listener 2.09s 🔴 +466ms
3 MessagingModule.forRootAsync / no inbox MessagingModule.forRootAsync / no inbox forRoot applies defaults when isGlobal/imports are omitted 2.08s 🔴 +684ms
4 PostgresInboxStore PostgresInboxStore runOnce processes a fresh key (async side effect) 2.07s 🔴 +480ms
5 runWorkerLoop runWorkerLoop a throwing tick without onError still continues (onError is optional) 2.07s 🔴 +569ms
6 WakeSocketServer + WakeSocketClient WakeSocketServer + WakeSocketClient client notify() against a missing path reports to onError and never throws 2.05s -
7 runWorkerLoop runWorkerLoop a waker-driven loop still stops promptly on abort (waker path honours the signal) 2.03s 🔴 +521ms
8 WakeSocketServer + WakeSocketClient WakeSocketServer + WakeSocketClient client notify() without onError still swallows the failure 2.02s -
9 OutboxClaimer (publish outcomes) OutboxClaimer (publish outcomes) tick publishes a pending row and marks it completed 2.01s 🔴 +295ms
10 postgres schema postgres schema outbox_events: columns + indexes 2.00s 🔴 +344ms
11 OutboxClaimer (publish outcomes) OutboxClaimer (publish outcomes) a generic error retries while attempts remain 1.99s 🔴 +486ms
12 runWorkerLoop runWorkerLoop a claiming tick without onTick reports no error (onTick is optional) 1.98s 🔴 +605ms
13 InProcessOutboxTransport InProcessOutboxTransport a handler throw propagates untouched (claimer generic retry/backoff) 1.98s 🔴 +452ms
14 wire contract wire contract deriveDedupKey follows event-id → idempotency-key → message-key order 1.98s 🔴 +671ms
15 OutboxProducer (atomic enqueue) OutboxProducer (atomic enqueue) a throw rolls back BOTH the outbox row and the business write 1.98s 🔴 +314ms

Updated for 7e631ac | Compared against base branch

@github-actions

Copy link
Copy Markdown

🧠 Cognitive Complexity Report

Metric PR Base Diff
Total complexity 66 59 🔴 +7
Max function complexity 12 12 ⚪ 0
Functions measured 30 27 ⚪ +3
🧩 Most complex functions
Function Location Complexity Status
runWorkerLoop packages/messaging/outbox-worker.ts:34 12 🟢 OK
KafkaInboxConsumer.consume packages/messaging/adapters/kafka/kafka-inbox-consumer.ts:67 5 🟢 OK
WakeSocketServer.listen packages/messaging/wake-socket.ts:52 5 🟢 OK
OutboxClaimer.onPublishError packages/messaging/outbox-claimer.service.ts:84 4 🟢 OK
deriveDedupKey packages/messaging/wire-contract.ts:55 4 🟢 OK
MysqlInboxStore.runOnce packages/messaging/dialects/mysql/inbox-store.ts:43 3 🟢 OK
PostgresInboxStore.runOnce packages/messaging/dialects/postgres/inbox-store.ts:37 3 🟢 OK
SqliteInboxStore.runOnce packages/messaging/dialects/sqlite/inbox-store.ts:39 3 🟢 OK
KafkaInboxConsumer.readKey packages/messaging/adapters/kafka/kafka-inbox-consumer.ts:90 2 🟢 OK
InProcessOutboxTransport.publish packages/messaging/in-process/in-process-outbox-transport.ts:38 2 🟢 OK
🧾 Changed files
File Total Max Functions Total diff
messaging/outbox-waker.ts 3 2 2 ⚪ 0
messaging/wake-socket.ts 7 5 3 🆕 new

🧭 Cognitive complexity is reported as a review signal, not a merge gate. Prefer small, intention-revealing refactors when complexity rises.


Updated for 7e631ac | Compared against base branch

@rodrigobnogueira
rodrigobnogueira merged commit 2344fe6 into main Jul 19, 2026
6 of 7 checks passed
@rodrigobnogueira
rodrigobnogueira deleted the feat/wake-socket branch July 19, 2026 05:08
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