Task scheduling robusto in Laravel: horizon, workers e gestione dei fallimenti

Task scheduling robusto in Laravel: horizon, workers e gestione dei fallimenti

Il 2 maggio 2025 sono stato contattato d'urgenza dal CEO di un'azienda marchigiana che opera nel settore della consulenza energetica per PMI italiane - 28 dipendenti, fatturato annuo di circa 4,2 milioni di euro, una piattaforma proprietaria Laravel che gestisce il monitoraggio dei consumi energetici di circa 380 clienti e la generazione automatica di report mensili di analisi. Il CEO mi aveva chiamato dopo una giornata particolarmente difficile: tre clienti enterprise avevano contestato simultaneamente l'assenza del loro report mensile di analisi atteso il primo del mese, scoprendo che non era mai stato generato. L'ispezione manuale del team tecnico aveva rivelato che il batch di generazione mensile dei report era fallito silenziosamente la notte del primo maggio per un errore di elaborazione di un singolo cliente con dati anomali, senza che nessun sistema avesse alertato il team. Il controllo degli ultimi tre mesi di log aveva rivelato che il pattern era ricorrente: in circa il 40% dei mesi, almeno un job di elaborazione falliva silenziosamente, producendo report incompleti o assenti per un sottoinsieme variabile di clienti, senza che nessuno se ne accorgesse fino ai reclami.

Il sistema di code Laravel dell'azienda era la versione più elementare possibile: driver database, nessun Horizon, worker Supervisor minimale, nessun monitoring esplicito, gestione dei fallimenti limitata al retry automatico standard di Laravel. In pratica, quando un job falliva dopo tutti i retry, finiva nella tabella failed_jobs e nessuno lo guardava. Il batch mensile generava 380 job (uno per cliente), i job che fallivano per qualsiasi ragione non disturbavano il completamento degli altri - quindi il batch "sembrava" terminato con successo dal punto di vista operativo, ma una percentuale variabile di clienti non aveva il report. In sei giornate di lavoro distribuite in tre settimane, ho ristrutturato completamente l'architettura di code del sistema: installazione di Laravel Horizon con dashboard di monitoring, separazione in code prioritarie (transactional high, reports medium, analytics low), implementazione di dead letter queue per job falliti definitivamente, integrazione con Slack per alert real-time su ogni job fallito, audit log persistente per compliance, test automatizzati di resilienza per verificare che fallimenti parziali non nascondano errori totali. Nei sei mesi successivi al go-live della nuova architettura, zero incidenti silenziosi, zero reclami clienti per report mancanti, e dashboard operativa consultata quotidianamente dal team. Costo consulenziale dell'intervento: 8.600 euro.

Questo articolo descrive i pattern di implementazione di task scheduling e code Laravel robuste in produzione, basato sull'esperienza di circa 30 progetti simili negli ultimi cinque anni. Il principio guida è uno: le code Laravel sono semplici da avviare ma difficili da rendere veramente affidabili in produzione senza disciplina architetturale. La differenza fra un sistema che "sembra funzionare" e uno che è effettivamente robusto è tutta nella gestione dei fallimenti e nel monitoring continuo.

Perché il driver "database" di Laravel non è adatto a produzione seria

Il driver database per Laravel Queue è il più semplice da configurare (nessuna dipendenza aggiuntiva, funziona con il database già presente) e per questo è tipicamente il primo che vedo in applicazioni PMI italiane. Funziona bene per volumi modesti e applicazioni non critiche, ma ha tre limiti strutturali che emergono in produzione seria.

Primo limite: lock contention sul database. Quando più worker interrogano contemporaneamente la tabella jobs per prelevare il prossimo lavoro, generano lock di riga e transazioni concorrenti che saturano il database sotto volumi alti (oltre 1000 job/minuto). Secondo limite: polling inefficiente. I worker con driver database fanno polling periodico della tabella anche quando non c'è lavoro, generando carico database costante anche durante ore di inattività. Terzo limite: difficoltà di monitoring efficace. Contare job pendenti, misurare tempi di elaborazione, tracciare fallimenti richiede query sulla tabella jobs che entrano in competizione con i worker stessi.

La soluzione standard per produzione seria è il driver redis, più Laravel Horizon come layer di monitoring e gestione. Redis come backend elimina lock contention (strutture dati dedicate a queue, operazioni atomiche lock-free), elimina polling (Horizon usa BRPOPLPUSH che blocca fino a disponibilità di nuovo job), offre visibilità completa via Horizon dashboard. La documentazione ufficiale Laravel Horizon è il riferimento canonico per configurazione e operatività.

Se gestisci un'applicazione Laravel con code in produzione e sei ancora sul driver database, o stai valutando l'introduzione di un sistema di code robusto, nel mio profilo professionale trovi il dettaglio degli interventi di ristrutturazione queue in contesti PMI, sempre con approccio pragmatico e orientato alla stabilità operativa.

L'architettura Horizon: worker supervisor, code prioritarie, dashboard

Laravel Horizon è il componente ufficiale di Laravel per la supervisione di code Redis. Offre quattro funzionalità critiche integrate. Prima funzionalità: supervisor di worker - gestisce un pool di processi worker, riavvia quelli che crashano, scala dinamicamente il numero in funzione del carico. Seconda funzionalità: dashboard real-time accessibile via browser, mostra job in attesa, in elaborazione, completati, falliti per ogni coda, con grafici di throughput e latency. Terza funzionalità: retry UI per rilanciare job falliti con un click dall'interfaccia. Quarta funzionalità: metriche storiche persistite per analisi trend nel tempo.

La configurazione di Horizon avviene nel file config/horizon.php con un pattern di supervisor dedicati per ciascuna coda. Per il cliente marchigiano, la configurazione finale è stata:

'environments' => [
    'production' => [
        'supervisor-transactional' => [
            'connection' => 'redis',
            'queue' => ['transactional_high'],
            'balance' => 'auto',
            'maxProcesses' => 8,
            'memory' => 256,
            'timeout' => 90,
            'tries' => 5,
        ],
        'supervisor-reports' => [
            'connection' => 'redis',
            'queue' => ['reports_medium'],
            'balance' => 'simple',
            'maxProcesses' => 4,
            'memory' => 512,
            'timeout' => 900,
            'tries' => 3,
        ],
        'supervisor-analytics' => [
            'connection' => 'redis',
            'queue' => ['analytics_low'],
            'balance' => 'simple',
            'maxProcesses' => 2,
            'memory' => 512,
            'timeout' => 1800,
            'tries' => 2,
        ],
    ],
],

Tre supervisor separati, uno per priorità. Il supervisor transactional ha 8 worker con timeout breve (90 secondi) e molti retry (5) - è ottimizzato per latency bassa su job leggeri critici. Il supervisor reports ha 4 worker con timeout più lungo (15 minuti) e memoria più alta (512 MB) - ottimizzato per job di elaborazione medi come la generazione di PDF o l'aggregazione di dati mensili. Il supervisor analytics ha 2 worker con timeout lungo (30 minuti) e retry minimi - ottimizzato per job di data warehousing dove il completamento non è urgente.

La separazione in supervisor indipendenti implementa il principio di isolamento del failure domain di cui ho parlato nel mio articolo sul Symfony Messenger per code asincrone robuste su processi business critici, con pattern analogo applicato all'ecosistema Laravel.

Gestione dei fallimenti: retry strategy, dead letter queue, alert automatici

Il vero valore di un'architettura di code robusta emerge nella gestione dei fallimenti. Un job che fallisce può farlo per due ragioni fondamentali: transitoria (network timeout, database lock momentaneo, servizio esterno temporaneamente offline) o permanente (dati malformati, bug applicativo, risorsa mancante). I due scenari richiedono gestione diversa.

Per fallimenti transitori, il pattern corretto è retry con backoff esponenziale. Laravel supporta nativamente questo pattern tramite metodo backoff() del job. Per il cliente marchigiano, i job di generazione report hanno questa configurazione:

class GenerateMonthlyReport implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 900;

    public function backoff(): array
    {
        return [60, 300, 900];
    }

    public function handle(): void
    {
        // ...
    }

    public function failed(\Throwable $exception): void
    {
        app(FailedJobNotifier::class)->notify($this, $exception);
    }
}

Primo retry dopo 1 minuto, secondo dopo 5 minuti, terzo dopo 15 minuti. Se dopo tre tentativi il job fallisce ancora, viene marcato come definitivamente fallito e viene chiamato automaticamente il metodo failed() che notifica il team via Slack.

Il metodo FailedJobNotifier implementa l'integrazione con Slack che ho configurato sul cliente marchigiano: invia un messaggio strutturato in un canale dedicato #queue-failures con dettaglio del job fallito (classe, parametri, eccezione, stack trace abbreviato), link diretto al Horizon dashboard per visualizzazione completa, bottone per rilancio manuale. Il pattern è:

namespace App\Infrastructure\Queue;

final class SlackFailedJobNotifier implements FailedJobNotifier
{
    public function __construct(
        private readonly SlackClient $slack,
        private readonly string $channel
    ) {}

    public function notify(object $job, \Throwable $exception): void
    {
        $this->slack->send($this->channel, [
            'text' => sprintf(
                'Job failed: %s - %s',
                $job::class,
                $exception->getMessage()
            ),
            'attachments' => [[
                'color' => 'danger',
                'fields' => [
                    ['title' => 'Job ID', 'value' => $job->job->getJobId()],
                    ['title' => 'Queue', 'value' => $job->queue],
                    ['title' => 'Stack', 'value' => substr($exception->getTraceAsString(), 0, 2000)],
                ],
            ]],
        ]);
    }
}

La differenza operativa rispetto al setup precedente è drammatica: prima, un job fallito finiva in failed_jobs e nessuno lo guardava; ora, un job fallito genera una notifica Slack visibile al team di sviluppo entro pochi secondi, con dettagli sufficienti per triage immediato.

Batch jobs e verifica di completezza

Il pattern specifico del cliente marchigiano - 380 job di generazione report lanciati sequenzialmente - richiede un'attenzione particolare che va oltre la gestione del singolo job. Il rischio è che N-1 job completino con successo ma 1 job fallisca, e nessuno si accorga del failure parziale. Laravel offre nativamente il meccanismo di Batch jobs che permette di tracciare un gruppo di job come una singola entità, monitorando completion e failure a livello di batch.

Il pattern corretto per il batch mensile di report è:

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$jobs = $clients->map(
    fn ($client) => new GenerateMonthlyReport($client, $month)
)->all();

$batch = Bus::batch($jobs)
    ->name("Monthly reports {$month->format('Y-m')}")
    ->allowFailures(false)
    ->then(function (Batch $batch) {
        dispatch(new NotifyBatchCompleted($batch));
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        dispatch(new NotifyBatchFailedCritical($batch, $e));
    })
    ->finally(function (Batch $batch) {
        dispatch(new ArchiveBatchResults($batch));
    })
    ->dispatch();

Il batch tiene traccia di quanti job sono stati dispatchati, quanti completati, quanti falliti. Il callback then scatta solo se tutti i job sono completati con successo. Il callback catch scatta se un job qualsiasi fallisce definitivamente. Il callback finally scatta sempre, permettendo cleanup o archiviazione dei risultati.

Il pattern allowFailures(false) indica che un fallimento in qualsiasi job marca l'intero batch come fallito. Questa è la difesa strutturale contro il problema originale del cliente marchigiano: non è più possibile che "il batch sembri completato con successo" quando in realtà alcuni job sono falliti - il framework stesso traccia centralmente lo stato del batch e genera il callback appropriato.

Monitoring e metriche con Prometheus per visibilità production

Horizon offre dashboard integrata utilissima per investigation in tempo reale, ma per visibilità operativa continua su piattaforma di monitoring aziendale (Grafana, DataDog, New Relic) serve esportazione metriche in formato standard. Il pattern che applico è l'integrazione con Prometheus tramite il pacchetto spatie/laravel-prometheus che espone un endpoint /metrics con metriche Horizon.

Le metriche chiave che esporto sono: numero di job pendenti per coda, throughput (job/secondo) per coda, durata media elaborazione per classe di job, tasso di fallimento per classe di job, numero di worker attivi per supervisor. Queste metriche alimentano un dashboard Grafana dedicato con alert automatici. Gli alert che configuro di default sono: coda con più di 100 job pendenti da oltre 10 minuti (backlog cresce), worker completamente inactive per più di 2 minuti (supervisor crashato), tasso di fallimento superiore al 5% in finestra di 30 minuti (problema applicativo sistemico), durata elaborazione oltre 3x baseline su classe specifica (degrade performance).

Questo livello di monitoring è coerente con i principi di osservabilità che ho descritto nel mio articolo sulla pipeline CI/CD con GitHub Actions per deploy Laravel su VPS unmanaged, dove monitoring e deploy sono integrati in un modello operativo unificato.

Deploy zero-downtime con worker restart graceful

Un aspetto operativo critico di qualunque sistema con worker long-running è il deploy senza perdita di job in corso. Un deploy ingenuo che fa supervisorctl restart horizon-worker:* uccide bruscamente tutti i worker - qualunque job in corso viene interrotto a metà, con effetti imprevedibili (email inviate a metà, scritture database parziali, file partialmente generati).

La soluzione corretta è il comando Laravel horizon:terminate che invia un segnale graceful ai worker: finisci il job corrente, poi esci. Dopo la terminazione graceful, Supervisor rilancia automaticamente i worker con il nuovo codice. Il pattern di deploy diventa:

php artisan horizon:terminate
# attendi che tutti i worker finiscano i job in corso (timeout massimo configurabile)
supervisorctl restart horizon:horizon-worker
# i worker ripartono con il codice nuovo

Con timeout configurato su 5-15 minuti a seconda dei job più lunghi previsti, il deploy zero-downtime dei worker è garantito. Per job che superano il timeout, il framework di Horizon ha il fallback di riassegnare il job a un nuovo worker dopo il timeout - purché il job sia idempotente (che dovrebbe sempre essere, come descritto nell'articolo Symfony Messenger).

Pattern di test per resilienza: simulare fallimenti e verificare alert

L'aspetto che spesso viene dimenticato nella configurazione di sistemi di code è il test esplicito del path di fallimento. Non basta che il sistema funzioni in condizioni normali - deve essere verificato che i meccanismi di retry, failure notification, e alert funzionino davvero quando qualcosa va male. Il pattern di test che applico include test di integrazione specifici che verificano:

  1. Un job che genera eccezione al primo tentativo viene rilanciato secondo la backoff strategy.
  2. Un job che fallisce definitivamente dopo tutti i retry innesca la notifica Slack configurata.
  3. Un batch con un job fallito scatena il callback catch e non il callback then.
  4. Un worker che supera il timeout di memoria o di esecuzione viene terminato dal supervisor.
  5. Un job marcato come idempotent non produce effetti duplicati se rilanciato.

Questi test sono tipicamente in tests/Integration/Queue/ come test che esercitano l'intero flusso di coda con Redis reale (non fake). Sono più lenti dei test unitari ma catturano problemi strutturali che i test unitari non possono catturare. Il pattern di test di resilienza si integra con i pattern aggiornati di Laravel 12 per testare queue con Queue::fake e withFakeQueueInteractions che ho descritto in un articolo dedicato, dove la combinazione di fake testing unitario e test di integrazione con queue reale produce una suite di test completa e affidabile.

Il risultato finale dell'intervento sul cliente marchigiano a sei mesi dal go-live è stato il seguente. Zero incidenti silenziosi di job falliti senza alert team. Tempo medio di detection di problemi nei batch: sceso da "reclami clienti dopo giorni" a "notifica Slack entro 30 secondi dal fallimento definitivo". Dashboard Horizon consultato dal team operations quotidianamente per check di routine. Tasso di completamento del batch mensile di report: 100% senza interventi manuali nei sei mesi (contro il 60-70% pre-intervento). Tempo di risposta utente in applicazione invariato grazie alla separazione in code prioritarie. Costo operativo infrastrutturale aggiuntivo: trascurabile (Redis e Horizon girano sull'infrastruttura esistente). Tempo ricuperato dal team technical support non più impegnato a gestire reclami clienti per report mancanti: stimato in oltre 200 ore nei primi sei mesi, equivalenti a circa 15.000 euro di costo opportunity. ROI dell'intervento sulla prima annualità: oltre 3x, senza contare il beneficio qualitativo sulla fiducia dei clienti enterprise che non hanno più sperimentato report mancanti.

Se gestisci un'applicazione Laravel con sistema di code in produzione e non hai ancora implementato Horizon con monitoring e gestione fallimenti strutturata, la probabilità statistica che tu stia subendo incidenti silenziosi simili a quelli del cliente marchigiano è significativa - e la natura "silenziosa" del problema fa sì che tu non lo sappia fino a quando un cliente importante non si lamenta. L'investimento in ristrutturazione è contenuto (5-8 giornate di consulenza senior) e produce beneficio operativo strutturale. Se vuoi confrontarti sul tuo caso specifico con una proposta di architettura robusta calibrata sul tuo dominio e volumi, contattami per una consulenza preliminare: in una sessione di analisi guidata produciamo insieme una mappatura dei job critici del tuo sistema, una configurazione Horizon con supervisor appropriati per priorità e carico, e una roadmap di implementazione con monitoring e alert strutturati per prevenzione proattiva dei problemi operativi.

Ultima modifica: