La cola que nunca paró

La memoria de Redis seguía subiendo. Horizon se veía en verde. Veintinueve clases de email reintentaban para siempre y nadie se dio cuenta.

Los emails estaban fallando. Esa parte era esperada: credenciales SMTP rotas durante una migración. Lo que no era esperado: nunca dejaron de fallar.


Dashboard de Horizon: en verde. Workers: sanos. Redis: creciendo despacio. Sin alertas, sin errores en los logs. Solo una acumulación silenciosa de jobs que seguían intentándolo, una y otra y otra vez.

Solo me di cuenta porque la memoria de Redis no volvió a bajar después de arreglar la configuración de SMTP. Algo seguía ahí dentro, masticando reintentos. Miles de ellos.


Di por hecho que la cola se encargaría. Ese es el trato: un job falla, reintenta unas cuantas veces, aterriza en failed_jobs. Sigues adelante.

A menos que el job sea un Mailable.

Cuando despachas un Mailable a una cola, Laravel lo envuelve en un job. El maxTries de ese job viene de la propiedad $tries del Mailable. Si no la defines —y por qué lo harías, la documentación apenas la menciona—, se serializa como null.

Null no significa "usa el valor por defecto del supervisor". Null significa "sin límite". Horizon ve null y piensa: este job quiere reintentar para siempre. Así que lo hace.


Resulta que es un bug conocido. Laravel Horizon issue #1346. El flag --tries del supervisor se ignora cuando el payload serializado del job lleva maxTries: null. La propia declaración del job gana, y su declaración dice: nunca pares.

Veintinueve clases Mailable. Cada una sin una propiedad $tries explícita. Cada una potencialmente inmortal.


El arreglo es casi insultante en su simplicidad:

class WelcomeEmail extends Mailable implements ShouldQueue
{
    public int $tries = 2;
    public int $maxExceptions = 2;
}

Dos propiedades. Veintinueve archivos. Eso es todo.

Un intento inicial, un reintento, y luego failed_jobs. Tal como siempre supuse que funcionaba.


Lo pruebo como probarías una ratonera. Rompo la configuración de SMTP a propósito. Despacho un email. Observo Horizon. Dos intentos. Job fallido. Listo. Sin fantasmas en la cola.

Después arreglo los otros veintiocho.


Tres lecciones, condensadas:

  1. Null no es "por defecto". En los payloads serializados de un job, un maxTries en null significa ilimitado. La configuración de tu supervisor es una sugerencia, no una regla.
  2. Los dashboards en verde mienten. Horizon mostraba workers sanos procesando alegremente jobs que nunca terminarían.
  3. Los valores por defecto de un framework no siempre son sensatos. Laravel no establece $tries en los Mailables. Tienes que hacerlo tú. La documentación no te avisará hasta que ya tengas un incendio.

Los bugs que más asustan son los que parecen una operación normal. Este lo parecía, durante semanas.


Comentarios

Boris D. Teoharov

Autor

Hola, soy Boris

No soy escritor. No soy filósofo. Solo soy un ingeniero backend de Bulgaria que se gana la vida entre colas de Laravel e índices de cientos de millones de filas. El resto del tiempo leo medicina que no me corresponde leer, novelas francesas que entiendo a medias y lo que sea que mi pequeña cabeza de goma quiera masticar. Dos callejeros rescatados me mantienen honesto.