Una cola no es un lujo: es la forma de sacar del camino crítico todo lo que no necesita responder al instante. En JXBS mando a SQS lo que puede esperar unos segundos: correos a candidatos, recálculo de matches, sincronizaciones. Lo que aprendí por las malas es que la parte difícil no es encolar, sino que el reintento no repita el trabajo. SQS entrega "al menos una vez", y ese "al menos" es exactamente donde se rompen las cosas.
Por qué encolo en vez de responder en línea
Cuando un reclutador publica una vacante, la respuesta HTTP no debería esperar a que se generen embeddings, se recalculen matches y se disparen notificaciones. Eso lo mando a una cola y devuelvo la respuesta de inmediato. El usuario percibe una app rápida; el trabajo pesado ocurre detrás, a su ritmo.
La cola me da tres cosas concretas. Absorbe picos: si entran cien vacantes de golpe, se procesan al ritmo que aguante el consumidor, no todas a la vez. Aísla fallos: si el proveedor de correo está caído, la tarea se reintenta sola sin tumbar la petición original. Y desacopla: el productor no sabe ni le importa quién consume.
El problema real: "al menos una vez"
SQS estándar garantiza entrega, pero no unicidad. El mismo mensaje puede llegar dos veces: porque el consumidor tardó más que el visibility timeout y SQS lo re-entregó, porque hubo un reintento de red, o porque el proceso murió justo después de trabajar pero antes de borrar el mensaje.
Si mi consumidor manda un correo por cada mensaje sin más, un candidato recibe dos. Si crea un cargo, cobro doble. La entrega duplicada no es un caso raro que ocurre una vez al año: es el comportamiento normal del sistema, y hay que diseñarlo asumiendo que pasará.
Idempotencia: la única defensa que escala
La solución no es evitar los duplicados, es hacer que procesarlos dos veces dé el mismo resultado que procesarlos una. Eso es idempotencia, y la implemento con una clave estable por unidad de trabajo.
CREATE TABLE processed_message (
idempotency_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
El consumidor, antes de trabajar, intenta insertar la clave. Si ya existe, el mensaje es un duplicado y lo descarto sin efectos.
async function handle(msg: Job) {
const inserted = await db.processedMessage
.create({ data: { idempotencyKey: msg.id } })
.catch(() => null); // choca con la PK si ya se procesó
if (!inserted) return; // duplicado: no hago nada
await doTheActualWork(msg);
}
La clave no la invento al azar: la derivo de la intención del mensaje. Para un correo de bienvenida es welcome-email:{candidateId}, no un UUID nuevo por cada intento. Así, dos mensajes que representan la misma acción colisionan y solo uno gana.
💡 Un reintento seguro no es el que nunca se repite; es el que puede repetirse sin que a nadie le importe.
Visibility timeout y la DLQ
Dos piezas de SQS hay que afinar. El visibility timeout debe ser mayor que el peor tiempo de proceso realista: si tardo más, SQS asume que fallé y re-entrega mientras yo sigo trabajando. Lo mido contra el p99 del consumidor, no contra el promedio.
Y la dead-letter queue: tras N intentos fallidos, el mensaje sale de la cola principal y cae en una DLQ. Sin ella, un mensaje envenenado —uno que siempre falla— se reintenta para siempre y tapa la cola. La DLQ es donde reviso qué se rompió sin que bloquee todo lo demás. Le pongo una alarma: si algo aterriza ahí, quiero enterarme.
Lo que haría diferente
Al principio metí la lógica de idempotencia dentro de cada handler. Terminé repitiéndola y equivocándome distinto en cada uno. Hoy vive en un wrapper único: recibe la clave, verifica, ejecuta, y ningún handler vuelve a pensar en duplicados. El patrón importa más que la infraestructura. SQS es reemplazable; la disciplina de asumir que todo mensaje puede llegar dos veces es lo que de verdad te salva.