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
El servidor que se reiniciaba cada cinco horas
Un contenedor que se crasheaba en loop, sin panic y sin logs — cuatro veces, por cuatro razones distintas. Un recorrido por todas las formas en que un health check te puede mentir.
El editor que se comía mis <
Escribías un < solo en el editor de código y se convertía en silencio en <. El culpable era un componente oculto, dos pestañas más allá, haciendo su trabajo al pie de la letra.