Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/outbox-pattern.md
T

271 lines
14 KiB
Markdown

# 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.
```ts
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:
1. **Atomic claim**: `UPDATE … SELECT … FOR UPDATE SKIP LOCKED RETURNING *`
moves up to `OUTBOX_DISPATCH_BATCH_SIZE` (default 100) PENDING rows to
PROCESSING with `lockedUntil = now() + 5 min`. Two dispatchers can run
concurrently without ever claiming the same event.
2. **Crash recovery**: rows stuck in PROCESSING past `lockedUntil` are
re-claimed automatically.
3. **Routing**: each event's `eventType` is mapped to a BullMQ queue via
the event router.
4. **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 row `DONE`; transient subscriber failures
are retried by the target queue instead of being lost after dispatch.
It also uses a deterministic BullMQ `jobId` of `outbox:<eventId>` so a
dispatcher crash after enqueue but before the Postgres `DONE` update 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).
`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.
```ts
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
(`withOutboxIdempotency` is the cheap path).
- **Storage**: each successful event leaves a DONE row until the
`outbox-done-retention` job purges DONE rows older than
`OUTBOX_DONE_RETENTION_HOURS`. FAILED rows are kept for operator triage and
compliance review.