14 KiB
Transactional Outbox Pattern
The outbox pattern keeps side-effects (mail sends, webhooks, audit logs,
workflow triggers) transactionally consistent with the database writes
that triggered them. Without it, a crash between DB COMMIT and "call
mailer" leaves the DB updated but the side-effect lost.
This document describes how to publish and consume outbox events in this
codebase. See docs/improvement-plan/03-scaling-infrastructure/outbox-pattern.md
for the broader design rationale.
When to publish
Publish inside a Prisma $transaction block whenever a successful DB
write should fan out an asynchronous side-effect.
import { publishOutboxEvent } from "@toolctrl/db/outbox";
await prisma.$transaction(async (tx) => {
const subscription = await tx.subscription.update({
where: { id },
data: { status: "active" },
});
await publishOutboxEvent(tx, {
tenantId: subscription.tenantId,
type: "billing.subscription.updated",
payload: { subscriptionId: subscription.id, status: "active" },
dispatchKey: `stripe:${stripeEvent.id}`, // optional idempotency key
});
});
publishOutboxEvent is type-constrained to require a
Prisma.TransactionClient — calling it with a top-level PrismaClient
fails to type-check.
Event-type naming convention
Events follow <domain>.<entity>.<verb>, lowercase, dot-separated. The
dispatcher routes by the first segment by default:
| Prefix | Target BullMQ queue |
|---|---|
tenant.subscription. |
outbox.notification |
mail. |
outbox.mail |
webhook. |
outbox.webhook |
notification. |
outbox.notification |
audit. |
outbox.audit |
workflow. |
outbox.workflow |
search.index. |
outbox.search-index |
rag.index. |
outbox.rag-index |
ai.cost. |
outbox.ai-cost |
marketing.lead. |
outbox.marketing |
form.submission. |
outbox.form-submission-route |
billing. |
outbox.notification |
plugin.hook. |
outbox.plugin-hook |
plugin.capability-use. |
outbox.plugin-capability-use |
Add a routing rule before introducing a new prefix — events with no
matching rule go straight to FAILED with a Sentry alert. See
apps/worker/src/outbox/event-router.ts.
Workflow routing has an active receiver/subscriber path when the worker is
configured for the general queue group with the workflow execution settings.
apps/worker/src/jobs/workflow-trigger.ts parses workflow.* outbox jobs,
applies receiver-side idempotency, fans out to matching live event-trigger
workflows, starts queued manual workflow.execution.requested executions, and
resumes queued workflow.execution.continue.requested continuations through the
selected executor. HTTP mode uses the signed internal bridge; hybrid/native mode
can resume worker-native safe-core continuations when the only native preflight
gap is the current waitForInput boundary. Candidates that fail preflight are
skipped or recorded as rejected.
apps/worker/src/jobs/workflow-trigger-store.ts provides the worker-native
Prisma lookup/preflight port. Application events publish
workflow.trigger.event through EventsService. The route-backed row mutation
producers for row.created, row.updated, and row.deleted record the row
write, application event, and workflow outbox event in the same transaction
before post-commit notifications/webhook callouts run. Internal mutations
without a request context remain intentionally eventless unless the caller opts
into an event context. Workflow row blocks pass a workflow event context so
requestless rowCreate, rowUpdate, and rowDelete blocks still publish row
events without enabling external webhook callouts. A source-inventory test guards the current execution
boundary: manual and tenant API runs must publish
workflow.execution.requested instead of executing inline, non-stream
continuations must publish workflow.execution.continue.requested, only the
internal workflow bridge may execute
type: "trigger" directly, and the legacy WorkflowEngine.trigger(...)
facade must stay absent.
The worker-native block contract is intentionally explicit. email is the
first-class mail block, httpRequest is the outbound/webhook-style block, and
conditions are represented by if plus switch. There is no separate
mail, webhook, notification, audit, delay, or schedule block id in
the registry today; adding any of those requires updating
packages/foundation/src/workflows.ts, its capability matrix tests, workflow
UI i18n, and worker-native execution/preflight behavior together.
Operationally, the outbox dispatcher and workflow subscriber are separate from
mail delivery workers. Production-like Coolify stacks need a persistent
worker-general (WORKER_QUEUE_GROUP=general) for the dispatcher and
outbox.workflow, plus a persistent worker-mail
(WORKER_QUEUE_GROUP=mail) for outbox.mail, outbox.webhook,
outbox.marketing, and toolctrl.email work. A green outbox row does not prove
that both services exist or that the downstream BullMQ job finished.
Non-durable exception: stream runs
Workflow stream starts remain an explicit exception to the worker-durable
execution boundary. The stream route starts through
WorkflowsExecutionsService.stream(...), creates a running execution row, and
then calls execute(...) in the web process so the existing SSE/emitter
progress contract can deliver local run updates to the browser. Web restarts can
interrupt an in-flight stream run before the worker can resume it. Stream
continuations are already queued through workflow.execution.continue.requested;
moving stream starts to the worker requires a distributed progress channel or
SSE broker rather than a partial outbox migration.
The worker bridge has an opt-in real-Postgres regression test at
apps/worker/src/jobs/workflow-trigger-e2e.test.ts. It skips without a real
database URL and runs with WORKFLOW_TRIGGER_E2E_DATABASE_URL, falling back to
OUTBOX_E2E_DATABASE_URL or any non-stub DATABASE_URL. The test publishes a
workflow trigger outbox event, claims and routes it through the production
dispatcher seam, then executes a live safe-core workflow through the native
worker runner and verifies idempotent re-delivery. It also verifies the
workflow.executed plugin hook path by routing plugin.hook.workflow.executed
through outbox.plugin-hook fanout into toolctrl.plugin-invoke. A paired
failure-path fixture forces an unsupported native block, verifies terminal
status: "error" persistence, then routes plugin.hook.workflow.failed
through the same fanout boundary into toolctrl.plugin-invoke.
The canonical non-browser readiness gate for this path is
pnpm run verify:workflow-outbox-native. It composes the existing focused
worker, foundation, and source-inventory tests for outbox.workflow routing,
workflow-trigger subscriber bootstrap, native/hybrid executor selection,
credential-free native execution boundaries, worker health fail-loud settings,
and queued manual/API/continuation producers. It is intentionally not a live
drain probe; use the opt-in real-Postgres regression or the operator-gated E2E
drain readiness check when an environment-level worker/Redis/Postgres proof is
needed.
Dispatcher
apps/worker/src/outbox/dispatcher.ts polls every 1 second (configurable
via OUTBOX_DISPATCH_INTERVAL_MS). Each tick:
- Atomic claim:
UPDATE … SELECT … FOR UPDATE SKIP LOCKED RETURNING *moves up toOUTBOX_DISPATCH_BATCH_SIZE(default 100) PENDING rows to PROCESSING withlockedUntil = now() + 5 min. Two dispatchers can run concurrently without ever claiming the same event. - Crash recovery: rows stuck in PROCESSING past
lockedUntilare re-claimed automatically. - Routing: each event's
eventTypeis mapped to a BullMQ queue via the event router. - Enqueue + status update: claimed rows are processed with bounded
concurrency (
OUTBOX_DISPATCH_CONCURRENCY, default 10) so a large batch does not serialize every Redis enqueue and status update. The default BullMQ enqueue path adds receiver-level retries with exponential backoff before marking the outbox rowDONE; transient subscriber failures are retried by the target queue instead of being lost after dispatch. It also uses a deterministic BullMQjobIdofoutbox:<eventId>so a dispatcher crash after enqueue but before the PostgresDONEupdate does not create a second receiver job when the same outbox row is re-claimed.- success →
status = DONE,processedAt = now() - failure → increment
retries,scheduledAt = now() + backoff(retries)with backoff[1, 2, 4, 8, 16, 32]minutes. - retries >
OUTBOX_MAX_RETRIES(default 6) →status = FAILED(dead- letter).
- success →
DONE means the dispatcher successfully handed the event to the target BullMQ
queue and recorded that handoff in Postgres. It does not mean the subscriber
processed the job, the provider call succeeded, or a domain state machine
reached a terminal success state. For workflow events, operators must also
watch the outbox.workflow BullMQ job state and the associated
WorkflowExecution status. For mail/webhook events, also watch the
worker-mail subscriber queues and the toolctrl.email job state.
The dispatcher is enabled by OUTBOX_DISPATCHER_ENABLED=true. Defaults to
off in dev so a missing Redis URL does not crash the worker.
Retention
worker-general registers a daily cleanup.run maintenance job named
outbox-done-retention. It deletes only rows with status = DONE and a
non-null processedAt older than OUTBOX_DONE_RETENTION_HOURS (default 720
hours, 30 days). The deletion is bounded by OUTBOX_DONE_RETENTION_BATCH_SIZE
(default 1000, clamped to 1..10000) and uses FOR UPDATE SKIP LOCKED, so large
first runs drain gradually without blocking the dispatcher.
FAILED and CANCELLED rows are intentionally retained for audit and incident
review. Deleting DONE rows releases their dispatchKey uniqueness after the
retention window; producers that require longer idempotency windows must use a
domain-level idempotency key as well.
Receiver-side idempotency
BullMQ delivery is at-least-once: a receiver crash between
"job processed" and "ack" causes a redelivery. Wrap your handler in
withOutboxIdempotency so duplicate deliveries are a no-op.
import { withOutboxIdempotency } from "@toolctrl/db/outbox-receiver";
import { createIdempotencyStore } from "@toolctrl/shared-store";
const idempotencyStore = createIdempotencyStore({
store: sharedStore,
prefix: "outbox",
ttlSeconds: 60 * 60 * 24, // 24h — longer than the longest retry window
});
await withOutboxIdempotency(idempotencyStore, job.data.outboxEventId, async () => {
await sendMail(job.data.payload);
});
claim() does an atomic SET NX EX. If the claim fails (another worker
already processed the event), the handler is skipped and the cached
result returned.
Operational
Pending count alert
SELECT count(*) FROM "OutboxEvent" WHERE status = 'PENDING' AND scheduledAt < now() - interval '60 seconds'
Alert when > 1000 for > 5 minutes — that means the dispatcher is behind.
Downstream queue alert
Outbox DONE counts are not sufficient for worker health. Alert separately on
target BullMQ queue depth, failed jobs, and stalled jobs for outbox.workflow,
outbox.mail, outbox.webhook, outbox.marketing, and toolctrl.email.
Workflow incidents must include the WorkflowExecution terminal status because
workflow.execution.requested can be handed to BullMQ successfully while the
execution later fails, stalls, or remains queued behind a missing bridge.
Re-queueing failed events
Use toolctrl-cli outbox requeue after fixing the underlying receiver.
The CLI supports single-row and filtered bulk requeues, --dry-run, JSON
output, and guardrails that avoid replaying PENDING / PROCESSING
rows. Prefer it over raw SQL so processedAt, lastError, retry state,
and replay warnings stay consistent.
Raw SQL is an emergency-only fallback for a DBA-led incident when the CLI cannot run; record the exact statement and reason in the incident log.
Environment
| Variable | Default | Purpose |
|---|---|---|
OUTBOX_DISPATCHER_ENABLED |
false |
Master switch for the dispatcher worker |
OUTBOX_DISPATCH_INTERVAL_MS |
1000 |
Polling cadence (ms) |
OUTBOX_DISPATCH_BATCH_SIZE |
100 |
Max events claimed per tick |
OUTBOX_DISPATCH_CONCURRENCY |
10 |
Max claimed events processed in parallel per tick |
OUTBOX_MAX_RETRIES |
6 |
Retries before dead-letter |
OUTBOX_DONE_RETENTION_HOURS |
720 |
DONE-row replay/debug retention window |
OUTBOX_DONE_RETENTION_BATCH_SIZE |
1000 |
Max DONE rows purged per maintenance run |
Trade-offs
- Latency: ~1s polling tick. A LISTEN/NOTIFY variant could cut that to <10ms; deferred until needed.
- At-least-once delivery: receivers MUST be idempotent
(
withOutboxIdempotencyis the cheap path). - Storage: each successful event leaves a DONE row until the
outbox-done-retentionjob purges DONE rows older thanOUTBOX_DONE_RETENTION_HOURS. FAILED rows are kept for operator triage and compliance review.