Un barrido que no confía en que alguien lo note
retryUntil() protege a un job que sigue ejecutándose. No tiene nada que decir sobre una cadena que se detiene entre dos pasos: un worker al que un despliegue mata justo cuando toma un job, un paso que nunca se despachó porque la conexión de la cola parpadeó. Nada lanza una excepción. Nada sondea. El flujo se queda callado, y el panel de alguien muestra un indicador de carga que no terminará nunca.
Atrapar eso necesita algo fuera de la cola: un comando programado que sale a buscar el trabajo que quedó marcado como empezado y nunca como terminado.
// app/Console/Commands/ReapStuckProvisions.php, en handle()
$cutoff = now()->subMinutes(self::STUCK_AFTER_MINUTES);
$stuck = Site::query()
->whereNotNull('provision_started_at')
->whereNull('provision_finished_at')
->where('provision_started_at', '<', $cutoff)
->limit(self::MAX_PER_RUN + 1)
->get();
if ($stuck->count() > self::MAX_PER_RUN) {
$this->error('Too many stuck provisions. Investigate.');
return self::FAILURE;
}
foreach ($stuck as $site) {
if ($site->refresh()->isStillStuck($cutoff)) {
ProvisionSite::failTerminally($site);
}
}
Cuatro decisiones de ese comando hacen más trabajo del que sugiere su número de líneas.
La consulta es la señal de fallo. Nada en un aprovisionamiento atascado lanza una excepción. Lo que lo hace detectable es una marca de tiempo duradera escrita cuando empezó la cadena. La ausencia de una marca de finalización pasado un tiempo suficiente es el fallo.
El tope es una negativa, no un recorte. Si coinciden más de los que permite el tope, el comando se detiene. Muchos aprovisionamientos atascados a la vez no parecen desgaste normal. Parecen algo sistémico que se rompió, y un barrido que da por fallidos sitios en masa durante una caída es peor que uno que obliga a una persona a mirar.
Vuelve a comprobar justo antes de actuar. La lista es una instantánea. Para cuando el bucle llega a una fila, ese sitio puede haber terminado. Releerla significa que el barrido nunca desmonta un sitio que acaba de quedar en línea.
Reutiliza el camino terminal. El mismo failTerminally() al que llama la cadena, que ya es seguro llamar dos veces.
Prográmalo de modo que no pueda amontonarse sobre sí mismo:
Schedule::command('provision:reap')
->everyFiveMinutes()
->withoutOverlapping()
->onOneServer();
Ejecutar workers en producción
Todo lo anterior daba por hecho que algo está ejecutando tus jobs. En desarrollo eso es composer run dev. En producción es una decisión con varias partes.
Mantener vivos a los workers
php artisan queue:work se ejecuta hasta que algo lo detiene. Algo lo hará: un despliegue, un corte por falta de memoria, un reinicio del servidor. Un gestor de procesos tiene que arrancarlo otra vez. En tus propios servidores eso es Supervisor:
[program:license-worker]
command=php /var/www/api/artisan queue:work --max-time=3600
user=forge
numprocs=4
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopwaitsecs=60
numprocs es cuántos workers se ejecutan. autorestart es la razón de ser del archivo. stopwaitsecs es cuánto espera Supervisor a que un worker termine su job actual antes de matarlo, y debe ser más largo que el timeout de tu job más largo, o un despliegue cortará jobs por la mitad. Las plataformas gestionadas te dan los mismos ajustes con otros nombres. (Documentación de Laravel: Queues › Supervisor Configuration.)
--max-time=3600 hace que cada worker termine al cabo de una hora y se reinicie fresco. PHP se construyó para peticiones cortas, y un proceso que se ejecuta durante días acumula memoria. Un worker que se retira según un horario nunca llega a tener esa oportunidad.
Los dos timeouts que deben concordar
Antes en este capítulo, un job podía ser entregado a un segundo worker mientras el primero aún lo ejecutaba. De aquí viene eso.
Hay dos relojes. El timeout del job es cuánto lo deja ejecutarse un worker antes de matarlo. El retry_after de la conexión, en config/queue.php, es cuánto espera la cola antes de decidir que un job que alguien tomó y nunca terminó debe de haberse perdido, y ofrecérselo a otro worker.
Si retry_after es más corto que el timeout, la cola da por perdido un job que sigue en ejecución, y un segundo worker lo empieza. Ahora se ejecuta dos veces a la vez, cada vez que va lento.
La regla: retry_after debe ser más largo que tu timeout más largo, con margen de sobra. Si un job puede durar treinta segundos, un retry_after de noventa es cómodo. Compruébalo cada vez que subas el timeout de un job. Es la causa más común de «mi job se ejecutó dos veces» que no es una caída.
Más de una cola
Pon todos los jobs en una sola cola y una avalancha de lo que no importa retrasa lo urgente. Si un consumidor acaba de pedir una exportación de dos millones de filas, el borrado de licencia que pidió otro consumidor espera detrás.
Nombra las colas por urgencia, y envía cada job a la que le corresponde:
// app/Jobs/ExportApiRequests.php
#[Queue('low')]
class ExportApiRequests implements ShouldQueue
{
// ...
}
php artisan queue:work --queue=high,default,low
Un worker arrancado así toma siempre primero de high, luego de default, y toca low solo cuando las otras están vacías. Para un aislamiento más fuerte, ejecuta un grupo de workers aparte que atienda solo low, de modo que una exportación nunca pueda ocupar un worker que necesita un borrado.
Resiste la tentación de crear una cola por job. Tres niveles cubren casi cualquier sistema: lo que una persona está esperando, el caso normal y lo que puede esperar.
Horizon, cuando estás sobre Redis
Si tus colas funcionan sobre Redis, Laravel Horizon reemplaza el archivo de Supervisor por configuración en el repositorio y añade un panel:
// config/horizon.php
'production' => [
'supervisor-1' => [
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 10,
],
],
Con balance en auto, Horizon mueve workers hacia la cola que esté más ocupada, dentro de los límites que fijes, de modo que una ráfaga se absorbe sin que nadie tenga que entrar a añadir procesos. También registra el caudal, el tiempo de espera y los fallos de cada cola, que es casi todo lo que pide la sección siguiente. (Documentación de Laravel: Horizon.)
Horizon sigue necesitando un proceso que se mantenga vivo, php artisan horizon. El Capítulo 20 trata cómo decirles a los workers, incluidos los de Horizon, que tomen el código nuevo tras un despliegue.