Un job che chiama un modello generativo non dura mezzo secondo come l'invio di una mail: dura quanto decide il fornitore, e ogni tanto molto di più. Se quel job gira in un container Docker, i modi in cui può morire sono tre — scade il timeout, il kernel lo ammazza per memoria, un deploy lo interrompe — e fino a questo mese Laravel li trattava in modo diverso senza dirvelo. La 13.33 e una PR mergiata il 26 settembre cambiano due di questi comportamenti. Vale la pena capire quali, perché il resto del lavoro resta vostro.
Tre morti diverse per lo stesso job
Prendete un job che manda un'immagine a un modello e aspetta la risposta. In produzione, dentro php artisan queue:work in un container, può finire male così:
- Timeout del worker. Il job supera
--timeout(o la proprietà$timeoutdel job). Laravel usapcntle unSIGALRM: quando scatta, il worker fino a ieri si uccideva da solo conSIGKILL. Il timeout viene registrato sul job (che fallisce subito se ha$failOnTimeout), e il processo sparisce. - OOM del container. Il processo supera il limite di memoria del container (
mem_limitodeploy.resources.limits.memoryin Compose). Il kernel lo uccide conSIGKILL: niente eccezione, nientefailed, niente log applicativo. Sephpè il PID 1, il container esce con 137. - Deploy.
docker compose upricrea il container: mandaSIGTERM, il worker prova a finire il job corrente, ma dopostop_grace_period— dieci secondi di default — arrivaSIGKILL. Per un job che chiama un modello, dieci secondi sono pochi.
Nel secondo e nel terzo caso il job resta "riservato" nella coda finché non passa retry_after, poi torna disponibile e qualcuno lo riprende. Dal punto di vista del framework non è successo niente: nessuna eccezione è stata lanciata, quindi nessuna eccezione è stata contata.
Il buco: maxExceptions non vede i crash
Con $tries il problema è limitato: ogni volta che il job viene prelevato il contatore dei tentativi sale, e prima o poi fallisce. Ma chi chiama API esterne con limiti di frequenza spesso non può usare $tries, perché il middleware RateLimited rilascia il job e consuma un tentativo anche quando il job non ha nemmeno provato. La combinazione tipica diventa quindi $tries = 0, $maxExceptions e retryUntil() come paracadute.
Il guaio è che maxExceptions conta solo le eccezioni vere. Un job che manda il worker in OOM, viene ucciso, torna in coda e manda di nuovo il worker in OOM, non incrementa niente: gira in cerchio fino alla scadenza di retryUntil(), bruciando CPU e — se chiama un modello a pagamento — chiamate fatturate. L'autore della PR #61737 racconta di esserci cascato con un worker ucciso per memoria su Laravel Cloud.
La soluzione è opt-in, per job:
#[MaxExceptions(3), CountCrashesAsExceptions]
class GeneraAnteprima implements ShouldQueue
{
public $tries = 0;
public function retryUntil(): DateTime
{
return now()->addMinutes(30);
}
}
Il meccanismo è semplice e onesto: quando il worker preleva il job scrive un marcatore in cache, e lo cancella quando il tentativo finisce. Se al giro successivo il marcatore c'è ancora, il tentativo precedente è morto male, e quella morte conta come un'eccezione. Costa due chiamate alla cache per tentativo, e richiede una cache condivisa fra i worker: con il driver array o file in container separati il marcatore non sopravvive al container che muore. L'import esatto degli attributi e la versione che li include sono da verificare sul changelog quando uscirà la release: la PR è mergiata sul ramo 13.x ma, mentre scrivo, non l'ho vista in un tag.
Il timeout che non uccide il worker
La PR #61591, nella 13.33, aggiunge un flag statico:
// AppServiceProvider::boot()
Worker::$killOnTimeout = false;
Con il flag spento, allo scadere del timeout il worker non si suicida: lancia una TimeoutExceededException dentro il job. Il job può ripulire, chiudere quello che ha aperto, persino catturarla e decidere cosa fare. Il worker sopravvive e passa al job successivo senza ripartire da zero, senza ricaricare il framework e riaprire le connessioni.
Io lo userei con cautela, per due motivi scritti nero su bianco nella discussione della PR #61622. Primo: un'eccezione lanciata da un signal handler si propaga solo quando PHP torna a eseguire istruzioni. Un job bloccato dentro una chiamata di sistema non ci arriva mai. Secondo: se l'eccezione risale, il worker continua a lavorare portandosi dietro lo stato lasciato dal job interrotto. Il SIGKILL è brutale ma garantisce che quello stato muoia con il processo. Un try/catch (\Throwable) troppo largo dentro handle(), poi, si mangia l'eccezione e il timeout smette di esistere: è la ragione per cui il default resta true.
Allineare i timeout, dall'interno verso l'esterno
Nessuno dei due flag sostituisce la cosa che conta davvero: un ordine preciso fra i timeout, dal più interno al più esterno. È la parte che in anni di code Laravel ho visto sbagliare più spesso, e quasi mai per ignoranza — semplicemente i numeri stanno in quattro file diversi e nessuno li guarda insieme.
- Il timeout della chiamata HTTP al modello, per primo.
Http::timeout(60)o l'equivalente dell'SDK. È l'unico che trasforma l'attesa in un'eccezione normale, gestibile, contata. - Il
$timeoutdel job, qualche secondo sopra. Scatta solo se il primo non è bastato. retry_afternella connessione della coda, sopra il timeout del job. Se è più basso, un secondo worker riprende il job mentre il primo lo sta ancora eseguendo: lo dice la documentazione, e con un modello a pagamento significa pagare due volte la stessa risposta.stop_grace_perioddel servizio worker in Compose, sopra il timeout del job, così un deploy aspetta che il job in corso finisca invece di ucciderlo.
services:
worker:
image: app:latest
command: php artisan queue:work --timeout=90 --memory=256
stop_grace_period: 120s
deploy:
resources:
limits:
memory: 512M
Nota sull'opzione --memory: fa fermare il worker in modo pulito quando la memoria supera la soglia, ma la controlla fra un job e l'altro. Non vi protegge da un singolo job che si gonfia oltre il limite del container mentre decodifica un'immagine. Per quello serve un limite del container con margine, e — ora — CountCrashesAsExceptions per non entrare in loop.
Il payload è un file anche se non lo chiamate così
Un'ultima cosa, che viene da Miraviso. Lì l'anteprima del taglio è generata da Gemini su server UE, previo consenso, e non viene mai scritta su disco. Quello stack è FastAPI, non Laravel, ma la regola si traduce parola per parola: se mettete un'immagine nel payload di un job, l'avete scritta nel backend della coda — Redis con persistenza, una tabella jobs, e in caso di errore failed_jobs. I retry la rileggono da lì. Ho scritto di queste copie non dichiarate in un pezzo sui dati sensibili nei log; per le code vale lo stesso ragionamento. Se la promessa è "mai su disco", un job asincrono non è il posto giusto per quel dato, e i retry automatici vanno ripensati di conseguenza.
Il resto è igiene: una PR che conta i crash, un flag che rende il timeout gestibile, e quattro numeri messi nell'ordine giusto. Di tutto questo, i quattro numeri sono l'unica cosa che nessun aggiornamento farà per voi.