Milestone 7: long-running outbox worker process#12
Merged
Conversation
Wraps the OutboxClaimer (milestone 6) in a polling loop with graceful
shutdown so the post-commit side-effect path can run as a separate
process from the API.
scripts/start-worker.ts boots a headless Nest application context,
resolves OutboxClaimer from DI, and ticks on a configurable interval
(default 2s). The loop is signal-cancellable: SIGTERM/SIGINT abort an
AbortController that interrupts the in-flight wait, lets the current
tick drain, closes the Nest container, and exits cleanly. Per-tick
errors are logged and the loop continues — per-event retry/backoff/
failure is already handled by the claimer.
The loop body is exported as runWorkerLoop(claimer, { pollIntervalMs,
claimer, signal }) so tests drive it with an AbortController without
spawning a child process.
Worker config flows through src/config/env.ts as a typed `outbox`
block: OUTBOX_POLL_MS (default 2_000), OUTBOX_BATCH_SIZE (32),
OUTBOX_STUCK_TIMEOUT_MS (60_000), OUTBOX_WORKER_ID (host-pid by
default in the claimer). All match brief §8.
Tests cover three properties:
- drain: pending event flips to completed via the loop, FakeEmailTransport
records exactly one matching email
- prompt abort: with a 30s poll interval, AbortController.abort()
mid-wait returns within 5 seconds
- tick failure resilience: a synthetic throw on the first tick is
logged; subsequent ticks run normally
Manual smoke confirmed via `npm run start:worker`: boots, ticks,
SIGTERM → drain → "stopped cleanly" → exit 0.
Local npm run ci passes: 45/45 tests, 91.43% statements, 95.55% functions.
rodrigobnogueira
added a commit
that referenced
this pull request
May 25, 2026
Milestone 7: long-running outbox worker process
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.
Summary
OutboxClaimerin a polling loop with graceful shutdown so the post-commit side-effect path runs as a separate process from the API.npm run ciexits 0 locally.Changes
scripts/start-worker.ts:OutboxClaimerfrom DI.OUTBOX_POLL_MS(default 2_000ms); each iteration ticks once then waits.SIGTERM/SIGINTabort anAbortControllerthat interrupts the in-flightawait delay(..., { signal }), lets the current tick drain, closes the Nest container, exits cleanly (verified manually: signal → "received SIGTERM, draining current tick…" → "outbox worker stopped cleanly" → exit 0).runWorkerLoop(claimer, { pollIntervalMs, claimer, signal })so tests can drive it with anAbortControllerwithout spawning a child process.src/config/env.ts: typedoutboxblock —pollIntervalMs,batchSize,stuckTimeoutMs,workerInstanceId— read fromOUTBOX_POLL_MS,OUTBOX_BATCH_SIZE,OUTBOX_STUCK_TIMEOUT_MS,OUTBOX_WORKER_ID. Defaults match brief §8.npm run start:workerscript entry point.test/integration/outbox-worker.spec.ts— 3 tests:user.invitedoutbox row flips tocompleted, claimed by the configured worker id;FakeEmailTransportrecords exactly one matching email.AbortController.abort()mid-wait returns the loop within 5 seconds.tick()is logged; subsequent iterations run normally.Modules Touched
organizations/users/membershipsprojectsaudit-logoutbox(worker process wrapper around the existing claimer)auth/contexttrpcdatabasenpm run start:worker,OUTBOX_*env)Public Surface (libraries)
NestFactory.createApplicationContext+ DI resolution — nonest-trpc-native/nest-drizzle-nativesurface is touched here.Security Review
OUTBOX_POLL_MS,OUTBOX_BATCH_SIZE,OUTBOX_STUCK_TIMEOUT_MS) are parsed viaNumber.parseIntwith rejection forNaN/non-positive values (Invalid <NAME>thrown byloadEnv()).db=<path>,poll=<ms>,batch,stuck, claim counts). No payloads, tokens, or PII.finally; double-signal is idempotent (if (shuttingDown) return). No risk of partial shutdown loop.Dependency Review
runWorkerLoopusesnode:timers/promises'setTimeoutandAbortSignal, both Node built-ins.Migrations
Validation
npm run typechecknpm run lintnpm run complexity:checknpm run test:cov— 45/45 tests; 91.43% statements / 95.55% functionsnpm run security:audit— exits 0 (4 moderate dev-only findings unchanged)npm run buildnpm run smokeOUTBOX_POLL_MS=300 OUTBOX_WORKER_ID=manual-smoke npm run start:worker→ boots, ticks, sends SIGTERM viatimeout, drains, logs "stopped cleanly".Release Notes
CHANGELOG.mdupdated under[Unreleased]with milestone-7 entry.