Laravel: `timeout`, `retry_after` e i job eseguiti due volte

Il sintomo è sempre lo stesso e non assomiglia a un bug: il cliente riceve due volte la stessa email,
il gestionale registra due volte la stessa riga, il file importato produce il doppio dei record. Nei
log non c’è niente. Nessuna eccezione, nessun job fallito, la dashboard è pulita. Il job risulta
eseguito, e infatti lo è stato: due volte.
Quasi sempre la causa è il rapporto fra due valori che stanno in due file diversi e che nessuno
guarda insieme: --timeout del worker e retry_after della connessione.
Due orologi che contano la stessa cosa
retry_after sta in config/queue.php, dentro la configurazione della connessione. La
documentazione di Laravel lo spiega così: “questa opzione specifica quanti secondi la connessione
deve aspettare prima di riprovare un job che è in lavorazione. Per esempio, se il valore di
retry_after è 90, il job verrà rilasciato di nuovo sulla coda se è in lavorazione da 90 secondi
senza essere stato rilasciato o cancellato”.
È una misura di sicurezza, e serve: un worker può morire nel mezzo di un job — OOM killer, deploy,
macchina riavviata — e senza retry_after quel job resterebbe in lavorazione per sempre, invisibile.
Il punto è che la coda non ha modo di sapere perché il job è ancora lì dopo 90 secondi. Non
distingue un worker morto da un worker che sta lavorando bene su un file grosso. Scaduto il tempo,
lo rimette in coda e il primo worker libero lo riprende.
--timeout è l’altro orologio, e sta dalla parte del worker: è l’opzione di queue:work, vale 60
secondi di default, e se un job resta in lavorazione più a lungo il worker che lo sta processando
esce con un errore — normalmente il process manager lo fa ripartire subito dopo.
Il rapporto fra i due è documentato in una riga sola, ed è la riga che conta: “il valore di timeout
di un job dovrebbe essere sempre minore del suo valore di ‘retry after’. Altrimenti il job potrebbe
essere ripreso prima di avere davvero finito di essere eseguito o di essere andato in timeout”.
I default di Laravel rispettano già la regola: retry_after è 90 per le connessioni database,
redis e beanstalkd, --timeout è 60. Trenta secondi di margine.
Come si rompe
Non si rompe da solo: lo rompiamo noi, e sempre nello stesso modo. Un job diventa più lento —
l’import è passato da 200 a 5.000 righe — comincia a morire in timeout, e la correzione ovvia è
alzare il timeout:
php artisan queue:work --timeout=300
Il job adesso ha cinque minuti per finire. Solo che retry_after è rimasto a 90. Al novantesimo
secondo la coda considera quel job abbandonato e lo rilascia; un secondo worker lo prende e comincia
a eseguirlo da capo, mentre il primo sta ancora lavorando e non ha idea di avere un gemello. Se il
job impiega quattro minuti, il giro si ripete: due, tre, quattro esecuzioni in parallelo dello stesso
lavoro. Nessuna di queste va in errore. Tutte scrivono.
La regola pratica è una riga in config/queue.php, non un’opzione della command line:
'database' => [
'driver' => 'database',
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
],
Se alzi il timeout, alzi prima retry_after, e lo tieni qualche secondo più alto. Il valore giusto
per retry_after è il tempo massimo che i tuoi job possono ragionevolmente impiegare — non una
media.
Un caso a parte è Amazon SQS: è l’unica connessione che non ha retry_after, perché il tempo lo
decide il Default Visibility Timeout configurato nella console AWS. Stessa logica, altro posto dove
guardare.
Il timeout si mette sul job, non sul worker
Alzare --timeout a 300 secondi per un job lento significa darli a tutti i job che passano da quel
worker, compresi quelli che dovrebbero durare due secondi e che invece resteranno appesi cinque
minuti prima che qualcuno se ne accorga. Il valore si mette sul job. In Laravel 13 c’è un attributo:
use Illuminate\Queue\Attributes\Timeout;
#[Timeout(120)]
class ImportaAnagrafiche implements ShouldQueue
{
// ...
}
Nella 12 e precedenti è una proprietà pubblica, public $timeout = 120;. In entrambi i casi il
valore sul job vince su quello della command line. Due avvertenze dalla documentazione: serve
l’estensione PCNTL per poter specificare un timeout, e --timeout non ha effetto quando
queue:work viene invocato con --once.
E va saputo cosa succede dopo: di default un job che va in timeout consuma un tentativo e torna in
coda. Se il problema è la lentezza, riprovare non lo risolve — lo ripete. Quando il timeout
significa “questo job non deve essere ritentato”, c’è l’attributo FailOnTimeout, che lo manda
direttamente fra i falliti qualunque sia il valore di tries.
Il rimedio vero è l’idempotenza
Sistemare i due numeri toglie la causa più comune, non tutte. Un worker può morire davvero, e allora
la ripresa del job è esattamente quello che vuoi. Un job in coda va scritto partendo dal presupposto
che possa essere eseguito più di una volta, e che questo sia normale.
Concretamente, per un job che scrive: al posto di un insert un updateOrCreate sulla chiave
naturale del dato; un controllo all’inizio del handle() sullo stato di quello che sta per fare
(“questa fattura è già stata inviata?”); e per le operazioni che non si possono rendere idempotenti —
un pagamento, una email — un flag scritto prima dell’azione, non dopo.
Laravel offre anche due strumenti diretti. ShouldBeUnique, con uniqueId(), impedisce che due
istanze dello stesso job stiano in coda insieme: al dispatch prende un lock sulla chiave e, se il
lock è già occupato, il job non viene accodato. Il lock si rilascia quando il job finisce o fallisce
tutti i tentativi, e con ShouldBeUniqueUntilProcessing invece subito prima dell’esecuzione. Serve un
cache driver con lock atomici — memcached, redis, dynamodb, database, file, array — e se
l’applicazione gira su più macchine devono puntare tutte allo stesso server di cache, altrimenti ogni
nodo si crede unico per conto suo. L’alternativa, quando il problema è la concorrenza e non
l’accodamento, è il middleware WithoutOverlapping.
Nota che ShouldBeUnique non copre il caso di cui parla questo articolo: lì il job è uno solo, ed è
la coda stessa a rilanciarlo.
La checklist
retry_afterdi ogni connessione inconfig/queue.php, sempre più alto del timeout più lungo.- Il timeout sul job, non sul worker, e PCNTL installato dove girano i worker.
triesebackoffdecisi per ogni job:--tries=0significa all’infinito.handle()scritto per essere eseguito due volte senza danni.- E in fase di deploy,
php artisan queue:restart: i worker sono processi longevi e tengono
l’applicazione in memoria, quindi non vedono il codice nuovo finché non ripartono. Il comando li fa
uscire in modo pulito quando hanno finito il job in corso, senza perdere niente; a farli ripartire
ci pensa il process manager.
Fonte di tutti i valori citati: la pagina Queues della
documentazione di Laravel.
