Em toda discussão sobre fila alguém pergunta se o broker garante entrega exatamente uma vez. E sempre existe uma página de documentação que parece dizer que sim.
Não existe, no sentido que a pergunta quer dizer. E a boa notícia é que isso não importa, desde que você entenda o que está sendo prometido de verdade.
Por que é impossível
Você manda uma mensagem. A rede cai antes da confirmação chegar.
Você não sabe se ela chegou. Duas opções: mandar de novo, arriscando duplicar, ou não mandar, arriscando perder.
Não existe terceira. Nenhum protocolo resolve isso, porque a informação que decidiria simplesmente não existe do seu lado. Isso é um resultado conhecido de sistemas distribuídos, não uma limitação de implementação que alguém vai consertar na próxima versão.
O mesmo vale do outro lado. O consumidor processa a mensagem e morre antes de confirmar. O broker não sabe se o trabalho foi feito, então reentrega.
As três garantias reais
No máximo uma vez. Confirma antes de processar. Nunca duplica, e perde quando o processamento falha. Serve pra métrica, telemetria, log de acesso: coisas em que perder um evento não muda nada.
Ao menos uma vez. Confirma depois de processar. Nunca perde, e duplica quando a confirmação falha. É o padrão de praticamente todo broker sério, e é o que você deve assumir.
“Exatamente uma vez”. Sempre com aspas. O que existe são mecanismos que dão efeito exatamente uma vez dentro de um domínio fechado, tipicamente quando entrada e saída estão no mesmo sistema transacional. Kafka faz isso com transações entre tópicos. É real e é limitado: assim que o efeito sai desse domínio, para o seu banco, pra uma API externa, pro envio de um e-mail, a garantia acaba.
E o efeito quase sempre sai.
O que resolve de verdade
Pare de procurar entrega exatamente uma vez. Construa processamento idempotente.
A pergunta muda de “como garantir que a mensagem chegue uma vez?” para “o que acontece se ela chegar duas vezes?”. A segunda tem resposta.
Identificador estável na mensagem. Não gerado no momento do envio: derivado do fato.
// frágil: cada publicação gera um id diferente
{ id: randomUUID(), type: "payment.approved", paymentId }
// robusto: o mesmo fato produz o mesmo id
{ id: `payment.approved:${paymentId}`, type: "payment.approved", paymentId }
Constraint no banco, não verificação na aplicação.
create table processed_message (
message_id text primary key,
processed_at timestamptz not null default now()
);
Verificar com SELECT e depois inserir tem uma corrida no meio, e essa corrida acontece exatamente quando duas cópias da mensagem chegam em paralelo, que é o caso que você está tentando cobrir.
A marcação junto com o efeito, na mesma transação.
await db.transaction(async (tx) => {
await tx.insert("processed_message", { message_id: msg.id }); // falha se repetido
await tx.insert("ledger_entry", entries); // o efeito
});
Se a transação der rollback, os dois somem e a reentrega processa normalmente. Se der commit, os dois existem e a reentrega bate na constraint.
Separar as duas escritas reintroduz o problema com passos extras.
Quando o efeito é externo
O caso difícil: o efeito não está no seu banco. Uma cobrança num gateway, um e-mail, uma chamada pra API de um parceiro.
Aí a idempotência precisa existir do outro lado. É pra isso que serve o cabeçalho de chave de idempotência das APIs de pagamento, e é por isso que a chave precisa ser derivada do fato e não gerada por tentativa, como escrevi em idempotência.
Se o outro lado não oferece idempotência, você tem um problema sem solução limpa. As mitigações: registrar a intenção antes de chamar, consultar o estado remoto antes de repetir, e reconciliar periodicamente. Nenhuma é perfeita, e reconciliação é o que fecha a lacuna na prática.
Ordem: o outro mito
Junto com entrega exatamente uma vez vem a expectativa de ordem garantida.
Broker entrega em ordem dentro de uma partição, com um consumidor. Assim que você paraleliza pra ganhar vazão, a ordem entre partições acaba.
Duas saídas.
Particione por chave de agregado. Todos os eventos de um pedido vão pra mesma partição, então a ordem dentro daquele pedido está preservada. A ordem global não, e quase nunca importa.
Ou torne o consumidor tolerante a desordem, usando número de versão. Se chegar a versão 5 e você está na 3, guarde e espere. Se chegar a versão 2 e você está na 5, descarte.
O segundo é mais robusto e mais trabalhoso. O primeiro cobre a maioria dos casos.
O teste que revela tudo
Um teste, e ele derruba mais implementação do que parece:
it("processa o mesmo evento duas vezes sem efeito duplicado", async () => {
const event = paymentApprovedFixture();
await Promise.all([handle(event), handle(event)]);
const entries = await ledger.entriesFor(event.paymentId);
expect(entries).toHaveLength(2); // as pernas de uma transação só
});
O Promise.all é o detalhe. Chamar duas vezes em sequência passa em código com corrida. Em paralelo é onde o bug aparece.
Se o seu consumidor nunca passou por esse teste, ele provavelmente duplica sob carga, e você ainda não descobriu.