Skip to content

Diseñando jobs en segundo plano idempotentes: reintentos, locks y la ilusión del exactly-once

El procesamiento exactly-once no existe en sistemas distribuidos: así se diseñan jobs en segundo plano que sobreviven reintentos, caídas y entregas duplicadas.

Publicado el 26 de agosto de 20266 min de lectura
Diagrama que muestra un job en segundo plano siendo reintentado varias veces mientras una base de datos aplica idempotencia mediante una restricción de unicidad

Todos los sistemas de colas que vas a usar —SQS, RabbitMQ, BullMQ, Sidekiq, Pub/Sub— prometen entrega "at-least-once" (al menos una vez). Ninguno promete exactly-once, porque la entrega exactly-once en un sistema distribuido es demostrablemente imposible sin la cooperación del consumidor. La red puede perder un ack. Un worker puede caerse después de hacer el trabajo pero antes de confirmarlo. Un reintento puede disparase mientras el intento original todavía está corriendo.

La verdad incómoda: la garantía de entrega de tu cola no es tu problema. El comportamiento de tu handler de jobs ante ejecución duplicada, sí lo es. Si diseñas asumiendo "esto va a correr más de una vez, a veces de forma concurrente", el comportamiento exactly-once se vuelve alcanzable aunque la entrega exactly-once nunca lo sea.

Por qué los reintentos siempre generan duplicados

El ciclo de vida típico de un job es: sacar el mensaje → procesar → confirmar (ack). El modo de falla que rompe los handlers ingenuos es el hueco entre "procesar" y "confirmar".

tstypescript
// ❌ Mal: el trabajo y la confirmación no son atómicos
async function processPayment(job: Job<PaymentPayload>) {
  await chargeCard(job.data.customerId, job.data.amountCents);
  await sendReceiptEmail(job.data.customerId);
  // If the process crashes here, or the ack never reaches the broker,
  // the queue redelivers this job — and the customer gets charged twice.
}

Si el worker muere justo después de que chargeCard tuvo éxito pero antes de confirmar el mensaje, la mayoría de los brokers van a reenviar el mensaje a otro worker. Ese worker no tiene idea de que el cobro ya se hizo. Esto no es un caso límite hipotético: es el comportamiento por defecto de cualquier cola at-least-once bajo carga, despliegues y particiones de red.

Claves de idempotencia: la base

La solución es darle a cada job una identidad estable y registrar esa identidad antes de hacer cualquier cosa irreversible. Es el mismo patrón que se usa para requests de API idempotentes, aplicado al procesamiento en segundo plano.

sqlsql
-- ✅ Bien: una tabla de dedupe respaldada por una restricción de unicidad
CREATE TABLE job_executions (
  idempotency_key TEXT PRIMARY KEY,
  job_type TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'processing',
  result JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ
);
tstypescript
// ✅ Bien: reclamar la clave atómicamente antes de generar efectos secundarios
async function processPayment(job: Job<PaymentPayload>) {
  const key = job.data.idempotencyKey; // generated once, at job creation time
 
  const claimed = await db.query(
    `INSERT INTO job_executions (idempotency_key, job_type)
     VALUES ($1, 'process_payment')
     ON CONFLICT (idempotency_key) DO NOTHING
     RETURNING idempotency_key`,
    [key],
  );
 
  if (claimed.rowCount === 0) {
    // Already claimed by this run or a previous attempt.
    const existing = await db.query(
      `SELECT status, result FROM job_executions WHERE idempotency_key = $1`,
      [key],
    );
    if (existing.rows[0].status === "completed") {
      return existing.rows[0].result; // safe to no-op, work already done
    }
    throw new RetryableError("Job already in progress, back off and retry");
  }
 
  const result = await chargeCard(job.data.customerId, job.data.amountCents);
  await db.query(
    `UPDATE job_executions SET status = 'completed', result = $2, completed_at = now()
     WHERE idempotency_key = $1`,
    [key, result],
  );
  await sendReceiptEmail(job.data.customerId, result);
  return result;
}

El INSERT ... ON CONFLICT DO NOTHING es la línea que sostiene todo esto. Convierte "¿este job ya corrió?" de una condición de carrera a una única operación atómica de base de datos. La restricción de unicidad hace el trabajo que las validaciones a nivel de aplicación no pueden hacer de forma segura bajo concurrencia.

!

La clave de idempotencia debe generarse una sola vez, en el momento de creación del job, y viajar junto con el payload. Si generás una clave nueva en cada reintento, arruinaste todo el mecanismo.

Los locks resuelven un problema distinto al de la idempotencia

Es tentador recurrir a un lock distribuido (Redis SETNX, locks de advisory de Postgres, ZooKeeper) y asumir que con eso alcanza. No es así. Un lock previene la ejecución concurrente; no hace nada contra la re-ejecución secuencial después de una caída.

tstypescript
// ❌ Mal: un lock solo detiene la concurrencia, no los reintentos
async function runNightlyReport(jobId: string) {
  const lockKey = `lock:report:${jobId}`;
  const acquired = await redis.set(lockKey, "1", "NX", "EX", 300);
  if (!acquired) return; // another worker is already on it
 
  await generateAndSendReport(jobId);
  await redis.del(lockKey);
  // If the worker crashes after generateAndSendReport but before del,
  // the lock expires after 300s and the NEXT retry runs the whole thing again.
}

Los locks y las claves de idempotencia resuelven problemas complementarios:

MecanismoPrevieneNo previene
Lock distribuidoQue dos workers procesen el mismo job simultáneamenteQue el mismo job se reprocese después de una caída o reintento
Clave de idempotencia + restricción de unicidadQue la misma operación lógica se reprocese, jamásQue dos workers compitan brevemente antes de que la restricción se resuelva
Ambos combinadosEjecución concurrente y efectos secundarios duplicadosNada relevante — esta es la solución real

Usá locks para reducir trabajo desperdiciado y contención (¿por qué dejar que dos workers compitan para hacer el mismo job cuando uno puede replegarse de inmediato?). Usá claves de idempotencia para garantizar corrección incluso cuando el lock falla, expira o se evita. Nunca trates un lock como una garantía de corrección por sí solo: la simple expiración por TTL hace que eso sea inseguro.

Hacer que los efectos secundarios sean idempotentes en sí mismos

Registrar que un job corrió no alcanza si el efecto secundario —un email, un webhook, una llamada a una API de terceros— no es idempotente por su cuenta. Envolver una acción no idempotente en una verificación de idempotencia solo te protege si esa verificación ocurre antes de la acción, de forma atómica.

tstypescript
// ✅ Bien: empujar la idempotencia hacia la llamada externa cuando la API lo soporta
async function chargeCard(customerId: string, amountCents: number) {
  return stripe.charges.create(
    { customer: customerId, amount: amountCents, currency: "usd" },
    { idempotencyKey: `charge:${customerId}:${amountCents}:${dayBucket()}` },
  );
}

La mayoría de los procesadores de pago, proveedores de email y receptores de webhooks modernos soportan sus propias claves de idempotencia: usalas. Esto te da dos capas de protección independientes: tu tabla de dedupe de jobs, y la deduplicación propia del sistema downstream. Cuando no controlás el sistema downstream (digamos, un endpoint SOAP legacy sin soporte de idempotencia), el patrón de outbox transaccional se vuelve necesario: escribís la intención en una tabla de base de datos dentro de la misma transacción que tu lógica de negocio, y un proceso relay separado se encarga de entregarla exactamente una vez, rastreando el estado de entrega.

Manejando el estado limbo de "processing"

Un job que se cayó a mitad de ejecución deja tu fila de dedupe atascada en status = 'processing' para siempre, a menos que lo tengas en cuenta. No dejes que un claim obsoleto bloquee reintentos indefinidamente.

tstypescript
// ✅ Bien: expirar claims obsoletos para que los jobs caídos puedan reintentarse de forma segura
async function reclaimStaleJobs() {
  await db.query(`
    UPDATE job_executions
    SET status = 'failed'
    WHERE status = 'processing'
      AND created_at < now() - interval '10 minutes'
  `);
}

Elegí la ventana de obsolescencia según el peor caso realista de duración de tu job, no una suposición al azar. Si es demasiado corta, vas a reclamar jobs que todavía están corriendo legítimamente, causando duplicados reales de efectos secundarios. Si es demasiado larga, un job caído bloquea el progreso más tiempo del necesario.

Testear la idempotencia, no solo la corrección

Los tests unitarios estándar verifican que un job produce el resultado correcto una vez. No verifican qué pasa cuando lo corrés dos veces, o dos veces de forma concurrente. Agregá eso explícitamente:

tstypescript
test("processing the same job twice produces one charge", async () => {
  const payload = { idempotencyKey: "test-key-1", customerId: "cus_123", amountCents: 500 };
 
  await Promise.all([
    processPayment({ data: payload } as Job<PaymentPayload>),
    processPayment({ data: payload } as Job<PaymentPayload>),
  ]);
 
  const charges = await getChargesForCustomer("cus_123");
  expect(charges).toHaveLength(1);
});

Si este test no está en tu suite para cada job que toca dinero, inventario, o llamadas externas irreversibles, debería estarlo. Detecta exactamente la clase de bug que solo aparece en producción, bajo carga, meses después de que el código fue escrito.

Puntos clave

  1. La entrega exactly-once no existe: diseñá handlers de jobs que se comporten correctamente bajo entrega at-least-once.
  2. Usá una restricción de unicidad sobre una clave de idempotencia como el filtro atómico antes de que corra cualquier efecto secundario irreversible.
  3. Los locks reducen la contención y el trabajo desperdiciado; no garantizan corrección después de una caída. No confundas las dos cosas.
  4. Empujá la idempotencia hacia los sistemas externos (procesadores de pago, receptores de webhooks) cuando soporten sus propias claves de dedupe.
  5. Recuperá los claims obsoletos en "processing" con un timeout ajustado a la duración real del peor caso de tu job, o los jobs caídos nunca se reintentarán.
  6. Escribí tests explícitos que corran un job dos veces —una vez secuencial, una vez concurrente— y verifiquen que el efecto secundario ocurrió exactamente una vez.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX