O pedido foi salvo, mas o evento que deveria reservar o estoque nunca chegou ao broker. O processo caiu entre o COMMIT e a chamada de publicação. No caminho inverso, publicar primeiro pode anunciar um pedido que depois falha no banco.

O padrão outbox transacional fecha essa janela gravando a mudança de negócio e o evento em uma única transação local. Depois do commit, um relay lê a tabela de outbox e publica as linhas pendentes. A publicação ainda pode falhar ou acontecer duas vezes, então o desenho precisa incluir retry, observabilidade e consumidor idempotente.

Este é um spoke da execução durável para agentes de IA com checkpoints, filas e workflows. O padrão também serve para APIs e serviços comuns. Em um runtime de agente, a mesma fronteira aparece quando uma transição confirmada precisa acordar uma tarefa, emitir um evento de auditoria ou iniciar uma etapa fora do processo atual. O outbox não torna a chamada externa parte da transação do banco.

Diagrama mostra a mudança no banco e o evento no outbox confirmados juntos, seguidos por relay, retry e consumidor idempotente.

Resposta curta

  • Grave o registro de negócio e a mensagem do outbox na mesma transação do banco.
  • Publique depois do commit por polling ou CDC, mantendo a linha até a entrega ser aceita pelo relay.
  • Considere duplicatas normais: o relay pode cair depois de publicar e antes de registrar o sucesso.
  • Faça o consumidor deduplicar por um ID estável e preserve a ordem apenas onde o domínio realmente exige.
  • Prefira uma transação local simples quando não existe uma segunda obrigação de publicação.

O problema é um dual write

Imagine este código em uma API de pedidos:

const order = await saveOrder(input);
await broker.publish("OrderPlaced", order);
return order;

Se saveOrder confirmar e o processo morrer antes de publish, o banco conhece o pedido, mas o estoque, o e-mail ou a projeção downstream não recebem a notícia. Se a ordem for invertida, o evento pode chegar enquanto o registro não existe. Um retry também não resolve sozinho: um timeout pode esconder uma publicação que já foi aceita.

O AWS Prescriptive Guidance descreve esse caso como uma operação de escrita em dois sistemas. A recomendação é persistir o evento junto com a atualização e usar um processo separado para enviá-lo. O padrão de Transactional Outbox do microservices.io chega à mesma fronteira: a transação local confirma a obrigação; o relay cuida da comunicação posterior.

Isso não é uma promessa de entrega exatamente uma vez. É uma forma de evitar que a obrigação de publicar exista apenas na memória do processo.

Quando o outbox vale o custo?

Use o padrão quando uma mudança local confirmada precisa causar um efeito assíncrono em outro sistema e perder essa notificação deixa estados divergentes. Exemplos:

Situação Decisão inicial
Atualizar uma tabela e chamar um broker em seguida Considere o outbox na mesma transação
Salvar uma preferência sem nenhuma notificação downstream Uma transação local costuma bastar
Criar uma tarefa de agente depois de uma aprovação persistida Outbox pode registrar a obrigação de iniciar a tarefa
Atualizar vários bancos como se fosse uma única transação O outbox não resolve o commit entre serviços; avalie uma saga ou outro desenho de coordenação
Enviar e-mail diretamente após o commit O outbox pode proteger a entrega da intenção, mas o provedor de e-mail ainda precisa de idempotência ou de um recibo seguro

O custo é real. Você passa a operar uma tabela, um relay, retries, retenção, métricas e uma política para mensagens que não avançam. Se a operação pode continuar síncrona dentro do mesmo banco, introduzir um broker e um relay pode adicionar partes sem resolver um problema existente.

O que precisa ser confirmado junto?

A unidade atômica deve conter o estado que o leitor downstream precisa e a obrigação de publicar a mudança. Em PostgreSQL, COMMIT torna as alterações da transação visíveis e duráveis quando a operação retorna sucesso (documentação do PostgreSQL sobre COMMIT). O formato exato depende do banco e do driver, mas a ideia é a mesma:

-- Forma ilustrativa. Adapte tipos, constraints e cliente ao seu banco.
CREATE TABLE outbox_events (
  id uuid PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL,
  published_at timestamptz,
  attempts integer NOT NULL DEFAULT 0,
  last_error text
);

BEGIN;

UPDATE orders
SET status = 'placed'
WHERE id = 'order-42';

INSERT INTO outbox_events
  (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES
  ('event-42', 'order', 'order-42', 'OrderPlaced', '{"orderId":"order-42"}', now());

COMMIT;

O UPDATE e o INSERT precisam falhar ou confirmar juntos. Se a transação fizer rollback, o relay não deve encontrar uma obrigação fantasma. Se fizer commit, a linha do outbox precisa carregar um ID estável, o tipo do evento, a chave do agregado, o payload e informação suficiente para diagnosticar tentativas.

Não grave no outbox em uma conexão separada por acidente. Isso recria o dual write: o pedido pode confirmar enquanto a linha do evento falha, ou o contrário. Passe o mesmo contexto transacional ao código que salva a entidade e ao código que registra o evento.

O relay publica depois do commit

O relay é um worker, não uma etapa invisível da transação. Ele encontra linhas elegíveis, reclama um lote, publica no broker e registra o resultado. Se a publicação falhar, preserva a linha, registra o erro e agenda uma nova tentativa. Se o processo morrer depois do broker aceitar a mensagem e antes de gravar published_at, a mesma linha pode ser tentada novamente.

Para vários relays, a tabela precisa de uma política de claim. Em PostgreSQL, FOR UPDATE SKIP LOCKED pode evitar que workers aguardem as mesmas linhas, mas a própria documentação avisa que o resultado não é uma visão consistente e que esse recurso serve para tabelas parecidas com filas, não para qualquer consulta (documentação do PostgreSQL sobre locking em SELECT). O claim também precisa de lease ou recuperação quando um worker some no meio do publish.

Há duas famílias comuns de relay:

  1. Polling publisher: workers consultam a tabela em intervalos ou recebem um sinal para acordar. É simples de entender, mas precisa de índices, limites de lote, backoff e uma política para não transformar a tabela de negócio em um gargalo.
  2. Change data capture: uma ferramenta captura a inserção na tabela e a encaminha ao broker. O Outbox Event Router do Debezium, por exemplo, lê mudanças de uma tabela de outbox e roteia eventos por campos do registro. CDC reduz o polling da aplicação, mas adiciona configuração, operação e uma dependência de captura.

Escolha pela carga, latência aceitável, capacidade operacional, ordenação necessária e caminho de recuperação. Não há uma taxa universal que diga quando trocar um relay por outro.

Duplicatas e ordem continuam sendo responsabilidade do desenho

O relay não pode apagar a linha antes de uma publicação aceita. Mesmo depois de marcar o evento como publicado, o processo pode ter caído no intervalo entre o broker e o banco. Por isso, a AWS recomenda consumidores idempotentes para mensagens duplicadas, e o padrão descrito pelo microservices.io trata a publicação duplicada como uma possibilidade normal do relay.

O consumidor pode usar o ID do evento em uma tabela de inbox com constraint única, ou pode fazer a própria operação ser idempotente. O importante é que a verificação e o efeito não criem uma nova corrida. Um SELECT de verificação seguido de um INSERT separado não protege nada se duas entregas chegarem juntas; use uma constraint ou uma transação adequada ao banco.

A ordem também precisa de escopo. O AWS alerta que eventos devem respeitar a ordem das atualizações do serviço quando essa sequência é necessária para o domínio. Isso não significa que todas as mensagens de todos os agregados precisem de uma fila global. Escolha uma chave de ordenação por agregado, partição ou fluxo, e documente o que um consumidor pode fazer com um evento atrasado.

Como verificar o contrato antes de operar

Um outbox não está pronto quando a tabela existe. Verifique os casos que atravessam o processo inteiro:

  1. Rollback: force uma falha depois de inserir a entidade e o evento. Nenhuma das duas linhas deve permanecer confirmada.
  2. Relay indisponível: confirme que o estado local continua recuperável e que a linha não some quando o broker recusa a conexão.
  3. Timeout ambíguo: simule uma publicação aceita seguida de erro no relay. O consumidor deve tolerar a segunda entrega.
  4. Dois workers: faça dois relays disputarem o mesmo lote e confirme que o claim, o lease e a atualização final não fazem um evento desaparecer.
  5. Ordem por agregado: grave duas transições do mesmo pedido e verifique a ordem que chega ao consumidor. Teste agregados diferentes separadamente.
  6. Mensagem venenosa: registre tentativas, erro e payload suficiente para uma triagem. Depois de um limite, mova a mensagem para uma quarentena operável, como a dead-letter queue para tarefas de agentes que falham sempre.

Esse conjunto complementa os testes de idempotência de uma API e os testes de webhooks com duplicatas e retries. Eles exercitam fronteiras diferentes: o outbox protege a obrigação de publicar; o consumidor e a API protegem o efeito de receber ou repetir.

O que o outbox não resolve

O padrão não faz um banco e um broker participarem de uma transação distribuída. Ele também não reverte automaticamente um efeito externo já aceito, não garante que consumidores sejam rápidos e não elimina a necessidade de métricas, retenção e reprocessamento.

Se vários serviços precisam concluir uma transação de negócio, cada serviço ainda terá suas próprias mudanças e falhas. Uma saga pode coordenar essas etapas com compensações, mas isso é uma decisão diferente da publicação confiável de um evento local.

Para um agente, registre também o ID da execução, o ID da ação e a versão do contrato que gerou o evento. Meu RemoteCode é uma ferramenta própria para esse tipo de fluxo. Isso ajuda a ligar uma mudança confirmada ao trabalho que o relay iniciou sem transformar prompts ou segredos em payload de integração.

Use este critério: se perder a notificação depois do commit deixa o sistema em um estado que ninguém consegue reconstruir, persista a obrigação de publicar dentro da mesma transação. Depois trate relay, duplicatas, ordem e recuperação como partes visíveis do sistema.

Fontes