Operaciones por lotes sin timeouts
Un consumidor quiere crear cincuenta licencias en una sola petición. La versión ingenua hace un bucle:
// Mal: cincuenta llamadas al proveedor en una petición
$created = [];
foreach ($request->validated('licenses') as $item) {
$created[] = Licenses::create(
name: $item['name'],
domain: $item['domain'],
);
}
return LicenseResource::collection($created);
Cada llamada tarda más o menos un segundo. La petición supera el timeout del servidor web hacia el elemento treinta, el consumidor recibe un 504, y nadie sabe cuáles de las cincuenta salieron bien. Reintentar crea duplicados de las treinta primeras.
Deja de hacer el trabajo dentro de la petición. Acepta el lote, encola un job por elemento y responde de inmediato. Un lote es un recurso por derecho propio, así que tiene una ruta de recurso y un controlador propios, y el controlador empieza diciendo quién puede llamarlo:
// routes/api.php, dentro del grupo auth:sanctum
Route::apiResource(
'license-batches',
LicenseBatchController::class,
)->only(['store', 'show']);
// app/Http/Controllers/LicenseBatchController.php
#[Middleware('ability:licenses:write')]
class LicenseBatchController
{
public function store(
StoreLicenseBatchRequest $request,
): JsonResponse {
$consumer = $request->user();
$jobs = [];
foreach ($request->validated('licenses') as $item) {
$jobs[] = new CreateLicense($item, $consumer);
}
$batch = Bus::batch($jobs)
->name("consumer:{$consumer->id}")
->allowFailures()
->dispatch();
return response()->json(
['data' => ['id' => $batch->id]],
Response::HTTP_ACCEPTED,
);
}
}
La línea de la habilidad no es opcional, y es el error sobre el que advirtió el Capítulo 4. Este es un controlador nuevo. Sin ese atributo, un token de solo lectura podría crear cien licencias por petición a través del único controlador que a nadie se le ocurrió cerrar.
StoreLicenseBatchRequest es el request del Capítulo 2, con su techo de cien elementos. También comprueba que el lote cabe en lo que queda del license_limit del consumidor:
// app/Http/Requests/StoreLicenseBatchRequest.php
public function after(): array
{
return [function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$room = $this->user()->license_limit
- Licenses::all()->count();
if ($this->collect('licenses')->count() > $room) {
$validator->errors()->add(
'licenses',
__('validation.license_room'),
);
}
}];
}
Un hook after() se ejecuta incluso cuando las reglas de los campos han fallado, así que en ese caso termina enseguida. Una petición que ya es inválida no debería costar además una llamada al proveedor. (Validator aquí es la instancia del validador que Laravel pasa, no la facade que usó el Capítulo 3.)
Esto es validación y no la excepción del Capítulo 7, según la prueba del Capítulo 2: el consumidor puede arreglarlo enviando menos elementos.
Cada job comprueba otra vez antes de llamar al proveedor, porque dos lotes pueden aceptarse en el mismo instante y solo el job ve el estado en el momento en que se ejecuta. CreateLicense usa el trait Batchable, lleva a su consumidor como lo hace DeleteLicense, y termina de inmediato si ese consumidor ha sido eliminado. Después repite las dos comprobaciones que hace store:
// app/Jobs/CreateLicense.php, en createWithinLimit()
$driver = StatamicDriver::for($this->consumer);
$held = $driver->all();
$domain = $this->item['domain'];
if ($held->pluck('domains')->flatten()->contains($domain)) {
return; // un intento anterior ya la creó
}
if ($held->count() >= $this->consumer->license_limit) {
$this->fail(new LicenseLimitReached);
return;
}
$driver->create(name: $this->item['name'], domain: $domain);
Es el mismo par de comprobaciones, escrito como llamadas a la colección porque el job no tiene un helper propio. Con varios workers, los jobs de un lote se ejecutan a la par, y dos de ellos pueden leer el mismo recuento. Así que handle() ejecuta ese método bajo un bloqueo, el mismo que el Capítulo 19 pone también alrededor de store:
// app/Jobs/CreateLicense.php, en handle()
Cache::lock("license-create:{$this->consumer->id}", 120)
->block(30, fn () => $this->createWithinLimit());
allowFailures() es una elección. Aquí los elementos son independientes, así que un dominio rechazado no debería detener a los otros cuarenta y nueve. Cuando los elementos dependen unos de otros, no lo pongas y deja que el primer fallo cancele el resto, como hace el lote de aprovisionamiento del Capítulo 10.
Un 202 sin nada con lo que hacerle seguimiento es media respuesta. Dale al consumidor un lugar donde preguntar cómo fue:
// app/Http/Controllers/LicenseBatchController.php
public function show(
Request $request,
string $licenseBatch,
): JsonResponse {
$batch = Bus::findBatch($licenseBatch);
$owner = "consumer:{$request->user()->id}";
abort_unless($batch?->name === $owner, 404);
return response()->json(['data' => [
'total' => $batch->totalJobs,
'pending' => $batch->pendingJobs,
'failed' => $batch->failedJobs,
'finished' => $batch->finished(),
]]);
}
El abort_unless es la autorización. Un ID de lote es una puerta de acceso al trabajo de otro salvo que compruebes de quién es, y responder 404 y no 403 evita confirmar que el lote existe.
Todo lo que dijo el Capítulo 10 sobre ejecutarse dos veces se aplica a cada job CreateLicense. Y cuando el lote termina, su callback finally() puede disparar el webhook que acabas de construir, de modo que un consumidor suscrito no tiene que sondear en absoluto. Usa finally() y no then(): con allowFailures(), then() se ejecuta solo si todos los jobs salieron bien.