Encadenar trabajo largo de varios pasos
Hay trabajo que genuinamente no puede ser un solo job. Aprovisionar un sitio implica crear un repositorio, luego —y sólo una vez existe ese repositorio— levantar el alojamiento y generar contenido en paralelo, después desplegar, y después confirmar que el despliegue aterrizó. Bus::chain() ejecuta pasos estrictamente en orden, y un paso de la cadena puede ser a su vez un Bus::batch() de jobs en paralelo:
dispatch chain
↓
create repository
↓
batch (parallel): CDN setup · content generation · hosting setup
↓
deploy
↓
mark provisioning complete
// app/Jobs/SiteProvisionJob.php (condensed)
public function handle(): void
{
self::markProvisionStarted($this->site, $this->webhook);
Bus::chain([
(new RepositoryCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook))->onQueue('default'),
Bus::batch([
new CDNTenantCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook),
new AiContentCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook),
new ForgeSiteCreateJob(site: $this->site, token: $this->token, webhook: $this->webhook),
])->allowFailures(false),
(new ForgeSiteDeployJob(site: $this->site, token: $this->token, webhook: $this->webhook))->onQueue('default'),
function () {
self::clearStage($this->site);
},
])->catch(function (Throwable $exception) {
self::failTerminally($this->site, $this->webhook, $exception);
})->onQueue('default')->dispatch();
}
// Belt and suspenders: if the OUTER job fails before the chain is even
// dispatched, the chain's own ->catch() never runs at all.
public function failed(Throwable $exception): void
{
self::failTerminally($this->site, $this->webhook, $exception);
}
allowFailures(false) en el lote significa que un job fallido de ese grupo paralelo cancela el resto en lugar de dejar que sus hermanos sigan mutando estado externo alrededor de un condenado. El ->catch() de la cadena es el único sitio que decide qué significa «este proceso de varios pasos falló»: no sólo registra, sino que desmonta el estado parcial que se hubiera creado. Y failed() en el job exterior existe porque el ->catch() de la cadena sólo corre si la cadena llegó a despacharse; si el job exterior lanza antes de eso (un error de serialización, un parpadeo de la conexión de cola), nada más llamará nunca al camino de limpieza. Dos puntos de fallo distintos, un manejador terminal compartido, hecho seguro de llamar dos veces por la misma reclamación con Cache::add() de la sección anterior.
Vigilantes para el trabajo que se atasca en silencio
Un job que lanza es fácil. Un job que consulta una API externa y simplemente… sigue consultando, para siempre, porque el otro extremo nunca alcanza un estado terminal, es el fallo difícil. $tries no te salva aquí, porque el job no está fallando: está teniendo éxito en cada intento y pidiendo que lo reintenten. Necesitas otra condición de parada: no «cuántas veces» sino «hasta cuándo».
// app/Jobs/ForgeSiteProvisioningWaitJob.php (condensed)
class ForgeSiteProvisioningWaitJob implements ShouldBeEncrypted, ShouldQueue
{
use Batchable, Queueable;
public int $tries = 0; // Bounded by retryUntil() instead of a fixed attempt count.
public int $timeout = 80;
public function backoff(): array
{
return [1, 2, 3, 3, 3, 3, 4, 4, 5];
}
public function retryUntil(): \DateTimeInterface
{
return now()->addMinutes(3);
}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
// ... resolve the bearer's Forge credentials, then poll.
$api = ForgeNewAPI::boot($forgeToken);
$api->existing($this->site);
$this->site->refresh(); // read what Forge just reported, not stale metadata
$status = $this->site->metadata['site']['status'] ?? '';
if (in_array($status, ['creating', 'provisioning', 'installing', 'pending'], true)) {
throw new PollingRetryException("Site provisioning in progress. Current status: {$status}");
}
if (in_array($status, ['created', 'installed'], true)) {
return;
}
// Unknown/failed status is TERMINAL. With tries=0 + retryUntil(), a plain
// throw would just keep polling a dead provision for the full window.
// fail() bypasses retryUntil and ends the job now.
$this->fail(new \Exception("Site provisioning failed or unexpected status: {$status}"));
}
}
$tries = 0 junto a retryUntil() es una sustitución deliberada: en lugar de «inténtalo nueve veces», es «sigue intentándolo hasta dentro de tres minutos, con el calendario de esperas que te lleve hasta ahí». PollingRetryException es una excepción dedicada, marcada como no reportable, que sólo significa «aún no ha terminado, vuelve a preguntar», así que un ciclo normal de consulta nunca aparece como ruido. Y $this->fail() importa precisamente porque no es un throw: una excepción lanzada bajo retryUntil() simplemente se reintenta hasta la fecha límite, así que un estado externo genuinamente muerto hay que fallarlo explícitamente, ahora mismo, o estará consultando un cadáver durante tres minutos más.
retryUntil() resuelve el job que sigue corriendo pero tarda demasiado. No ayuda con el job que nunca llegó a recogerse, ni con una cadena que se colgó entre dos pasos sin que nada lance excepciones. Para eso necesitas algo fuera del job por completo: un comando programado que busque trabajo marcado como iniciado pero nunca completado ni fallado, pasado un umbral, y lo falle a la fuerza por el mismo camino terminal que usaría un fallo normal. Ese es el trabajo de un vigilante: no espera a que le digan que algo está atascado, va a buscarlo.