Um teste de worker pode ficar verde mesmo quando a mensagem será processada duas vezes. Basta testar somente a primeira entrega: o handler termina, o efeito é criado e o caso verifica um ack bem-sucedido. A janela perigosa aparece depois, quando o efeito já foi confirmado, mas o worker cai antes de confirmar a entrega ao broker.

Para testar essa janela, faça o ack falhar depois do efeito, entregue a mesma mensagem novamente e conte o efeito de negócio. O teste deve provar que a reentrega é possível e que o efeito continua singular. Isso é diferente de testar apenas uma função de retry ou de contar quantas mensagens o consumidor recebeu.

Este artigo se conecta ao guia de execução durável com filas e checkpoints. Aquele texto ajuda a escolher a forma de manter o trabalho vivo. Aqui, a pergunta é como testar o consumidor quando a entrega pode voltar. A fronteira do outbox transacional trata da publicação; este teste trata do efeito no consumidor.

Não estou apresentando um benchmark de RabbitMQ, SQS ou outro broker. Executei a fixture local com Node.js v25.5.0 e node:test. Ela usa estado em memória para controlar a falha do ack. Não prova atomicidade em um banco, comportamento de múltiplos processos ou a política de um provedor específico.

Diagrama mostra uma mensagem sendo entregue, produzindo um efeito, falhando no ack e sendo entregue novamente sem repetir o efeito.

Resposta curta

  • O worker deve aplicar o efeito antes de confirmar o ack.
  • O teste precisa forçar a falha entre esses dois passos e entregar a mesma mensagem novamente.
  • O consumidor ou a operação de negócio precisa reconhecer o mesmo ID sem criar um segundo efeito.
  • Um teste em memória prova a decisão do código, mas não substitui a prova com o broker e o armazenamento reais.

Por que o primeiro teste verde não basta?

Filas distribuídas normalmente separam a entrega da confirmação. O Amazon SQS documenta a entrega pelo menos uma vez: em uma condição rara, uma cópia de uma mensagem pode voltar a aparecer. A recomendação da própria documentação é tornar o processamento idempotente.

O RabbitMQ descreve uma fronteira parecida na documentação de confirmações do consumidor. O consumidor confirma que processou a entrega; falhas de conexão e requeue podem fazer a mensagem voltar. O broker não sabe se o efeito da aplicação foi criado antes da queda do processo.

Por isso, um teste que verifica apenas ack responde uma pergunta estreita: o código enviou uma confirmação? Ele não responde se a operação pode ser repetida depois que o efeito foi persistido. A asserção mais importante deve observar o pedido, registro, email ou outro efeito que não pode acontecer duas vezes.

O teste mais informativo não começa com um retry. Ele começa com a perda do ack depois do efeito. Essa ordem imita a falha que transforma uma operação concluída em uma entrega aparentemente pendente.

Qual contrato o consumidor deve testar?

Escreva o contrato antes da fixture. Para cada entrega, defina quando o worker pode confirmar, quando deve pedir retry e qual situação vai para uma dead-letter queue. Um consumidor não deve usar a mesma resposta para erro transitório, payload inválido e efeito já aplicado.

Cenário Preparação O que o teste verifica
Entrega nova ID ainda não processado um efeito e um ack
Ack perdido efeito concluído, ack lança erro a entrega volta e o efeito não se repete
Falha antes do efeito handler lança antes de persistir não há efeito e a mensagem pode ser repetida
Payload inválido schema ou versão não aceita falha sem efeito e decisão de descarte explícita
Mesmo ID em dois workers duas tentativas próximas uma operação aceita o ID e a outra não cria outro efeito
Erro permanente dependência responde falha não recuperável o worker para de repetir indefinidamente

O ID da mensagem e o ID do efeito podem ser diferentes. Um evento pode ser entregue duas vezes, enquanto duas mensagens diferentes podem representar a mesma operação de negócio. A regra de deduplicação precisa dizer qual identidade protege qual efeito.

Como montar uma fixture sem esperar o relógio?

Comece com um consumidor pequeno. O worker executa o efeito, confirma a entrega somente depois que o efeito termina e deixa o erro do ack escapar para que o broker possa redeliver a mensagem.

type Delivery = {
  id: string;
  orderId: string;
};

type Acknowledge = (delivery: Delivery) => Promise<void>;
type ApplyEffect = (delivery: Delivery) => Promise<"applied" | "duplicate">;

async function consume(
  delivery: Delivery,
  applyEffect: ApplyEffect,
  acknowledge: Acknowledge,
) {
  const result = await applyEffect(delivery);
  await acknowledge(delivery);
  return result;
}

O consume não captura a falha do ack para chamar o efeito de novo dentro do mesmo processo. A decisão de repetir fica com o mecanismo de entrega. Isso mantém a responsabilidade separada: o broker decide se a mensagem volta; o consumidor decide se o mesmo efeito é seguro.

Uma fixture em memória pode representar a operação idempotente com um conjunto de IDs já aplicados:

class FakeEffects {
  private readonly applied = new Set<string>();
  readonly writes: string[] = [];

  async applyOnce(delivery: Delivery): Promise<"applied" | "duplicate"> {
    if (this.applied.has(delivery.id)) return "duplicate";

    this.writes.push(delivery.orderId);
    this.applied.add(delivery.id);
    return "applied";
  }
}

Esse Set é uma fixture, não um armazenamento de produção. Em um sistema real, o registro do ID e o efeito precisam obedecer à transação, restrição única ou chave idempotente adequada ao recurso. O ponto do exemplo é deixar observável a decisão que o teste precisa verificar.

Como forçar a reentrega depois do efeito?

Faça a primeira confirmação lançar um erro controlado. Depois execute a mesma entrega novamente com o mesmo ID. O caso passa somente se o segundo consumo reconhecer a operação já aplicada e se a lista de escritas continuar com um item.

import assert from "node:assert/strict";
import test from "node:test";

test("redelivery after a lost ack does not repeat the effect", async () => {
  const effects = new FakeEffects();
  let acknowledgements = 0;
  const acknowledge: Acknowledge = async () => {
    acknowledgements += 1;
    if (acknowledgements === 1) {
      throw new Error("connection lost after the effect");
    }
  };

  const delivery = { id: "delivery-1", orderId: "order-1" };
  const firstAttempt = consume(delivery, effects.applyOnce.bind(effects), acknowledge);

  await assert.rejects(firstAttempt, /connection lost/);
  const secondAttempt = await consume(
    delivery,
    effects.applyOnce.bind(effects),
    acknowledge,
  );

  assert.equal(secondAttempt, "duplicate");
  assert.deepEqual(effects.writes, ["order-1"]);
  assert.equal(acknowledgements, 2);
});

O exemplo usa node:test, que o Node.js documenta como seu test runner nativo, e não usa sleep, setTimeout ou uma corrida acidental. Execute-o como um arquivo TypeScript depois de transpilar ou adapte a sintaxe para .mjs:

node --test queue-worker.test.mjs

Para conferir que a asserção alcança o risco, remova temporariamente a deduplicação de FakeEffects e execute o caso de novo. A lista deve receber order-1 duas vezes. Um teste que continua verde depois dessa remoção não está medindo o efeito que importa.

Quais outros caminhos entram na matriz?

O caso do ack perdido é o centro da suíte, mas não é o único caminho. Mantenha cada cenário ligado a uma decisão observável:

  1. Falha antes do efeito. Faça o handler lançar antes de escrever e confirme que nenhuma gravação ocorreu. Se a falha for transitória, a próxima entrega deve poder tentar de novo.
  2. Falha depois de uma parte do efeito. Separe uma escrita parcial de uma operação completa. O teste deve provar rollback, compensação ou idempotência, conforme o contrato real.
  3. Payload inválido. Entregue schema quebrado, versão desconhecida ou ID ausente. A mensagem não deve atravessar o handler de negócio.
  4. Retry que esgota. Simule tentativas suficientes para a política de dead-letter e verifique o motivo e o ID registrado.
  5. Concorrência. Inicie duas tentativas para o mesmo ID com uma barreira controlada. Verifique que a reserva do efeito é atômica na fronteira que você pretende proteger.

Esse último caso não é resolvido pelo Set se houver mais de um processo. Use a fixture de condição de corrida em JavaScript para controlar a ordem do algoritmo e depois teste a disputa no banco, cache ou broker que realmente coordena os workers.

O que precisa permanecer real no teste de integração?

O teste em memória é rápido e explica a política do consumidor. Ele não prova a transação do banco, a visibilidade do ack, o timeout de lease, a reentrega do broker ou a recuperação depois que o processo morre. Essas perguntas pedem uma camada de integração separada.

Use um broker e um armazenamento reais em poucos cenários bem escolhidos:

  • publique uma mensagem com um ID conhecido;
  • faça o worker persistir o efeito e encerre o processo antes do ack;
  • reinicie o consumidor e aguarde a redelivery;
  • verifique o estado final e a quantidade de efeitos;
  • confirme que a mensagem inválida foi para o caminho previsto.

Não use sleep(2000) como prova de que o worker terminou. Espere uma condição observável com um deadline único ou use uma API do broker que informe o estado. Um teste que dorme pode passar sem encontrar a mensagem, acordar tarde demais ou mascarar um worker que nunca encerrou.

O teste de integração também deve registrar qual parte ficou real. Uma suíte pode usar o broker real com um efeito falso, ou o banco real com um transporte controlado. Cada combinação responde a uma pergunta diferente. Não chame o resultado de teste end-to-end se a fronteira decisiva continua substituída.

Falsos sinais de segurança

Confirmar antes de aplicar o efeito. Isso reduz algumas redeliveries, mas abre a perda de trabalho quando o processo cai depois do ack e antes da operação. A ordem precisa seguir o contrato de durabilidade que você escolheu.

Deduplicar apenas na memória do worker. O segundo worker pode não compartilhar o mesmo conjunto. Use uma identidade persistente ou uma garantia de exclusão que cubra o conjunto de processos envolvidos.

Contar apenas mensagens recebidas. Receber uma mensagem duas vezes não é, por si só, um erro. O erro é produzir dois efeitos quando o contrato exige um só. Conte a saída observável.

Repetir qualquer erro até o timeout. Erro de schema, autorização ou versão desconhecida não fica correto com mais tentativas. Classifique falhas transitórias e permanentes antes de escolher retry ou dead-letter.

Chamar o broker de exatamente uma vez. A documentação de serviços de fila costuma expor entrega pelo menos uma vez, e a aplicação precisa lidar com a possibilidade de repetição. O resultado pode ser um efeito idempotente, não uma promessa de transporte mágico.

Checklist para o CI

Antes de colocar o worker no pipeline, confira:

  • o teste força a falha do ack depois do efeito;
  • a mesma mensagem volta com o mesmo ID;
  • o efeito observável aparece uma vez;
  • uma falha antes do efeito pode ser repetida quando o contrato permite;
  • schema e versões desconhecidas falham antes do handler;
  • a deduplicação cobre os processos que podem concorrer;
  • retry, backoff e dead-letter têm casos separados;
  • o caminho de integração usa um deadline e uma condição observável;
  • o teste identifica o que ficou falso e o que ficou real;
  • o armazenamento real prova a atomicidade que a fixture em memória não consegue provar.

Se o sistema usa mensagens para disparar uma operação de agente ou uma tool, aplique a mesma matriz ao efeito externo. O teste deve continuar verificando o ID da operação, a aprovação necessária e a ausência de duplicação. A presença de um modelo não muda a janela entre o efeito e o ack.

Conclusão

Um worker de fila pode funcionar na primeira entrega e ainda duplicar o trabalho quando perde o ack. O teste que encontra esse defeito força o efeito a terminar, quebra a confirmação e entrega a mesma mensagem novamente. Depois, conta o efeito de negócio em vez de contar somente chamadas ao broker.

A fixture em memória torna a política rápida e determinística. O teste de integração ainda precisa atravessar o broker, o armazenamento e a recuperação do processo. Essas duas camadas não competem. Uma explica a decisão do consumidor; a outra verifica se a infraestrutura mantém essa decisão quando a falha acontece.

Como este artigo foi produzido

Samuel Fajreldines é o autor responsável por este texto. A pesquisa combinou a documentação oficial do Amazon SQS, RabbitMQ e Node.js com resultados atuais e discussões públicas sobre redelivery, leases e deduplicação. A fixture foi executada localmente em Node.js v25.5.0 com estado em memória. Ela não usa broker, banco ou serviço externo, portanto não mede recuperação distribuída.

Usei assistência de IA para organizar fontes, comparar a cobertura existente e revisar a consistência da matriz. A assistência não executou um worker de produção, não mediu latência e não substitui a verificação das garantias do broker escolhido.

Fontes consultadas