Hay bugs que se sienten como un crucigrama: una pista lleva a otra, las piezas encajan, y al final hay una solución elegante. Hay bugs que se sienten como una piñata a oscuras: pegas a todo y no le aciertas a nada. Este fue el segundo tipo. Esta es la historia y la lección.
El síntoma
En ReclamaAI, cada noche corre un job que revisa documentos generados los últimos 30 días y le manda a sus dueños un recordatorio si todavía no los han descargado. Es simple: SELECT, loop, send email, mark as reminded. La función estaba escrita, probada en local, deployada con BullMQ corriendo sobre Upstash Redis.
Funcionó la primera noche. La segunda noche, dos usuarios reportaron que recibieron el mismo correo cinco veces. La tercera noche, otro usuario reportó haberlo recibido doce veces. Y esto seguía aumentando.
Lo primero que probé (y estaba mal)
Mi primer instinto fue: el cron está disparando varias veces. Verifiqué el cron — corría una sola vez al día a las 3am. Verifiqué BullMQ — el job estaba siendo encolado una sola vez por noche. Verifiqué los logs del worker — el worker procesaba el job una sola vez. Pero los emails salían múltiples veces.
Mi segundo instinto: el SELECT debe estar trayendo el mismo documento varias veces (un join mal hecho). No. La query era correcta y devolvía 47 filas distintas, una por documento.
La pista
Pasé un día persiguiendo la cola equivocada. Lo que finalmente me dio la pista fue agregar un log con el job.id de BullMQ dentro del worker. Y ahí lo vi: el mismo job ID se estaba procesando múltiples veces, con minutos de separación, por distintas instancias del worker.
Resulta que en serverless (Vercel functions), no hay un único worker corriendo siempre. Cada vez que llega una invocación, una nueva instancia del worker se enciende, lee de la cola, procesa lo que pueda, y se apaga. Si el job tarda más de los 60 segundos máximos de la función Hobby, la función se mata, BullMQ marca el job como "stalled", y otra instancia lo retoma. El loop interno del job (el que mandaba 47 emails) ya había mandado N de los 47 antes de morir, pero como la función nunca llegó a confirmar el job, BullMQ lo intentaba de nuevo.
El fix
Tres cambios:
- El job de "recordar 47 documentos" se descompuso en 47 jobs individuales, uno por documento. Cada uno corre en menos de 2 segundos. Si uno falla, BullMQ retry solo ese, no los 47.
- Cada job individual valida idempotencia: antes de mandar el correo, hace
UPDATE documents SET reminded_at = NOW() WHERE id = ? AND reminded_at IS NULL. Si afecta 0 filas, ya se mandó, abort. Si afecta 1 fila, manda el correo. - Subimos el plan de Vercel a Pro para tener funciones más largas, y redujimos el batch size de BullMQ por instancia para que ninguna instancia tomara más de lo que podía procesar en su window.
La lección general
Toda función serverless puede morir a la mitad. Toda cola puede reintentar. Toda red puede partir un mensaje en dos. La idempotencia no es una optimización, es un requisito. Si tu acción no es segura de ejecutarse dos veces, eventualmente se va a ejecutar dos veces, y eventualmente vas a recibir un correo de un usuario molesto.
La regla que ahora aplico en cualquier worker: antes de hacer cualquier cosa con efecto secundario (mandar correo, cobrar dinero, llamar a una API externa), pasa por una guarda que deduplique en la base de datos con un constraint único. Si no puedes deduplicar, registra que se intentó antes de hacerlo, no después. Y si tu lógica no se puede dividir en unidades pequeñas que cada una sea idempotente, divídela hasta que sí.
Lo que no sabía y aprendí
BullMQ tiene un parámetro removeOnComplete que estaba en false por default. Eso significaba que mi cola de Redis estaba creciendo indefinidamente con jobs procesados, llenando el plan free de Upstash sin que me diera cuenta. Lo cambié a true, y de paso a removeOnFail: { age: 86400 } para mantener los failed por 24 horas (útil para debugging) y luego limpiarlos. Tres días para encontrar el bug, treinta minutos para arreglarlo, cinco minutos para configurarlo bien desde el principio. La próxima vez ya sé.