Todas las notas
Ingeniería6 de agosto de 20264 min

Que el envío sobreviva a un reinicio

La primera versión de los envíos masivos en SendDock funcionaba en un buen día. Dabas enviar, una goroutine recorría tus suscriptores, y los correos salían. El problema era cada mal día: un deploy, un crash, un OOM — cualquier cosa que terminara el proceso a mitad del envío — y el envío simplemente desaparecía. Algunos destinatarios recibían el correo, otros no, y no había registro de cuáles. Para una herramienta cuyo pitch entero es ser dueño de tus envíos, "perdimos media campaña" no es un modo de fallo aceptable.

Así que lo reconstruí. El objetivo de diseño era acotado y estricto: un envío debe sobrevivir a que el proceso se muera en cualquier momento — ningún destinatario enviado dos veces, ninguno descartado en silencio.

Durable significa que vive en la base de datos

La versión de una sola goroutine mantenía todo su estado en memoria, que es justo por lo que un reinicio lo borraba. El arreglo empieza por negarse a guardar en memoria cualquier cosa importante. Cada destinatario de un envío se convierte en una fila de una tabla broadcast_jobs, cada una con un estado: pending, sending, retry, sent, failed. La cola es la tabla. Si el proceso se muere, la cola sigue ahí en Postgres, exactamente donde estaba.

Drenar la cola sin pisarse a sí misma

Cinco worker goroutines drenan la tabla en paralelo. El truco que hace seguros a los workers concurrentes sobre una tabla SQL compartida es una sola cláusula:

SELECT ... FROM broadcast_jobs
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED

FOR UPDATE bloquea las filas que un worker reclama; SKIP LOCKED les dice a los demás workers que pasen de largo las filas bloqueadas en vez de quedarse esperando por ellas. Nunca dos workers toman el mismo destinatario, y ningún worker se queda ocioso esperando un lock. Es la forma correcta más simple de convertir una tabla de Postgres en una cola de trabajo, y no necesita infraestructura extra — ni broker, ni un servicio de colas aparte.

Reintentos que respetan la diferencia entre "después" y "nunca"

No todos los fallos significan lo mismo. Un error SMTP transitorio — un 4xx, un pico de red, un tropiezo de DNS — significa intenta de nuevo más tarde. Un rebote 5xx significa esta dirección está muerta; detente. Tratarlos igual es cómo terminas rindiéndote demasiado pronto o martillando un buzón muerto para siempre.

Así que los fallos transitorios se reprograman con backoff exponencial — 30s, 2m, 8m, 30m, 1h — con un tope de cinco intentos antes de marcar al destinatario como failed. Un 5xx nunca se reintenta: al destinatario se lo etiqueta como bounced y se lo agrega de inmediato a la lista de supresión, para que la siguiente campaña ni siquiera lo intente.

La parte que de verdad hace honor al título

Aquí está el comportamiento en el reinicio. Cuando el backend arranca, cualquier job que quedó en sending — es decir, un worker lo había reclamado pero el proceso se murió antes de terminarlo — se resetea a retry. El envío se reanuda exactamente donde se detuvo. Como reclamar y enviar son estados separados, un job que realmente salió ya está marcado sent y no se toca; un job que estaba en vuelo vuelve a la fila. Sin envíos dobles, sin descartes.

Hubo un bug derivado sutil que vale la pena mencionar, porque muestra cómo estos cambios se propagan. Con la nueva cola, Broadcast() retorna en milisegundos — solo encola. El worker de campañas antes lo llamaba y marcaba de inmediato la campaña como sent con el conteo de destinatarios inicial, así que las campañas saltaban a 213/0 enviados antes de que saliera un solo correo. El arreglo fue enlazar cada campaña con su envío mediante un broadcast_id y dejar que los conteos reales se propaguen de vuelta a medida que la cola se drena. La campaña se queda en sending hasta que el trabajo esté realmente hecho — porque ahora "hecho" es algo real y observable.

La lección

"Durable" no es una feature que atornillas encima; es una propiedad que obtienes al negarte a guardar estado importante donde puede evaporarse. En el momento en que la cola pasó del stack de una goroutine a una tabla de Postgres, casi toda la fiabilidad cayó gratis — recuperación tras reinicio, concurrencia, reintentos, visibilidad. La parte difícil no fue la cola. Fue soltar la ilusión cómoda de que el proceso se iba a mantener vivo.

Notas relacionadas