Skip to content

feat: add OutboxWaker — in-process wake to cut worker idle latency#20

Merged
rodrigobnogueira merged 1 commit into
mainfrom
feat/outbox-waker
Jul 19, 2026
Merged

feat: add OutboxWaker — in-process wake to cut worker idle latency#20
rodrigobnogueira merged 1 commit into
mainfrom
feat/outbox-waker

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Contributor

What

Adds OutboxWaker, an opt-in in-process wake for runWorkerLoop. It lets a producer cut the worker's idle poll wait short so a freshly-committed event is relayed immediately instead of on the next poll — directly answering a reader question on the outbox article: "that 2s poll interval is a quiet latency knob… did you land on 2s for a reason, or is it a sane default?"

Why

runWorkerLoop is self-clocking: after a full-batch tick it loops again immediately to drain the backlog, and it only waits pollIntervalMs (default 2s) when a tick claims nothing. So the interval is the worst-case latency for a lone event landing in an idle outbox — not a per-event tax, and under load throughput is never gated by it.

When even that idle latency matters (a user-facing "we've got it" behind an outbox event), turning pollIntervalMs down works but has a floor and a DB-poll cost. OutboxWaker is the better lever.

How it works

import { OutboxWaker, runWorkerLoop } from '@nest-native/messaging';

const waker = new OutboxWaker();
runWorkerLoop(claimer, { pollIntervalMs: 2_000, waker, signal }); // worker

// request path — notify AFTER the tx commits (pre-commit the row isn't visible
// to the claimer's own transaction yet)
await this.txHost.withTransaction(async () => { await this.outbox.enqueue(...); });
waker.notify();
  • Polling stays the backstop. A missed or absent notify() never stalls delivery — it only widens latency back to one interval, so correctness never depends on the wake.
  • Wakes are latched. A notify() that arrives in the sliver between an idle tick and its sleep is remembered, so the next wait returns at once — no lost-wakeup race.
  • Same process only. notify() can't cross process boundaries; a worker in a separate process still polls. A cross-process wake (Postgres LISTEN/NOTIFY) is a planned follow-up, and this primitive is the dialect-agnostic half that bridge will drive.
  • Backward compatible. Omit the waker and runWorkerLoop is an unchanged pure poller.

Tests / gates

  • New test/waker.spec.ts (backstop timer, notify-wakes-parked-wait, latched-and-consumed-once, already-aborted, abort-mid-wait + listener cleanup, signal-less) + worker-loop waker-path specs.
  • Coverage: 100% on outbox-waker.ts and outbox-worker.ts (105 hermetic tests pass); complexity ≤15; core typecheck clean.
  • Gated I/O ran (Docker up): infra:up && test:full → hermetic 105/105 and the real Postgres + MySQL integration specs 3/3, 0 skipped. Not store-adjacent, but verified nothing regressed.
  • Mutation: hand-verified the lost-wakeup latch — removing this.#pending = true fails the "latched wake" spec (waits the full 8s), so the mutant is killed.

No version bump here; this is the feature landing on main. Cutting a release is a follow-up.

`runWorkerLoop` only waits `pollIntervalMs` when a tick claims nothing, so that
interval is the worst-case latency for a lone event landing in an idle outbox
(under load the loop drains immediately and is never gated by it). When that idle
latency matters — a user-facing signal behind an outbox event — turning the poll
down has a floor and a DB-load cost.

`OutboxWaker` is the better lever: pass it to `runWorkerLoop({ waker })` and call
`waker.notify()` after the enqueueing transaction commits, so the worker relays
now instead of on the next poll. Polling stays the backstop — a missed or absent
notify never stalls delivery, only widens latency back to one interval — and wakes
are latched, so an event committed in the sliver between a tick and its sleep is
never lost.

Dialect-agnostic and same-process only; a cross-process wake (Postgres
LISTEN/NOTIFY) is a planned follow-up. Fully backward compatible: omit the waker
and the loop is an unchanged pure poller.

- outbox-waker.ts: the primitive (notify + internal latched wait), 100% covered
- outbox-worker.ts: optional `waker` option; idle/error wait uses it when present
- tests: waker unit specs + worker-loop waker path; latch mutant hand-verified
- README + CHANGELOG: the low-latency-wake recipe and the same-process caveat
@github-actions

Copy link
Copy Markdown

📊 Coverage Coverage Report

Metric PR Base Diff
Statements ████████████████████ 1677/1677 (100%) 1588/1588 (100%) ⚪ 0%
Branches ████████████████████ 205/205 (100%) 188/188 (100%) ⚪ 0%
Functions ████████████████████ 67/67 (100%) 62/62 (100%) ⚪ 0%
Lines ████████████████████ 1677/1677 (100%) 1588/1588 (100%) ⚪ 0%
🧾 Changed files
File Statements Branches Diff
messaging/outbox-waker.ts ████████████████████ 74/74 (100%) 13/13 (100%) 🆕 new
messaging/outbox-worker.ts ████████████████████ 74/74 (100%) 20/20 (100%) ⚪ 0%

Updated for b6ce092 | Compared against base branch

@github-actions

Copy link
Copy Markdown

⏱️ Performance Report

✅ Tests 105 passed, 0 failed, 0 skipped
🧪 Suites 28
⏱️ Total step time 209.00s 🔴 +68.00s
⚙️ Test execution 202.40s 🔴 +65.24s
🐢 Slowest test suites
# Suite Tests Duration vs Base
1 runWorkerLoop 13 23.42s 🔴 +9.02s
2 OutboxClaimer (publish outcomes) 9 15.94s 🔴 +3.23s
3 SqliteOutboxStore 8 13.79s 🔴 +4.02s
4 OutboxWaker 7 13.70s -
5 KafkaInboxConsumer 8 13.27s 🔴 +3.00s
6 MysqlOutboxStore 7 11.98s 🔴 +3.29s
7 InProcessOutboxTransport 5 9.32s 🔴 +2.90s
8 SqliteInboxStore 5 8.81s 🔴 +2.47s
9 PostgresOutboxStore 4 6.97s 🔴 +1.94s
10 PostgresInboxStore 4 6.94s 🔴 +1.97s
🐌 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.56s 🔴 +710ms
2 kafka pure helpers kafka pure helpers deriveDedupKey (strict) returns the key or throws PermanentError 2.31s 🔴 +868ms
3 OutboxWaker OutboxWaker notify() wakes a parked wait immediately 2.20s -
4 InMemoryOutboxTransport InMemoryOutboxTransport failWith makes publish reject until cleared; reset clears all 2.16s 🔴 +1.02s
5 OutboxWaker OutboxWaker wait() resolves on the backstop timer when nothing wakes it 2.12s -
6 InMemoryOutboxTransport InMemoryOutboxTransport records published messages and filters by topic 2.12s 🔴 +757ms
7 InProcessOutboxTransport InProcessOutboxTransport a handler throw propagates untouched (claimer generic retry/backoff) 2.05s 🔴 +804ms
8 OutboxWaker OutboxWaker wait() returns immediately when the signal is already aborted 1.99s -
9 OutboxWaker OutboxWaker a notify() with no waiter is latched and consumed by the next wait 1.98s -
10 runWorkerLoop runWorkerLoop runs without a signal: the loop keeps going (and never settles) 1.97s 🔴 +641ms
11 runWorkerLoop runWorkerLoop an abort during the idle sleep wakes it and removes the listener 1.96s 🔴 +610ms
12 wire contract wire contract deriveDedupKey follows event-id → idempotency-key → message-key order 1.95s 🔴 +509ms
13 OutboxWaker OutboxWaker an abort during the wait wakes it and removes the listener 1.94s -
14 DI tokens DI tokens tokens are global Symbol.for registrations under the package namespace 1.93s 🔴 +500ms
15 OutboxProducer (atomic enqueue) OutboxProducer (atomic enqueue) a throw rolls back BOTH the outbox row and the business write 1.93s 🔴 +489ms

Updated for b6ce092 | Compared against base branch

@github-actions

Copy link
Copy Markdown

🧠 Cognitive Complexity Report

Metric PR Base Diff
Total complexity 59 49 🔴 +10
Max function complexity 12 5 🔴 +7
Functions measured 27 25 ⚪ +2
🧩 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
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
OutboxWaker.notify packages/messaging/outbox-waker.ts:35 2 🟢 OK
🧾 Changed files
File Total Max Functions Total diff
messaging/outbox-waker.ts 3 2 2 🆕 new
messaging/outbox-worker.ts 13 12 2 🔴 +7

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


Updated for b6ce092 | Compared against base branch

@rodrigobnogueira
rodrigobnogueira merged commit 44e4c79 into main Jul 19, 2026
6 of 7 checks passed
@rodrigobnogueira
rodrigobnogueira deleted the feat/outbox-waker branch July 19, 2026 04:30
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