Pular para o conteúdo principal

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 — cada emit-event do 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:

  1. reclama um lote de linhas PENDING com availableAt <= now usando FOR UPDATE SKIP LOCKED — com N réplicas da API, nenhuma pega a linha que a outra travou (escala horizontal segura);
  2. enfileira cada evento no BullMQ com jobId = event_id (dedup de enfileiramento);
  3. marca PUBLISHED. Se o enfileiramento falhar, aplica backoff (availableAt) e incrementa attempts; esgotado o teto (OUTBOX_MAX_ATTEMPTS), a linha vira FAILED (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.post habilitado → 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 em failed = 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étodoRotaPermissãoDescrição
GET/api/outboxoutbox:readLista eventos de domínio (filtro status, limit).
POST/api/outbox/:id/reprocessoutbox:reprocessVolta 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ávelDefaultO que controla
REDIS_URLredis://localhost:6379Conexão do BullMQ (relay + worker).
OUTBOX_RELAY_ENABLEDtrueLiga/desliga o relay neste processo.
WORKER_ENABLEDtrueLiga/desliga o worker neste processo.
OUTBOX_POLL_INTERVAL_MS2000Intervalo de varredura do relay.
OUTBOX_BATCH_SIZE50Linhas por varredura.
OUTBOX_MAX_ATTEMPTS10Tentativas de enfileiramento antes de FAILED.
ORDER_POST_ATTEMPTS5Tentativas do worker antes da DLQ.
ORDER_POST_CONCURRENCY4Jobs 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-request no order.post tem o mesmo risco de SSRF do order.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.