Scheduler di Laravel: withoutOverlapping e onOneServer

Lo scheduler di Laravel è comodo: una sola riga di cron sul server, tutto il resto nel codice, sotto controllo di versione.
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Poi il progetto cresce e saltano fuori due problemi che hanno lo stesso sintomo — il task viene eseguito più volte di quanto dovrebbe — ma cause opposte. Laravel ha un metodo per ciascuno. Usare quello sbagliato non risolve niente.
Problema 1: il task si accavalla con se stesso
Un comando pianificato ogni minuto che a volte impiega tre minuti. Al secondo minuto ne parte un altro mentre il primo sta ancora lavorando. Poi un terzo. Se il task scrive su un file, manda email o elabora una coda di record, il risultato è duplicazione o corruzione.
È il comportamento predefinito, e la documentazione lo dice chiaramente: «By default, scheduled tasks will be run even if the previous instance of the task is still running».
La soluzione è withoutOverlapping():
use Illuminate\Support\Facades\Schedule;
Schedule::command('emails:send')->everyMinute()->withoutOverlapping();
Dietro le quinte il metodo usa la cache dell’applicazione per prendere un lock. Finché il lock c’è, le esecuzioni successive vengono saltate.
Il dettaglio che conta: la scadenza del lock
Il lock ha una scadenza, e il valore predefinito è 24 ore. Si può accorciare passando i minuti:
// il lock scade dopo 10 minuti
Schedule::command('emails:send')->withoutOverlapping(10);
Perché serve una scadenza? Perché se il processo muore male — il server si riavvia, l’OOM killer se lo prende, qualcuno fa kill -9 — il lock resta lì. Senza scadenza il task non ripartirebbe mai più. Con la scadenza di default, non ripartirebbe per 24 ore: abbastanza per accorgersi il giorno dopo che il report notturno non è uscito.
Quindi la regola pratica: imposta la scadenza a un valore ragionevolmente più alto della durata massima plausibile del task. Se il comando normalmente ci mette due minuti e nel giorno peggiore dieci, withoutOverlapping(20) è una scelta sensata. Ventiquattro ore no.
Se un lock resta bloccato e serve sbloccarlo subito, c’è il comando dedicato:
php artisan schedule:clear-cache
La documentazione precisa che serve «only if a task becomes stuck due to an unexpected server problem». Se lo stai lanciando tutte le settimane, il problema non è il lock: è la scadenza che hai scelto.
Problema 2: il task parte su tre macchine
Scenario diverso. L’applicazione gira su tre server dietro un load balancer, e il deploy è identico su tutti e tre — cron compreso. Il report del venerdì sera viene generato tre volte. Le email partono tre volte.
Qui withoutOverlapping() non serve a niente, o quasi: il lock vive nella cache dell’applicazione, e se ogni macchina ha la sua cache locale, ogni macchina prende il suo lock e parte tranquilla.
Il metodo giusto è onOneServer():
Schedule::command('report:generate')
->fridays()
->at('17:00')
->onOneServer();
Il primo server che arriva prende un lock atomico sul task e gli altri due lo saltano.
Il prerequisito che fa fallire tutto in silenzio
onOneServer() funziona solo a due condizioni, ed è qui che la gente si brucia un pomeriggio:
- il driver di cache predefinito dell’applicazione deve essere
database,memcached,dynamodboredis; - tutti i server devono parlare con lo stesso server di cache centrale.
Nota una cosa: i lock atomici di Laravel sono supportati anche dai driver file e array — la pagina sulla cache li elenca insieme agli altri. Ma onOneServer() quei due non li accetta, e il motivo è ovvio quando lo vedi scritto: un lock su file è locale alla macchina. Tre macchine, tre file, tre lock, tre report. Esattamente il problema che volevi risolvere.
La buona notizia è che su un’installazione recente il requisito è già soddisfatto: la documentazione dice che «by default, Laravel is configured to use the database cache driver», e finché le tre macchine puntano allo stesso database va bene così. La cattiva è che chi in passato ha messo CACHE_STORE=file “tanto è più veloce” ha disattivato onOneServer() senza accorgersene, e nessuno glielo dice.
Se vuoi che lo scheduler usi uno store diverso da quello predefinito dell’applicazione, si dichiara una volta sola:
Schedule::useCache('database');
La trappola dei nomi
Il lock è identificato dal nome del task. Per un comando Artisan il nome c’è già. Per una closure no, e Laravel ha bisogno che tu gliene dia uno:
Schedule::call(fn () => User::resetApiRequestCount())
->name('reset-api-request-count')
->daily()
->onOneServer();
Stesso discorso quando pianifichi lo stesso job con parametri diversi: senza nomi distinti i due task si contendono lo stesso lock, e uno dei due non parte mai.
Schedule::job(new CheckUptime('https://laravel.com'))
->name('check_uptime:laravel.com')
->everyFiveMinutes()
->onOneServer();
Schedule::job(new CheckUptime('https://vapor.laravel.com'))
->name('check_uptime:vapor.laravel.com')
->everyFiveMinutes()
->onOneServer();
Si usano insieme
Non sono alternative. Un task lungo, su più server, li vuole entrambi: onOneServer() decide quale macchina lo esegue, withoutOverlapping() impedisce che su quella macchina ne partano due sovrapposti.
Schedule::command('reports:build')
->everyFifteenMinutes()
->onOneServer()
->withoutOverlapping(30)
->runInBackground();
Su runInBackground() una precisazione: i task pianificati alla stessa ora vengono eseguiti in sequenza, nell’ordine in cui li hai definiti, quindi un task lento ritarda tutti quelli dopo di lui. runInBackground() li sgancia — ma funziona solo con command() ed exec(), non con call() e job().
Cosa evitare
- Lasciare la scadenza di default su
withoutOverlapping(). Ventiquattro ore di blackout dopo un riavvio sfortunato sono tante. - Contare su
withoutOverlapping()per il multi-server. Risolve un problema diverso. - Mettere
onOneServer()con cachefile. Non protegge niente e non te lo dice nessuno. - Dimenticare
name()sulle closure. Idem. - Confondere
Schedule::...->withoutOverlapping()conCache::withoutOverlapping(). Il secondo è un’API della cache per limitare la concorrenza di una closure qualsiasi, non un metodo dello scheduler. Si somigliano solo nel nome.
Un ultimo consiglio banale ma utile: php artisan schedule:list mostra tutti i task pianificati e quando partiranno. È il primo posto da guardare quando un task “non è partito” — spesso scopri che non era pianificato affatto.
Fonti: Task Scheduling e Cache nella documentazione ufficiale di Laravel (13.x). Le sezioni Preventing Task Overlaps, Running Tasks on One Server e Atomic Locks sono identiche nella 12.x, quindi tutto quello che c’è scritto qui vale anche lì.
