Saltar al contenido

Patrones de procesamiento de trabajos en segundo plano

No todo pertenece al ciclo de solicitud-respuesta: colas, workers, reintentos y patrones de dead letter para un procesamiento en segundo plano fiable.

3 min de lectura
Diagrama de arquitectura de cola de trabajos que muestra productores, cola, workers y cola de dead letter

Enviar correos de confirmación, generar informes en PDF, procesar imágenes subidas, sincronizar datos con APIs de terceros: nada de esto pertenece al ciclo de solicitud-respuesta. Son operaciones lentas, poco fiables, y su fallo no debería bloquear la acción del usuario. El procesamiento de trabajos en segundo plano traslada este trabajo a un pipeline separado donde puede reintentarse, limitarse y monitorizarse de forma independiente.

¿Por qué no usar simplemente setTimeout?

tstypescript
// ❌ "Background" processing that isn't actually reliable
app.post("/api/orders", async (req, res) => {
  const order = await createOrder(req.body);
 
  // This runs in the same process — if the server crashes, the email is lost
  setTimeout(async () => {
    await sendConfirmationEmail(order);
    await updateInventory(order);
    await notifyWarehouse(order);
  }, 0);
 
  res.json(order);
});

Si el servidor se reinicia, esos callbacks desaparecen. Sin reintentos, sin visibilidad, sin garantía de que el trabajo se complete. Una cola de trabajos adecuada persiste el trabajo y sobrevive a los fallos.

El patrón de cola de trabajos

Una cola de trabajos tiene tres componentes: productores (encolan trabajo), un almacén persistente (guarda los trabajos) y consumidores/workers (procesan los trabajos).

tstypescript
// Producer: enqueue a job after the main operation
import { Queue } from "bullmq";
import { Redis } from "ioredis";
 
const connection = new Redis(process.env.REDIS_URL);
const emailQueue = new Queue("email", { connection });
 
app.post("/api/orders", async (req, res) => {
  const order = await createOrder(req.body);
 
  // Job is persisted in Redis — survives server restarts
  await emailQueue.add("order-confirmation", {
    orderId: order.id,
    customerEmail: order.customerEmail,
    orderTotal: order.total,
  });
 
  res.json(order);
});
tstypescript
// Consumer: process jobs from the queue
import { Worker } from "bullmq";
 
const emailWorker = new Worker(
  "email",
  async (job) => {
    switch (job.name) {
      case "order-confirmation":
        await sendConfirmationEmail(
          job.data.customerEmail,
          job.data.orderId,
          job.data.orderTotal,
        );
        break;
      case "shipping-notification":
        await sendShippingEmail(job.data);
        break;
    }
  },
  {
    connection,
    concurrency: 5, // Process 5 jobs simultaneously
  },
);
 
emailWorker.on("completed", (job) => {
  console.log(`Job ${job.id} completed`);
});
 
emailWorker.on("failed", (job, error) => {
  console.error(`Job ${job?.id} failed:`, error.message);
});

Estrategias de reintento

Los trabajos fallan. Las APIs agotan su tiempo de espera, los servicios se caen, se alcanzan los límites de tasa. Una buena estrategia de reintento gestiona los fallos transitorios sin empeorar los fallos permanentes.

tstypescript
// Exponential backoff with jitter
await emailQueue.add(
  "order-confirmation",
  { orderId: order.id },
  {
    attempts: 5,
    backoff: {
      type: "exponential",
      delay: 2000, // 2s, 4s, 8s, 16s, 32s
    },
  },
);
tstypescript
// ❌ Retrying non-retryable errors wastes resources
async function processJob(job: Job) {
  try {
    await callExternalAPI(job.data);
  } catch (error) {
    // Retrying a 400 Bad Request will never succeed
    throw error; // All errors get retried
  }
}
 
// ✅ Distinguish retryable from permanent failures
async function processJob(job: Job) {
  try {
    await callExternalAPI(job.data);
  } catch (error) {
    if (error.status === 429 || error.status >= 500) {
      throw error; // Retryable — network/server issue
    }
    // Permanent failure — don't retry, move to dead letter
    throw new UnrecoverableError(error.message);
  }
}
Tipo de errorEjemploAcción
TransitorioTimeout, 503, conexión rechazadaReintentar con backoff
Límite de tasa429 Too Many RequestsReintentar con una espera mayor
Permanente400 Bad Request, datos inválidosCola de dead letter
BugTypeError no capturadoCola de dead letter + alerta

Colas de dead letter

Cuando un trabajo agota todos sus reintentos, pasa a una cola de dead letter para su investigación. Nunca descartes silenciosamente los trabajos fallidos.

tstypescript
// Configure dead letter queue behavior
const orderQueue = new Queue("orders", {
  connection,
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: "exponential", delay: 2000 },
    removeOnComplete: { age: 86400 }, // Keep completed for 24h
    removeOnFail: false, // Never auto-delete failed jobs
  },
});
 
// Monitor dead letter jobs
async function getDeadLetterJobs() {
  const failed = await orderQueue.getFailed(0, 100);
  return failed.map((job) => ({
    id: job.id,
    name: job.name,
    data: job.data,
    failedReason: job.failedReason,
    attemptsMade: job.attemptsMade,
    timestamp: job.timestamp,
  }));
}

Programación de trabajos

Algunos trabajos deben ejecutarse según un horario: informes diarios, sincronizaciones cada hora, limpiezas periódicas.

tstypescript
// Repeatable jobs with cron expressions
await reportQueue.add(
  "daily-revenue-report",
  {}, // Data can be empty for scheduled jobs
  {
    repeat: {
      pattern: "0 8 * * *", // Every day at 8 AM
      tz: "America/New_York",
    },
  },
);
 
await cleanupQueue.add(
  "expire-old-sessions",
  {},
  {
    repeat: {
      pattern: "*/15 * * * *", // Every 15 minutes
    },
  },
);

Idempotencia

Los trabajos pueden entregarse más de una vez (entrega at-least-once). Cada manejador de trabajos debe producir el mismo resultado tanto si se ejecuta una vez como diez.

tstypescript
// ❌ Not idempotent — charging the customer multiple times
async function processPayment(job: Job) {
  await stripe.charges.create({
    amount: job.data.amount,
    customer: job.data.customerId,
  });
}
 
// ✅ Idempotent — uses an idempotency key to prevent duplicates
async function processPayment(job: Job) {
  await stripe.charges.create(
    {
      amount: job.data.amount,
      customer: job.data.customerId,
    },
    {
      idempotencyKey: `order-${job.data.orderId}`,
    },
  );
}

Usa identificadores únicos (ID de pedido, ID de trabajo) como claves de idempotencia. Comprueba si el trabajo ya se hizo antes de volver a hacerlo.

Conclusiones clave

  1. Saca el trabajo lento y poco fiable del ciclo de solicitud y llévalo a trabajos en segundo plano
  2. Persiste los trabajos en un almacén duradero (Redis, PostgreSQL): el procesamiento en memoria no es fiable
  3. Reintenta con backoff exponencial los fallos transitorios y usa dead letter para los permanentes
  4. Cada manejador de trabajos debe ser idempotente: los trabajos pueden entregarse más de una vez
  5. Nunca descartes silenciosamente los trabajos fallidos: las colas de dead letter los conservan para su investigación
  6. Monitoriza la profundidad de la cola y la latencia de procesamiento: las colas que crecen señalan problemas de capacidad
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX