Pós-processamento assíncrono (outbox + fila)
Depois que o pedido é confirmado (síncrono, order.pre — ver Engine de Pipeline),
o que sobra — integração com ERP, notificações, auditoria, geração de eventos — roda fora do
request, de forma assíncrona, tolerante a falhas e reprocessável. Decisão em ADR-020 (E-6.2);
padrão em .claude/skills/event-driven.
O caminho é outbox → relay → fila → worker, reusando a mesma engine do E-6.1 como executor do consumidor. O produtor fica agnóstico ao broker: hoje é BullMQ/Redis; trocar por Kafka depois é mudar só o relay/worker, não o pedido.
POST /orders
└─(mesma transação)─► orders + outbox_events (PENDING)
│
OutboxRelayService ◄──┘ poller: FOR UPDATE SKIP LOCKED (seguro c/ N réplicas)
│ jobId = event_id
▼
BullMQ: fila "domain-events"
│
▼
OrderPostWorker ──(OrderConfirmed → order.post)──► Pipeline Engine ──► pipeline_executions
│ (tracking por step)
└─ falha ─► retry c/ backoff ─► tentativas esgotadas ─► job failed = DLQ
1. Outbox transacional (sem dual-write)
Ao confirmar o pedido, o OrdersService grava — na mesma transação do Order — uma linha em
outbox_events para cada evento de domínio:
OrderConfirmed— sempre; carrega um resumo do pedido (fat event: totais, códigos externos, itens) para o consumidor não precisar reler o banco.- Eventos emitidos pelo
order.pre— cadaemit-eventdo pipeline de pré-processamento é drenado do contexto e persistido aqui (fecha o débito do E-6.1: o nó agora publica de fato).
Ou o pedido e seus eventos entram juntos, ou nada entra — não há a janela de inconsistência do dual-write (gravar o estado e publicar no broker em chamadas separadas).
Cada linha é o envelope do evento: id (= event_id), eventType, eventVersion, orgId,
aggregateType/aggregateId, correlationId, causationId, payload, occurredAt.
2. Relay (poller)
O OutboxRelayService roda em intervalo (OUTBOX_POLL_INTERVAL_MS, default 2s) e:
- reclama um lote de linhas
PENDINGcomavailableAt <= nowusandoFOR UPDATE SKIP LOCKED— com N réplicas da API, nenhuma pega a linha que a outra travou (escala horizontal segura); - enfileira cada evento no BullMQ com
jobId = event_id(dedup de enfileiramento); - marca
PUBLISHED. Se o enfileiramento falhar, aplica backoff (availableAt) e incrementaattempts; esgotado o teto (OUTBOX_MAX_ATTEMPTS), a linha viraFAILED(aguarda reprocesso).
3. Worker order.post
O OrderPostWorker consome a fila e mapeia o tipo de evento para um trigger de pipeline:
OrderConfirmed → order.post; qualquer outro tipo cai em event.<Tipo> (convenção, não regra de
negócio hardcoded — eventos customizados já ficam prontos para ganhar um consumidor). Roda o
pipeline via a engine do E-6.1, com todo o tracking por step reaproveitado.
- Sem pipeline
order.posthabilitado → no-op (o pedido segue confirmado, nada a fazer). - Pipeline
FAILED→ o worker lança; o BullMQ retenta com backoff exponencial (ORDER_POST_ATTEMPTS). Esgotadas as tentativas, o job fica emfailed= DLQ inspecionável. - O pós-processamento nunca desfaz o pedido (anti-padrão) — falha vira retry/DLQ, não rollback.
Idempotência: jobId = event_id evita reenfileirar o mesmo evento; consumidores externos devem
ser idempotentes (at-least-once). Ver as métricas por step em /pipelines/:id/executions.
4. Endpoints (API NestJS)
Escopados por organização (ActiveOrgGuard) + RBAC.
| Método | Rota | Permissão | Descrição |
|---|---|---|---|
| GET | /api/outbox | outbox:read | Lista eventos de domínio (filtro status, limit). |
| POST | /api/outbox/:id/reprocess | outbox:reprocess | Volta um evento FAILED para PENDING (o relay reenfileira). |
O histórico de execução do order.post aparece em /api/pipelines/:id/executions (o pipeline cujo
trigger é order.post). Schemas completos na Referência da API (tags
Pipeline · Outbox e Pipeline · Engine).
5. Configuração
| Variável | Default | O que controla |
|---|---|---|
REDIS_URL | redis://localhost:6379 | Conexão do BullMQ (relay + worker). |
OUTBOX_RELAY_ENABLED | true | Liga/desliga o relay neste processo. |
WORKER_ENABLED | true | Liga/desliga o worker neste processo. |
OUTBOX_POLL_INTERVAL_MS | 2000 | Intervalo de varredura do relay. |
OUTBOX_BATCH_SIZE | 50 | Linhas por varredura. |
OUTBOX_MAX_ATTEMPTS | 10 | Tentativas de enfileiramento antes de FAILED. |
ORDER_POST_ATTEMPTS | 5 | Tentativas do worker antes da DLQ. |
ORDER_POST_CONCURRENCY | 4 | Jobs simultâneos por worker. |
A conexão Redis é resiliente: a API sobe mesmo com o Redis fora — o outbox só acumula PENDING
até o Redis voltar. Desligar OUTBOX_RELAY_ENABLED/WORKER_ENABLED permite rodar uma instância
"só-API" (sem processamento de fundo) separada dos workers.
Exemplo de pipeline order.post
Notificar um sistema externo quando o pedido é confirmado (o resultado fica no tracking):
{
"nodes": [
{ "id": "start", "type": "start" },
{ "id": "notify", "type": "http-request",
"config": { "method": "POST", "url": "https://erp.exemplo.com/orders",
"body": { "number": "{{orderNumber}}", "total": "{{order.totalAmount}}" } },
"onError": "abort" },
{ "id": "ok", "type": "set-status",
"config": { "statusTag": "success", "name": "Integrado ao ERP", "phase": "order.post" } }
],
"edges": [
{ "from": "start", "to": "notify" },
{ "from": "notify", "to": "ok", "branch": "success" }
]
}
Se o http-request falhar, o pipeline fica FAILED, o worker relança e o BullMQ retenta; após o
teto, o job vai para a DLQ e pode ser investigado (e o evento reprocessado via /outbox/:id/reprocess).
Segurança & limites
- Reprocessar um evento republica o
OrderConfirmed— os consumidores precisam ser idempotentes. - O nó
http-requestnoorder.posttem o mesmo risco de SSRF doorder.pre(egress configurável) — mitigação atual: só http/https, timeout curto, sem redirects; endurecimento (allowlist/egress-proxy) é dívida registrada. - Follow-ups (não nesta fatia): distinguir falha transitória (retentar) de terminal (DLQ direto);
dedup forte por tabela
ProcessedEvent; publicação para Kafka quando a extração de serviços justificar. Webhooks (webhook.received) = E-6.3; editor visual = E-6.4.