Un barrido que no confia en que nadie se de cuenta
El retryUntil() del capítulo 7 acota un job que consulta un estado externo en lugar de fallar. Ese mecanismo tiene un límite duro: sólo protege a un job que sigue corriendo. No tiene nada que decir sobre una cadena que se detiene entre dos pasos, un worker matado por un despliegue justo cuando recoge un job, o un paso que nunca se despachó por un parpadeo de la conexión de cola. Nada de ese fallo lanza. Nada consulta. No queda ningún job en ninguna parte al que se le pueda agotar el tiempo. La cadena simplemente se queda en silencio, y el panel del comerciante se queda con un indicador de carga que no se resolverá jamás por sí solo.
Atrapar eso necesita un mecanismo que viva fuera de la cola por completo: un barrido programado que no espera a que le digan que algo va mal, sino que va a buscar, a intervalos fijos, trabajo marcado como iniciado y nunca marcado como terminado.
// app/Console/Commands/ProvisionReapCommand.php (condensed)
final class ProvisionReapCommand extends Command
{
private const MAX_REAP_PER_RUN = 25;
public function handle(): int
{
$thresholdMinutes = max(1, (int) config('services.server.management.provision_stuck_after_minutes', 30));
$cutoff = Carbon::now()->subMinutes($thresholdMinutes);
$candidates = Site::query()
->whereNotNull('metadata->provision->started_at')
->whereNull('metadata->terminal_failure')
->whereNull('metadata->health_check->completed_at')
->where('metadata->provision->started_at', '<', $cutoff->toIso8601String())
->limit(self::MAX_REAP_PER_RUN + 1)
->get();
if ($candidates->count() > self::MAX_REAP_PER_RUN) {
$this->components->error('Refusing to reap: matched more stuck provisions than the per-run cap. Investigate before re-running.');
return self::FAILURE;
}
foreach ($candidates as $site) {
$site->refresh(); // re-check immediately before acting; it may have finished on its own
if (! self::isStillStuck($site, $cutoff)) {
continue;
}
SiteProvisionJob::failTerminally(
$site,
webhook: $site->metadata['provision']['webhook'] ?? null,
exception: new RuntimeException("Provision watchdog: exceeded {$thresholdMinutes} minute threshold without completing."),
);
}
return self::SUCCESS;
}
}
Cuatro decisiones de este fichero hacen más trabajo del que sugiere su longitud:
- La consulta es la señal de fallo, no una excepción. Nada de un aprovisionamiento atascado lanza. Lo que lo hace detectable es una marca de tiempo duradera escrita en el momento en que empieza la cadena, comprobada contra un umbral según un calendario. La ausencia de un marcador de finalización pasado suficiente tiempo es el fallo.
MAX_REAP_PER_RUNes una negativa, no un tope que trunca en silencio. Si coinciden más de veinticinco sitios, el comando falla en lugar de segar veinticinco y seguir. Que coincidan más de un puñado no parece desgaste normal: parece que algo sistémico se rompió, y un barrido que falla en masa a través de una caída sistémica es peor que uno que se detiene y hace que mire una persona.- Volver a comprobar justo antes de actuar cierra una carrera que la primera consulta no puede. La lista de candidatos es una instantánea. Para cuando el bucle llega a una fila, ese sitio puede haber terminado solo hace segundos. Releer el estado fresco antes de desmantelar significa que el barrido nunca tira abajo un sitio que acaba de ponerse en marcha.
- El camino de fallo terminal se reutiliza, no se reimplementa. Es el mismo método que llama el
->catch()de la cadena, ya hecho idempotente con una reclamación en caché en el capítulo 7. Eso es lo que hace seguro que dos caminos de código sin relación —el manejador de fallos de un worker y un barrido programado— lo llamen sobre el mismo sitio sin coordinarse.
Esta es la segunda línea de defensa que prometía la apertura del capítulo, y se gana esa descripción con honestidad: no sustituye a la política de reintentos del capítulo 7, existe porque esa política, por bien afinada que esté, no puede ver un fallo que nunca lanza.