O código abaixo está em todo projeto que tem fila, e ele tem um bug que só aparece de vez em quando:
await orderRepo.save(order);
await queue.publish("order.created", { id: order.id });
Duas operações, dois sistemas, duas falhas possíveis.
Se o publish falhar, o pedido existe e ninguém foi avisado. O e-mail não sai, o estoque não baixa, o parceiro não recebe. Do ponto de vista do banco, tudo certo.
Inverta a ordem e fica pior: você publica, o save falha, e agora existe um evento sobre um pedido que não existe.
Não tem ordem certa. O problema é estrutural: você está tentando fazer duas coisas atomicamente em dois sistemas que não compartilham transação.
A ideia
Não publique na fila. Grave a mensagem no seu banco, na mesma transação do dado.
Um processo separado lê a tabela de mensagens pendentes e publica. Se a transação der rollback, a mensagem some junto. Se der commit, a mensagem está lá e vai ser publicada, nem que seja daqui a trinta segundos.
create table outbox (
id bigserial primary key,
aggregate_type text not null,
aggregate_id uuid not null,
event_type text not null,
payload jsonb not null,
created_at timestamptz not null default now(),
published_at timestamptz,
attempts int not null default 0,
last_error text
);
create index on outbox (id) where published_at is null;
O índice parcial importa: a tabela cresce e a consulta só olha o que está pendente, que é sempre um punhado de linhas.
A escrita fica assim:
await dataSource.transaction(async (tx) => {
await tx.save(order);
await tx.insert(Outbox, {
aggregateType: "order",
aggregateId: order.id,
eventType: "order.created",
payload: { id: order.id, total: order.total },
});
});
Uma transação. Ou as duas coisas acontecem, ou nenhuma.
O publicador
Um processo que roda em laço, pega um lote de pendentes, publica e marca.
async function drainOutbox() {
await dataSource.transaction(async (tx) => {
const rows = await tx.query(`
select * from outbox
where published_at is null
order by id
limit 100
for update skip locked
`);
for (const row of rows) {
await broker.publish(row.event_type, row.payload, {
messageId: String(row.id), // idempotência do lado do consumidor
});
await tx.query(`update outbox set published_at = now() where id = $1`, [row.id]);
}
});
}
FOR UPDATE SKIP LOCKED é o detalhe que faz isso escalar. Várias instâncias do publicador podem rodar em paralelo, e cada uma pega um lote diferente sem bloquear as outras. Sem o SKIP LOCKED, a segunda instância fica esperando a primeira, e você tem um único consumidor com passos extras.
O ORDER BY id preserva a ordem de criação, o que importa quando os eventos de um mesmo agregado têm dependência entre si.
A garantia que você tem, e a que não tem
Outbox dá entrega ao menos uma vez. Não exatamente uma vez.
O caso: o publicador manda a mensagem pro broker, o broker aceita, e o processo morre antes de gravar published_at. Na volta, a mesma mensagem é publicada de novo.
Isso não é defeito do padrão. É a consequência de não existir transação distribuída entre o seu banco e o broker. Qualquer solução que prometa exatamente uma vez está escondendo essa duplicata em algum lugar.
A resposta é o consumidor ser idempotente. O messageId estável que o publicador manda é a chave pra isso: o consumidor guarda os IDs processados e ignora repetido.
create table processed_message (
message_id text primary key,
processed_at timestamptz not null default now()
);
Escrevi sobre o mecanismo geral em idempotência.
Limpeza
Outbox vira tabela gigante se ninguém limpar.
delete from outbox
where published_at is not null
and published_at < now() - interval '7 days';
Sete dias é um bom padrão: cobre investigação de incidente e não deixa a tabela inchar. Se você precisa do histórico permanente dos eventos, esse histórico pertence a uma tabela de evento própria, não ao outbox. São coisas diferentes: uma é log de negócio, a outra é fila de saída.
Quando as tentativas falham
Mensagem que falha repetidamente não pode travar a fila. Depois de N tentativas, marque como falha e siga:
alter table outbox add column failed_at timestamptz;
E alerte. Outbox com mensagem parada é sintoma de integração quebrada, e o valor de descobrir isso por alerta em vez de por reclamação de cliente é alto.
Uma métrica que vale ter no painel: idade da mensagem pendente mais antiga. Se passar de alguns minutos, alguma coisa está errada com o publicador, e esse é o tipo de falha que passa despercebida porque nada quebra visivelmente.
O custo
Latência. A mensagem sai com o atraso do laço de publicação, tipicamente entre cem milissegundos e alguns segundos.
Para a maioria das integrações, irrelevante. Para notificação em tempo real na interface, não serve, e aí você usa outro canal e aceita que aquele canal é o que pode perder mensagem.
Se o atraso incomodar, existe a variante com change data capture, lendo o WAL do Postgres com Debezium. Latência de milissegundos e uma peça de infraestrutura a mais pra operar. Comece pelo laço simples: ele resolve quase todo caso e cabe na cabeça de qualquer pessoa do time.
Por que eu insisto nisso
Perda de mensagem é o tipo de bug que não aparece em teste, não aparece em desenvolvimento, e acontece uma vez a cada mil operações em produção.
Aí alguém precisa explicar por que quinze clientes não receberam o e-mail de confirmação em três meses, e a resposta é que o publish falhou quinze vezes e ninguém soube. O outbox transforma isso em impossível por construção, ao custo de uma tabela e um laço.