+39 3662317539/+34 620899163
info@simonecosci.com

Scheduler di Laravel: withoutOverlapping e onOneServer

Sviluppo Siti Web a Tenerife

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:

  1. il driver di cache predefinito dell’applicazione deve essere database, memcached, dynamodb o redis;
  2. 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 cache file. Non protegge niente e non te lo dice nessuno.
  • Dimenticare name() sulle closure. Idem.
  • Confondere Schedule::...->withoutOverlapping() con Cache::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ì.