Cargando…

Esto está tardando más de lo esperado.

Estándares de código

Queues and Time Budget

Estos son los estándares que aplica el analizador. Cada regla dice qué revisa y por qué existe.

Todos los estándares

Ningún proceso dura más de cinco segundos

Ninguna unidad de trabajo dura más de cinco segundos: una petición web, una acción de Livewire, un job, un comando de Artisan o una migración. El trabajo que puede superar los cinco segundos no se ejecuta en línea; va a la cola, tal como describe el estándar de Colas y presupuesto de tiempo.
El presupuesto se fija antes de escribir el código, no se descubre después de que falle. Una petición que tarda minutos termina en un 502 o en un timeout de la pasarela; una migración interrumpida a mitad se reintenta desde el principio, y una ejecución cortada a medias queda "en curso" para siempre. Cada uno de esos casos obliga a quien pidió el trabajo a intentarlo de nuevo.
Cinco segundos es el techo, no el objetivo. Mide el trabajo sobre el mayor volumen de datos que el proyecto llegará a almacenar, no sobre el de hoy.
El analizador todavía no comprueba este estándar: ninguna regla de aquí tiene un procesador, así que una ejecución limpia del analizador no demuestra nada al respecto. Lo aplica quien planifica y construye el trabajo.

Qué puede superar el presupuesto

El trabajo puede superar el presupuesto cuando su coste crece con los datos, los archivos o los servicios externos a los que accede:

  • un bucle sobre filas que hace el trabajo él mismo, una fila cada vez;
  • copiar, mover, calcular el hash o borrar archivos en un disco o en S3;
  • calcular sobre muchas filas, o generar un informe o una exportación;
  • llamar a un servicio externo —una API, un proveedor de correo, un modelo— una vez por fila, o una única llamada cuyo timeout no está definido dentro del presupuesto. Una única llamada que define uno (Http::timeout()) y cuya respuesta necesita la persona para continuar se queda en línea.

El trabajo acotado se queda en línea: una lista fija de ids, crear un registro o corregir un texto, y una sentencia de conjunto única (un solo update(), delete() o insert … select), que supone un único viaje de ida y vuelta sin importar cuántas filas afecte, salvo que la tabla sea tan grande que la propia sentencia mantenga bloqueos durante mucho tiempo.
Cuando no está claro si el trabajo está acotado, no lo está. Un coste que crece con los datos acaba superando cualquier límite si hay datos suficientes.

El trabajo pesado va a la cola y la petición responde de inmediato

El trabajo que puede superar el presupuesto se envía a la cola, y la petición que lo inició responde de inmediato, sin esperar el resultado. La respuesta indica que el trabajo ha comenzado; nunca se bloquea hasta que termina.
El punto de entrada —un controlador, una acción de Livewire, un comando— llama a un método del modelo, y el modelo despacha el trabajo, como espera cualquier otro estándar de un punto de entrada.

// Correcto — la acción inicia el trabajo y retorna de inmediato
public function export(): void
{
    $this->authorize('export', $this->project);
    $this->export = $this->project->startOrderExport();
}

// Incorrecto — la acción espera por cada pedido
public function export(): void
{
    $this->authorize('export', $this->project);
    foreach ($this->project->orders as $order) {
        $this->project->exportOrder($order);
    }
}

Un trabajo grande es un lote de trabajos pequeños

Un trabajo largo también agota su tiempo. Un worker mata un trabajo que supera su timeout y lo reintenta desde el principio, así que el trabajo cuyo coste crece con los datos nunca es un único trabajo.
El trabajo se divide en muchos trabajos pequeños despachados como un solo lote (Bus::batch()):

  • Un coordinador divide el trabajo, también dentro del presupuesto. La petición despacha el lote con un único trabajo coordinador, de modo que dividir el trabajo ocurre en la cola y nunca en la petición. El coordinador nunca recorre las filas: lee el id más bajo y el más alto una sola vez y añade un trabajo pequeño por cada rango de ids a su propio lote con $this->batch()->add(). Cuando los rangos son demasiados para añadirlos dentro del presupuesto, añade una porción de ellos y después el coordinador de la siguiente porción, de modo que la división se reparte igual que el trabajo.
  • Cada trabajo pequeño se mantiene dentro del presupuesto de cinco segundos, lo que también lo deja muy por debajo del timeout del worker del entorno de destino. Usa rangos de 20 a 50 ids para trabajo con archivos, S3 o servicios externos, y unos cientos para SQL puro; el número es el que mantiene un trabajo dentro del presupuesto en el caso más lento. Declara $timeout en el trabajo, por debajo del del worker, y mantén el retry_after de la conexión por encima de él.
  • Los trabajos se reparten para no saturar la cola. Ponlos en una cola propia, o repártelos con delay(), en lugar de liberar miles a la vez por delante de todos los demás trabajos.
  • Solo el coordinador despacha. Un trabajo pequeño nunca despacha trabajos nuevos por su cuenta ni se vuelve a despachar a sí mismo; el coordinador añade todos los rangos.
  • Un paso de cierre recoge el resultado. El then() del lote construye el resultado final —el archivo, el resumen, la notificación— y catch() registra el fallo. Ambos callbacks se serializan, así que son closures static que capturan solo valores simples, nunca $this. Cuando recoger el resultado puede por sí mismo superar el presupuesto, el paso de cierre despacha un trabajo más en lugar de hacerlo en línea.
// Correcto: el modelo inicia un lote que rellena un coordinador
public function startOrderExport(): OrderExport
{
    $export = $this->orderExports()->create();
    $exportId = $export->id;

    $batch = Bus::batch([new PlanOrderExport($this->id, $exportId, null)])
        ->then(static function (Batch $batch) use ($exportId) {
            OrderExport::find($exportId)?->finish();
        })
        ->catch(static function (Batch $batch, Throwable $error) use ($exportId) {
            OrderExport::find($exportId)?->markFailed($error->getMessage());
        })
        ->onQueue('exports')
        ->dispatch();

    $export->update(['batch_id' => $batch->id]);

    return $export;
}

// El coordinador, dentro de PlanOrderExport::handle(): una porción de rangos y luego el siguiente coordinador
$orders = Order::query()->where('project_id', $this->projectId);
$last = $orders->clone()->max('id');
if ($last === null) {
    return;
}

$from = $this->from ?? $orders->clone()->min('id');
$until = min($from + 50 * 200 - 1, $last);
$jobs = collect(range($from, $until, 50))->map(function (int $start) use ($until) {
    return new ExportOrders($this->projectId, $this->exportId, $start, min($start + 49, $until));
});
$this->batch()->add($jobs->all());

if ($until < $last) {
    $this->batch()->add([new PlanOrderExport($this->projectId, $this->exportId, $until + 1)]);
}

Todo job es idempotente

Un job puede reintentarse, interrumpirse o ejecutarse dos veces, y el resultado debe ser el mismo cada vez. Vuelve a comprobar lo que ya está hecho antes de actuar, nunca dupliques una fila ni un archivo, y elimina un original solo después de verificar su copia.
Cada job registra su progreso (el batch lo hace por ti cuando el trabajo se ejecuta como uno solo), de modo que un proceso atascado se pueda distinguir de uno lento. No reanudes desde un cursor de último id almacenado: cada job recibe su rango al añadirse al batch, y un cursor convierte un reintento en un rango omitido.

La persona ve avanzar el trabajo

Mientras se ejecuta el lote, la pantalla que lo inició muestra una barra de progreso y la mantiene actualizada consultando el estado del lote con Livewire (wire:poll), mediante una propiedad #[Computed] que lee el lote al que apunta el registro. El registro figura como en ejecución desde el momento en que se crea hasta que then() o catch() del lote lo marcan, y en ese momento el sondeo se detiene. Cuando un coordinador añade el trabajo por tramos, el progreso propio del lote solo cuenta los trabajos añadidos hasta ese momento y retrocede cada vez que se añade un tramo: registra el total una sola vez, a partir del id más bajo y el más alto del primer coordinador, en el registro al que pertenece el lote, y mide el progreso con respecto a ese total.

  • Cuando el lote termina, la pantalla indica que el trabajo está listo y dónde está su resultado.
  • Cuando falla, la pantalla lo indica con palabras que la persona entienda y le dice qué puede hacer a continuación.
  • Cerrar la pantalla no detiene el trabajo. El lote se ejecuta en la cola tanto si alguien lo mira como si no, y el registro al que pertenece conserva su estado, de modo que al volver a la pantalla se ve hasta dónde llegó.
#[Computed]
public function exportState(): ?string
{
    return $this->export?->state();
}

#[Computed]
public function exportProgress(): ?int
{
    return $this->export?->progress();
}
<div @if ($this->exportState === 'running') wire:poll.2s @endif>
    @if ($this->exportState === 'running')
        <progress class="w-full" max="100" value="{{ $this->exportProgress }}"></progress>
    @elseif ($this->exportState === 'finished')
        <p class="text-positive-600">{{ __('The export is ready.') }}</p>
    @elseif ($this->exportState === 'failed')
        <p class="text-negative-600">{{ __('The export failed. Start it again.') }}</p>
    @endif
</div>

Debe haber un worker en ejecución

El trabajo no está terminado cuando la petición finaliza. Está terminado cuando un worker de colas ha ejecutado todos los jobs del lote.

  • Debe haber un worker de colas en ejecución en cada entorno que alcance el trabajo. php artisan queue:restart solo indica a los workers en ejecución que se recarguen; no inicia ninguno.
  • Con QUEUE_CONNECTION=sync los jobs se ejecutan en línea dentro de la petición, lo que reintroduce el timeout que este estándar pretende evitar. Usa una conexión de cola real.
  • Los lotes necesitan la tabla job_batches. Créala con la migración del proyecto antes de despachar el primer lote.

Las migraciones siguen su propia regla

El despliegue de una migración espera a que termine, así que su propio trabajo debe mantenerse dentro del presupuesto. Heavy Data Work Goes Through the Queue en el estándar de migraciones de Laravel es la forma que adopta su trabajo pesado. Esa regla es el caso particular de este estándar y se mantiene tal cual: una migración selecciona solo los ids por sí misma y despacha closures static, uno por fragmento, nunca una clase Job ni un coordinador. Recorrer esos ids es la única división que este estándar permite fuera de un coordinador, y hasta dónde puede llegar lo determina esa regla. Cada closure es un job, así que se mantiene dentro de los cinco segundos y también por debajo del timeout del worker. Esa regla exige lo demás que aplica a una migración con sus propias palabras: fragmentos pequeños, trabajo idempotente, un despacho repartido y un worker en ejecución. Una migración no tiene pantalla ni paso de cierre.

Weblabor Logo Desarrollado por Weblabor