Una prueba de un worker puede pasar aunque el mensaje vaya a procesarse dos veces. Basta con probar solo la primera entrega: el handler termina, crea el efecto y el caso comprueba un ack correcto. La ventana peligrosa aparece después de confirmar el efecto, cuando el worker muere antes de confirmar la entrega al broker.

Para probar esa ventana, haz que el ack falle después del efecto, entrega el mismo mensaje otra vez y cuenta el efecto de negocio. La prueba debe demostrar que puede haber redelivery mientras el efecto sigue siendo único. Eso es distinto de probar una función de retry o contar cuántos mensajes recibió el consumidor.

Este artículo se conecta con la guía sobre ejecución durable con colas y checkpoints. Allí se explica cómo mantener vivo el trabajo. Aquí, la pregunta es cómo probar un consumidor cuando la entrega puede volver. La frontera del outbox transaccional cubre la publicación; esta prueba cubre el efecto del consumidor.

No presento un benchmark de RabbitMQ, SQS ni de otro broker. Ejecuté la fixture local con Node.js v25.5.0 y node:test. Usa estado en memoria para controlar el fallo del ack. No demuestra atomicidad de una base de datos, comportamiento entre procesos ni la política de un proveedor concreto.

Diagrama que muestra un mensaje entregado, un efecto producido, un ack fallido y una nueva entrega sin repetir el efecto.

Respuesta breve

  • El worker debe aplicar el efecto antes de confirmar el ack.
  • La prueba debe forzar el fallo entre esos pasos y entregar otra vez el mismo mensaje.
  • El consumidor o la operación de negocio debe reconocer el mismo ID sin crear un segundo efecto.
  • Una prueba en memoria demuestra la decisión del código, pero no sustituye una prueba con el broker y el almacenamiento reales.

¿Por qué no basta con la primera prueba verde?

Las colas distribuidas suelen separar la entrega de la confirmación. Amazon SQS documenta la entrega al menos una vez: en una condición poco frecuente, una copia del mensaje puede aparecer de nuevo. La misma documentación recomienda que el procesamiento sea idempotente.

RabbitMQ describe una frontera parecida en su documentación sobre confirmaciones del consumidor. El consumidor confirma que procesó la entrega; los fallos de conexión y el requeue pueden hacer que el mensaje vuelva. El broker no sabe si la aplicación creó el efecto antes de que el proceso terminara.

Por eso, una prueba que solo comprueba ack responde una pregunta estrecha: ¿el código envió una confirmación? No responde si la operación puede repetirse después de persistir el efecto. La aserción más importante debe observar el pedido, registro, email u otro efecto que no puede ocurrir dos veces.

La prueba más informativa no empieza con un retry. Empieza perdiendo el ack después del efecto. Ese orden imita el fallo que convierte una operación terminada en una entrega que parece pendiente.

¿Qué contrato debe probar el consumidor?

Escribe el contrato antes de la fixture. Para cada entrega, define cuándo el worker puede confirmar, cuándo debe pedir un retry y qué situación va a una dead-letter queue. Un consumidor no debe usar el mismo resultado para un error transitorio, un payload inválido y un efecto ya aplicado.

Escenario Preparación Qué verifica la prueba
Entrega nueva el ID aún no fue procesado un efecto y un ack
Ack perdido el efecto termina y el ack lanza un error la entrega vuelve y el efecto no se repite
Fallo antes del efecto el handler falla antes de persistir no hay efecto y el mensaje puede reintentarse
Payload inválido se rechaza el schema o la versión falla sin efecto y existe una decisión explícita de descarte
Mismo ID en dos workers dos intentos se solapan una operación acepta el ID y la otra no crea otro efecto
Error permanente la dependencia devuelve un fallo no recuperable el worker no reintenta para siempre

El ID del mensaje y el ID del efecto pueden ser distintos. Un evento puede entregarse dos veces, mientras que dos mensajes diferentes pueden representar la misma operación de negocio. La regla de deduplicación debe decir qué identidad protege cada efecto.

¿Cómo construir una fixture sin esperar al reloj?

Empieza con un consumidor pequeño. El worker ejecuta el efecto, confirma la entrega solo después de que termine y deja escapar el error del ack para que el mecanismo de entrega pueda repetir el mensaje.

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;
}

consume no captura el fallo del ack para llamar otra vez al efecto en el mismo proceso. El mecanismo de entrega decide el retry. Así se separan las responsabilidades: el broker decide si vuelve el mensaje; el consumidor decide si el mismo efecto es seguro.

Una fixture en memoria puede representar una operación idempotente con un conjunto de IDs ya 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";
  }
}

Este Set es una fixture, no un almacenamiento de producción. En un sistema real, el registro del ID y el efecto necesitan la transacción, la restricción única o la clave de idempotencia adecuada para el recurso. El ejemplo hace visible la decisión que la prueba debe verificar.

¿Cómo forzar el redelivery después del efecto?

Haz que el primer ack lance un error controlado. Después ejecuta la misma entrega otra vez con el mismo ID. El caso solo pasa si el segundo consumo reconoce que la operación ya se aplicó y la lista de escrituras sigue teniendo un solo elemento.

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);
});

El ejemplo usa node:test, que Node.js documenta como su test runner nativo, y no usa sleep, setTimeout ni una carrera accidental. Ejecútalo como TypeScript después de transpilar o adapta la sintaxis a .mjs:

node --test queue-worker.test.mjs

Para comprobar que la aserción alcanza el riesgo, elimina temporalmente la deduplicación de FakeEffects y vuelve a ejecutar el caso. La lista debería recibir order-1 dos veces. Una prueba que sigue verde después de quitar esa protección no está midiendo el efecto que importa.

¿Qué otros caminos entran en la matriz?

El caso del ack perdido es el centro de la suite, pero no es el único camino. Mantén cada escenario unido a una decisión observable:

  1. Fallo antes del efecto. Haz que el handler falle antes de escribir y confirma que no hubo escritura. Si el fallo es transitorio, la siguiente entrega debe poder reintentarlo.
  2. Fallo después de una parte del efecto. Separa una escritura parcial de una operación completa. La prueba debe demostrar rollback, compensación o idempotencia según el contrato real.
  3. Payload inválido. Entrega un schema roto, una versión desconocida o un ID ausente. El mensaje no debe entrar al handler de negocio.
  4. Retry agotado. Simula suficientes intentos para la política de dead-letter y verifica el motivo y el ID registrados.
  5. Concurrencia. Inicia dos intentos para el mismo ID con una barrera controlada. Comprueba que la reserva del efecto es atómica en la frontera que quieres proteger.

El último caso no se resuelve con un Set cuando participan varios procesos. Usa la fixture determinista para condiciones de carrera en JavaScript para controlar el orden del algoritmo y después prueba la base de datos, el cache o el broker que coordina los workers.

¿Qué debe permanecer real en la prueba de integración?

La prueba en memoria es rápida y explica la política del consumidor. No demuestra la transacción de la base de datos, la visibilidad del ack, el timeout del lease, el redelivery del broker ni la recuperación después de que muera el proceso. Esas preguntas requieren una capa de integración separada.

Usa un broker y un almacenamiento reales en unos pocos casos bien elegidos:

  • publica un mensaje con un ID conocido;
  • deja que el worker persista el efecto y detén el proceso antes del ack;
  • reinicia el consumidor y espera el redelivery;
  • verifica el estado final y la cantidad de efectos;
  • confirma que un mensaje inválido siguió el camino esperado.

No uses sleep(2000) como prueba de que el worker terminó. Espera una condición observable con un único deadline o usa una API del broker que informe el estado. Una prueba que duerme puede pasar sin encontrar el mensaje, despertarse demasiado tarde u ocultar un worker que nunca terminó.

La prueba de integración también debe registrar qué parte permaneció real. Una suite puede usar el broker real con un efecto falso, o la base real con un transporte controlado. Cada combinación responde una pregunta diferente. No la llames end to end si la frontera decisiva sigue sustituida.

Falsas señales de seguridad

Confirmar antes de aplicar el efecto. Reduce algunos redeliveries, pero abre una ventana de pérdida si el proceso muere después del ack y antes de la operación. El orden debe seguir el contrato de durabilidad elegido.

Deduplicar solo en la memoria del worker. El segundo worker puede no compartir el mismo conjunto. Usa una identidad persistente o una garantía de exclusión que cubra todos los procesos que pueden competir.

Contar solo los mensajes recibidos. Recibir un mensaje dos veces no es automáticamente un error. El error es producir dos efectos cuando el contrato exige uno. Cuenta la salida observable.

Reintentar cualquier error hasta el timeout. Los errores de schema, autorización o versión desconocida no se corrigen con más intentos. Clasifica fallos transitorios y permanentes antes de elegir retry o dead-letter.

Llamar al broker exactamente una vez. La documentación de los servicios de colas suele exponer entrega al menos una vez, por lo que la aplicación debe manejar la repetición. El resultado puede ser un efecto idempotente, no una garantía mágica del transporte.

Checklist para CI

Antes de añadir el worker al pipeline, comprueba que:

  • la prueba fuerza el fallo del ack después del efecto;
  • el mismo mensaje vuelve con el mismo ID;
  • el efecto observable aparece una vez;
  • un fallo antes del efecto puede reintentarse cuando el contrato lo permite;
  • los schemas y versiones desconocidas fallan antes del handler;
  • la deduplicación cubre los procesos que pueden competir;
  • retry, backoff y dead-letter tienen casos separados;
  • el camino de integración usa un único deadline y una condición observable;
  • la prueba identifica qué era falso y qué era real;
  • el almacenamiento real demuestra la atomicidad que la fixture en memoria no puede demostrar.

Si los mensajes activan una operación de agente o una tool, aplica la misma matriz al efecto externo. La prueba debe verificar el ID de operación, cualquier aprobación requerida y la ausencia de duplicación. Un modelo no cambia la ventana entre el efecto y el ack.

Conclusión

Un worker de cola puede funcionar en su primera entrega y aun así duplicar el trabajo cuando pierde el ack. La prueba que encuentra el defecto deja terminar el efecto, rompe la confirmación y entrega otra vez el mismo mensaje. Después cuenta el efecto de negocio, no solo las llamadas al broker.

La fixture en memoria hace que la política del consumidor sea rápida y determinista. La prueba de integración todavía debe cruzar el broker, el almacenamiento y la recuperación del proceso. Las capas no compiten. Una explica la decisión del consumidor; la otra comprueba si la infraestructura la conserva cuando ocurre el fallo.

Cómo se produjo este artículo

Samuel Fajreldines es responsable de este artículo. La investigación combinó la documentación oficial de Amazon SQS, RabbitMQ y Node.js con resultados actuales y discusiones públicas sobre redelivery, leases y deduplicación. La fixture se ejecutó localmente con Node.js v25.5.0 y estado en memoria. No usa broker, base de datos ni servicios externos, por lo que no mide recuperación distribuida.

Usé asistencia de IA para organizar las fuentes, comparar la cobertura existente y revisar la consistencia de la matriz. La asistencia no ejecutó un worker de producción, no midió latencia y no sustituyó la verificación de las garantías del broker elegido.

Fuentes consultadas