Colas de trabajo: reintentos, idempotencia y DLQ explicados
DEV Community

Colas de trabajo: reintentos, idempotencia y DLQ explicados

TL;DR

Vas a entender la diferencia entre at-least-once, at-most-once y exactly-once en colas de trabajo. Vas a saber calcular un visibility timeout correcto para que un job lento no se procese dos veces. Vas a poder escribir un job idempotente con una clave de deduplicación en Redis o Postgres. Vas a configurar reintentos con backoff exponencial y una dead-letter queue en BullMQ y SQS. Vas a poder elegir entre SQS, RabbitMQ, Redis/BullMQ y Kafka según tu volumen y caso de uso. Vas a saber diagnosticar una cola atascada revisando profundidad, mensajes en vuelo y la DLQ. Vas a conocer el patrón outbox para evitar el problema de doble escritura entre tu base de datos y la cola.

Un job se ejecuta dos veces y tu sistema le cobra dos veces al mismo cliente. El código del worker no tiene ningún bug: el problema está en cómo diseñaste la cola. Las colas de trabajo (job queues) desacoplan quién pide un trabajo de quién lo ejecuta, pero esconden problemas difíciles: qué pasa si el worker se cae a mitad de proceso, cómo evitar procesar el mismo mensaje dos veces y qué hacer con un job que falla siempre. Acá está cada pieza explicada con código real, desde el productor hasta la dead-letter queue.

Qué es una cola de trabajo y por qué importa

Una cola de trabajo (job queue) es una pieza de infraestructura que recibe una tarea, la guarda y la entrega a un worker cuando hay capacidad para procesarla. La diferencia con llamar a una función directamente es que el que pide el trabajo (el productor) no espera a que termine: encola el mensaje y sigue. Quien lo procesa (el consumidor o worker) puede estar en otro proceso, otro servidor, o escalarse de forma independiente.

El caso típico: un usuario sube un video en tu app. Procesarlo (transcodificar, generar miniaturas, extraer subtítulos) puede tardar minutos. Si lo hacés dentro del mismo request HTTP, el usuario espera frente a una pantalla en blanco y cualquier timeout del navegador o del load balancer corta la conexión a mitad de camino. Con una cola de trabajo, el request HTTP responde en milisegundos y el trabajo pesado ocurre en background, en un worker que podés escalar horizontalmente según la carga.

Hasta acá suena simple. Lo difícil aparece cuando algo falla a mitad de camino: el worker se cae, la red se corta, el mensaje se entrega dos veces o nunca se resuelve. Ahí es donde la mayoría de las implementaciones caseras de colas de trabajo se rompen.

Cómo funciona una cola de trabajo por dentro

Productor, broker y consumidor

Toda cola de trabajo tiene tres roles. El productor crea el mensaje (el job) y lo envía al broker: por ejemplo, tu API cuando alguien sube un archivo. El broker es el sistema que guarda el mensaje de forma durable hasta que alguien lo procese: puede ser Redis, RabbitMQ, Amazon SQS o incluso una tabla de Postgres. El consumidor (worker) pide mensajes al broker, los procesa y le confirma que terminó. Esa confirmación se llama ack (acknowledgment). Es la pieza central de todo el sistema: si el worker no manda el ack, el broker asume que el trabajo no se completó y lo vuelve a entregar. Ese único mecanismo, entregar de nuevo cuando no hay ack, es la raíz de casi todos los problemas difíciles de las colas de trabajo.

flowchart TD
    A["Productor (API)"] --> B["Cola / broker"]
    B --> C["Worker"]
    C --> D[("Base de datos")]
    C -. "falla repetidamente" .-> E["Dead-letter queue"]

Reintentos y semánticas de entrega

Cuando un mensaje se reintenta, existen tres garantías posibles:

  • At-most-once: el mensaje se entrega una vez o ninguna, nunca se duplica, pero se puede perder si el worker se cae antes de procesarlo.
  • At-least-once: el mensaje se entrega una o más veces, nunca se pierde, pero puede duplicarse.
  • Exactly-once: el mensaje se procesa exactamente una vez, sin pérdidas ni duplicados.

La mayoría de los sistemas de colas en producción (SQS, RabbitMQ, BullMQ sobre Redis) ofrecen at-least-once por defecto porque es la garantía más fácil de sostener: para no perder nunca un mensaje, el broker prefiere reenviarlo de más antes que arriesgarse a perderlo. El costo es que tu worker va a recibir el mismo job dos veces alguna vez, y tiene que estar preparado para eso.

💭 Clave: exactly-once real en un sistema distribuido no existe sin coordinación adicional. Lo que sí existe, y alcanza en la práctica, es at-least-once entrega más procesamiento idempotente: el resultado neto es "efectivamente una vez".

Visibility timeout: la carrera contra el reloj

Cuando un worker toma un mensaje de la cola, la mayoría de los brokers no lo borran de inmediato: lo ocultan por un tiempo determinado, el visibility timeout, y esperan el ack. Si el ack llega antes de que expire ese tiempo, el mensaje se borra. Si no llega (el worker se cayó, tardó demasiado, perdió la conexión), el mensaje vuelve a quedar visible para que otro worker lo tome.

El error más común es fijar un visibility timeout más corto que el tiempo real de procesamiento. Si tu job de transcodificar video tarda 90 segundos pero el timeout es de 30, un segundo worker va a tomar el mismo mensaje mientras el primero todavía lo está procesando: los dos van a transcodificar el mismo video en paralelo y probablemente vas a terminar con dos cargos, dos emails o dos filas duplicadas en tu base de datos.

sequenceDiagram
    participant P as Productor
    participant Q as Cola
    participant W as Worker
    P->>Q: encola job (id=42)
    Q->>W: entrega job, inicia visibility timeout
    Note over W: procesando (puede tardar)
    W-->>Q: ack, borra el mensaje
    Q-->>P: job completado

⚠️ Ojo: el visibility timeout se calcula en base al peor caso de procesamiento, no al promedio. Si tu p99 de procesamiento es 90 segundos, un timeout de 30 va a duplicar trabajo constantemente.

Idempotencia: la defensa real contra los duplicados

Como at-least-once garantiza que un job puede llegar más de una vez, la única defensa confiable es hacer que procesarlo dos veces produzca el mismo resultado que procesarlo una sola vez. Eso es idempotencia: la propiedad de que aplicar una operación varias veces tiene el mismo efecto que aplicarla una.

En la práctica se implementa con una clave de deduplicación: antes de ejecutar el efecto secundario (cobrar, enviar un email, escribir una fila), el worker verifica si esa clave ya fue procesada. Si Redis o tu base de datos confirman que sí, el worker descarta el job sin volver a ejecutar el efecto. Esa clave suele construirse con el ID del job más algún dato del negocio, nunca con un timestamp: dos reintentos del mismo job no deberían generar dos claves distintas.

Dead-letter queues: el último recurso

No todos los jobs se pueden reintentar para siempre. Un mensaje corrupto, un payload con un bug de validación o una integración externa caída de forma permanente van a fallar en cada intento. Sin un límite, ese mensaje se reintenta infinitamente y consume workers sin producir nada útil: se lo conoce como mensaje envenenado (poison message).

La solución es fijar un número máximo de intentos (max receive count) y, al superarlo, mover el mensaje a una dead-letter queue (DLQ): una cola separada donde el mensaje queda visible para inspección manual en vez de seguir consumiendo ciclos de reintento. La DLQ no es un cementerio: es una bandeja de entrada que alguien tiene que revisar.

stateDiagram-v2
    [*] --> Visible
    Visible --> EnVuelo: worker lo toma
    EnVuelo --> Visible: timeout expira sin ack
    EnVuelo --> Completado: ack recibido
    EnVuelo --> DLQ: excede maxReceiveCount
    Completado --> [*]
    DLQ --> [*]

Ejemplos prácticos: de cero a un worker con reintentos e idempotencia

Vamos a construir esto de manera progresiva con BullMQ, una librería de colas sobre Redis para Node.js. Primero el mínimo indispensable: encolar y procesar un job.

// producer.js: encola un job minimo
import { Queue } from 'bullmq';

const emailQueue = new Queue('emails', {
  connection: { host: '127.0.0.1', port: 6379 },
});

await emailQueue.add('send-welcome', {
  userId: 1042,
  email: 'ana@example.com',
});

Esto crea una cola llamada emails en Redis y encola un job. BullMQ serializa el payload a JSON, le asigna un ID único y lo deja disponible para que un worker lo tome. Todavía no hay reintentos configurados ni idempotencia: si este job se procesa dos veces, se envían dos emails.

Ahora la versión realista: reintentos con backoff exponencial, idempotencia con Redis y manejo del evento de fallo definitivo.

// worker.js: procesa jobs con reintentos e idempotencia
import { Worker } from 'bullmq';
import Redis from 'ioredis';

const redis = new Redis();

const worker = new Worker(
  'emails',
  async (job) => {
    const dedupeKey = `email:sent:${job.data.userId}:${job.data.email}`;
    const isFirstTime = await redis.set(dedupeKey, '1', 'EX', 86400, 'NX');

    if (!isFirstTime) {
      return { skipped: true }; // ya se proceso este job antes
    }

    await sendWelcomeEmail(job.data.email);
  },
  {
    connection: { host: '127.0.0.1', port: 6379 },
    concurrency: 5,
  }
);

worker.on('failed', (job, err) => {
  console.error(`job ${job.id} fallo en intento ${job.attemptsMade}: ${err.message}`);
});

redis.set(..., 'NX') solo escribe la clave si no existe, de forma atómica. Eso convierte el "verificar y marcar" en una sola operación, sin condición de carrera entre dos workers que reciban el mismo job casi al mismo tiempo. La expiración de 24 horas evita que la clave crezca para siempre en Redis.

Para que los reintentos con backoff y el límite de intentos entren en juego, hay que configurarlos al encolar el job, no al procesarlo:

await emailQueue.add(
  'send-welcome',
  payload,
  {
    attempts: 5,
    backoff: { type: 'exponential', delay: 1000 },
    removeOnComplete: true,
    removeOnFail: false, // los jobs fallidos quedan visibles para inspeccionar
  }
);

Con attempts: 5 y backoff exponencial de 1 segundo, BullMQ reintenta a 1s, 2s, 4s, 8s y 16s antes de marcar el job como fallido definitivamente. removeOnFail: false deja esos jobs fallidos en la cola de failed, que funciona como dead-letter queue: podés inspeccionarlos con emailQueue.getFailed() y reintentarlos a mano una vez que arreglás la causa.

Cómo empezar: monta una cola local en minutos

Para probar todo lo anterior en tu máquina necesitás Redis corriendo y las dos librerías de Node.

docker run -d --name redis-queue -p 6379:6379 redis:7
npm install bullmq ioredis

Con Redis arriba, corré el archivo producer.js de arriba para encolar un job y después worker.js en otra terminal para procesarlo. BullMQ crea automáticamente las estructuras de datos que necesita en Redis (listas y sorted sets) la primera vez que usás la cola: no hace falta migración ni configuración adicional.

Verificar que funciona

Para confirmar que los jobs se están procesando y que la DLQ (la cola de fallidos) está vacía, podés consultar los contadores directamente:

const counts = await emailQueue.getJobCounts('active', 'completed', 'failed', 'delayed');
console.log(counts);
// { active: 0, completed: 12, failed: 0, delayed: 0 }

Si usás Amazon SQS en vez de Redis, el equivalente es consultar los atributos de la cola por CLI:

aws sqs get-queue-attributes \
  --queue-url $DLQ_URL \
  --attribute-names ApproximateNumberOfMessages

Y si usás RabbitMQ, rabbitmqctl list_queues name messages messages_unacknowledged muestra en una línea cuántos mensajes están pendientes y cuántos están en vuelo sin confirmar.

Casos de uso reales

Las colas de trabajo aparecen en casi cualquier sistema que separa responder rápido de hacer el trabajo pesado. Los casos más comunes:

  • Emails y notificaciones transaccionales: enviar el email de bienvenida o el push de "tu pedido salió" sin bloquear el request que los dispara.
  • Webhooks de pagos: procesar la confirmación de un pago de Stripe o Mercado Pago de forma asíncrona, con reintentos si tu servidor estuvo caído cuando llegó el webhook.
  • Procesamiento de medios: transcodificar video, generar miniaturas o extraer metadata de imágenes subidas por usuarios.
  • Generación de reportes y exports: armar un CSV o PDF grande sin mantener abierta una conexión HTTP de varios minutos.
  • Sincronización con sistemas externos: mandar datos a un CRM o un data warehouse con reintentos automáticos si la API de destino responde con error 500.

Errores comunes y buenas prácticas

El error más frecuente ya se mencionó: un visibility timeout menor al tiempo real de procesamiento, que genera duplicados constantes. Medí el p99 de tus jobs y fijá el timeout con margen, no el promedio.

El segundo error es no tener dead-letter queue configurada. Sin un maxReceiveCount ni una cola de fallidos, un mensaje envenenado consume workers en un bucle infinito y puede afectar el rendimiento de toda la cola para el resto de los jobs legítimos.

El tercero es guardar payloads grandes dentro del mensaje en vez de una referencia. Un job que carga un archivo de 50 MB directo en el mensaje satura el broker; lo correcto es subir el archivo a almacenamiento de objetos (S3, GCS) y encolar solo la URL o el ID.

El cuarto, menos obvio: tratar el orden de los mensajes como garantizado. Salvo que uses explícitamente una cola FIFO (SQS FIFO, o particiones ordenadas en Kafka), la mayoría de las colas estándar no garantizan que los mensajes se procesen en el orden en que se encolaron, sobre todo con varios workers en paralelo.

Por último: no monitorear la cola. La profundidad de la cola (cuántos mensajes esperan) y el tamaño de la DLQ son las dos métricas mínimas que necesitás alertar. Una DLQ que crece silenciosamente es trabajo que tu sistema dejó de hacer sin que nadie se entere.

Comparativa con alternativas

Opción Cuándo usarla Ventaja Limitación
Amazon SQS ya estás en AWS y querés cero infraestructura propia administrado, escala sola, DLQ nativa vendor lock-in, orden no garantizado en cola estándar
RabbitMQ necesitás enrutamiento complejo, prioridades o mensajes transaccionales maduro, flexible, protocolo AMQP operación más compleja, requiere mantenimiento
Redis / BullMQ querés simplicidad, baja latencia y ya tenés Redis en tu stack rápido, fácil de configurar, sin dependencias externas los datos se pierden si Redis no está persistido, sin DLQ nativa (hay que implementarla)
Kafka necesitás alto throughput, retención de mensajes y reprocesamiento histórico durable, ordenado por partición, replay de mensajes latencia más alta, overhead operativo, no es una cola tradicional (es un log)

Comments

No comments yet. Start the discussion.