
Maggio 2024, martedì pomeriggio, alert su Telegram dal canale interno di on-call: un job di indicizzazione Typesense per una testata era fallito tre volte di fila. Apro la sessione SSH sul nodo di queue del backend di AI Multisite, lancio tail -f su /var/log/supervisor/laravel-worker-stdout.log (path standard dell’installazione di supervisord, non rivelatore), e mi accorgo che il log è ruotato male: il file corrente è di 4 KB, gli archivi precedenti sono compressi e il grep su gzip -dc per trovare lo stack trace giusto sta richiedendo troppo. Otto minuti in totale per arrivare al job che mi interessa, capire che ha fatto OOM su un articolo da 14.000 caratteri serializzato con tutti i metadata, e decidere come retriarlo. Otto minuti che ripeti tre volte alla settimana fanno una giornata-uomo al mese di triage di code. Negli altri post di Production racconto le scelte di stack di AI Multisite: questo è il mio postmortem sulla scelta tra Horizon vs supervisord worker Laravel.
Horizon vs supervisord worker Laravel: il setup originale
Il backend Laravel di AI Multisite, da quando è in produzione, ha sempre processato i job asincroni con php artisan queue:work orchestrato da supervisord. Il file di configurazione laravel-worker.conf era il classico tutorial: cinque processi per la coda default, due processi per sync-wp (le chiamate REST verso le installazioni WordPress dei tenant), due processi per embeddings (le chiamate ai provider LLM per generare gli embedding di stilometria), un processo per billing (i job di fatturazione mensile).
Funzionava. Onestamente funzionava bene per quasi tutto: supervisord rilancia il processo se cade, gestisce i segnali, scrive su file di log. È software inossidabile da quindici anni, niente di rotto, niente di esoterico.
I problemi non erano sulla felicità del processo. Erano sulla mia capacità di vedere cosa stava succedendo dentro la queue:
- Visibilità per tenant assente. I worker generici non sapevano (e non dovevano sapere) quale tenant stesse spingendo job. Per arrivare alla domanda “quanti job in retry sta accumulando il tenant X stamattina?” dovevo fare query manuali su
failed_jobse sujobscon join sul payload serializzato. - Latenza p99 invisibile. Sapevo quanti job al minuto venivano processati guardando le metriche aggregate di MariaDB, ma non avevo distribuzione per coda, per tipo di job, per tenant.
- Recovery manuale. Se un job restava in
reservedper 30 minuti perché il worker era andato in OOM senza chiudere bene la connessione Redis, tornava processabile solo dopo il timeout configurato inconfig/queue.php. Per i job idempotenti non era un dramma. Per i job di fatturazione con effetti collaterali non idempotenti (rare ma esistenti) era un problema.
Provo a quantificare lo stato di partenza, perché senza numeri la decisione è di pancia: tempo medio di triage di un job fallito dalla notifica al fix, ~8 minuti, misurato su 23 incidenti del bimestre marzo-aprile 2024.
Cosa ho provato prima di cambiare runner
La prima reazione, a febbraio, è stata “tappare i buchi senza migrare”. Due interventi su supervisord stesso.
Log strutturati su Loki. Ho convertito i log dei worker da plaintext a JSON (Monolog formatter JsonFormatter), con campi standardizzati per tenant_id, queue, job_class, attempt_number. Promtail su quel nodo, push verso un’istanza Loki self-hosted, query da Grafana. Beneficio reale: il triage post-incidente è sceso da otto a circa cinque minuti, perché potevo grep per tenant_id invece di scorrere la timeline del file.
Dashboard Grafana custom su failed_jobs. Una tabella di MariaDB legge i fallimenti, li raggruppa per queue e per ora, ne disegna l’andamento. Utile per vedere i trend, inutile per la singola incident: la tabella è popolata solo quando il job fallisce definitivamente, non quando va in retry.
Dopo sei settimane di questo setup ho fatto i conti: il triage migliorava ma rimaneva sopra i quattro minuti di mediana, e la visibilità per coda in tempo reale (job in volo, throughput istantaneo, tempo medio di esecuzione per tipo di job) era ancora quella che non avevo. Stavo costruendo da capo una console di queue, e c’era una console di queue Laravel che esisteva già, con sei anni di iterazioni alle spalle.
La scelta: passare a Horizon
Laravel Horizon è il dashboard ufficiale per le queue Laravel su connection Redis. Tre cose mi interessavano davvero:
- Bilanciamento automatico dei worker. Configuri un budget di processi totale e Horizon distribuisce dinamicamente i worker fra le code in base a coda più trafficata. Niente più ribilanciamento manuale di
numprocssu supervisord quando una nuova testata aumenta il volume di sync. - Metriche per coda e per tipo di job nativi. Throughput, runtime medio, tempo trascorso in queue, numero di retry. Il tutto via interfaccia web protetta da gate Auth (
Horizon::auth(...)inapp/Providers/HorizonServiceProvider.php). - Tag dei job. Si possono taggare i job con
tags()nel job class. Per noi è stato banale taggare ogni job contenant:{id}epublication:{slug}, ottenendo automaticamente la vista per tenant che mi serviva.
La migrazione l’ho fatta in due giorni, con il senior dev a coppia su un branch separato.
Giorno 1. composer require laravel/horizon, php artisan horizon:install, php artisan horizon:publish. Editing di config/horizon.php per dichiarare i due ambienti (production e staging) con i supervisor che reggono le code. Configurazione di base per il primo profilo:
// config/horizon.php (estratto)
'environments' => [
'production' => [
'supervisor-default' => [
'connection' => 'redis-queue',
'queue' => ['high', 'default', 'sync-wp'],
'balance' => 'auto',
'maxProcesses' => 12,
'minProcesses' => 2,
'tries' => 3,
'timeout' => 90,
],
'supervisor-llm' => [
'connection' => 'redis-queue',
'queue' => ['embeddings', 'llm-completions'],
'balance' => 'auto',
'maxProcesses' => 6,
'minProcesses' => 1,
'tries' => 5,
'timeout' => 240,
],
'supervisor-billing' => [
'connection' => 'redis-queue',
'queue' => ['billing'],
'balance' => 'simple',
'maxProcesses' => 1,
'tries' => 2,
'timeout' => 600,
],
],
],
Tre supervisor distinti perché i workload sono diversi: il general purpose vuole tante code corte, il workload LLM vuole pochi worker con timeout lungo, il billing vuole un singolo worker seriale per evitare race condition sulle fatture (stessa filosofia della coda default su supervisord, espressa più dichiarativamente).
Giorno 2. Aggiunta dei tag dentro i job class:
// app/Jobs/SyncArticleToWordPress.php (estratto)
public function tags(): array
{
return [
'tenant:'.$this->tenant->id,
'publication:'.$this->publication->slug,
'job:sync-article',
];
}
Test in staging con replay di 4.000 job reali presi da una giornata-tipo, validazione che le metriche fossero coerenti, switch del processo unico di supervisord da php artisan queue:work a php artisan horizon. Supervisord rimane in piedi: ora orchestra un singolo processo Horizon, e Horizon orchestra i worker. Bel layering pulito, niente da buttare.
Cosa è andato storto in produzione
Il primo lunedì dopo il deploy, alle 6:42 del mattino, Grafana mi alza un alert: latenza p99 sull’API utente schizzata da ~95ms (con Octane) a ~640ms. Sento la sveglia, apro il portatile in cucina, controllo Horizon: il dashboard sta caricando lentamente. Apro redis-cli sul nodo di queue: INFO memory, used_memory_human:6.91G su 8 GB di maxmemory. Quello è il momento in cui ho capito di aver creato un problema nuovo migrando.
Il colpevole l’ho trovato in 12 minuti, dopo aver fatto redis-cli --bigkeys sul namespace di Horizon: una manciata di chiavi horizon:*:job:* con valori da 14 MB ciascuna. Un job di re-indicizzazione Typesense per una testata grossa veniva costruito serializzando dentro il payload tutto l’articolo (titolo, body, metadata, vector di 384 float, tutto). Su supervisord questo non era un problema, perché il job veniva preso dal worker, processato, e il payload sparato dalla queue. Su Horizon, di default, ogni job completato resta visibile nel dashboard per un periodo di retention (1 ora per i completati, 7 giorni per i falliti). Migliaia di job al giorno, payload medio 14 MB, RAM Redis che si gonfia in poche ore.
Ai tempi del cambio non avevo ancora pensato alle implicazioni della retention sui payload grossi. Lezione costosa.
Il fix: payload by reference, threshold custom, prune aggressivo
Tre cose, in ordine di urgenza.
Prima: payload by reference, non by value. Ho riscritto i job che potenzialmente sforavano per passare l’id del modello al posto del modello serializzato.
// PRIMA (rotto):
SyncArticleToTypesense::dispatch($article); // $article: Eloquent model con body 14k caratteri + vector
// DOPO:
SyncArticleToTypesense::dispatch($article->id);
// Dentro il job:
public function handle(): void
{
$article = Article::find($this->articleId);
if (!$article) {
return; // articolo eliminato fra dispatch e processing, idempotente
}
// ... resto del job
}
Effetto immediato: il payload tipico del job di indicizzazione è sceso da ~14 MB a ~0,3 KB. Niente di nuovo concettualmente (è il pattern raccomandato anche dalla doc Laravel sui job dispatch), avevo lasciato passare la cattiva pratica perché su supervisord non era visibile. Su Horizon è esplosa.
Seconda: thresholds custom su Horizon. In config/horizon.php:
'trim' => [
'recent' => 60, // job recenti tenuti per 1 ora
'pending' => 60,
'completed' => 30, // completati: 30 minuti invece di 60
'recent_failed' => 10080, // 7 giorni
'failed' => 10080,
'monitored' => 10080,
],
Trim più aggressivo sui completati, perché la dashboard non beneficia di tenere una settimana di completati su un workload da decine di migliaia di job al giorno.
Terza: monitoring su Redis maxmemory. Una metrica Prometheus dedicata che esporta redis_memory_used_bytes / redis_memory_max_bytes sul nodo di queue, con alert a 75% e a 88%. Dovevo averlo già da prima, in realtà. Avere Octane con tre Redis distinti (cache, session, queue) e monitorare solo il primo era una svista mia di gennaio.
Dopo questi tre fix, la RAM Redis è scesa stabilmente sotto i 2,1 GB, l’API ha ripreso la latenza p99 di ~95ms, e il dashboard di Horizon ha smesso di laggare.
I numeri del cambio Horizon vs supervisord worker Laravel
Confronto bimestre marzo-aprile (supervisord con log Loki) vs bimestre giugno-luglio (Horizon con i fix sopra applicati).
Triage di un job fallito, dalla notifica al fix:
- Prima: 8 minuti di mediana, deviazione ampia, code lunghe il lunedì mattina.
- Dopo: ~90 secondi di mediana, perché clicco sul tag del tenant in Horizon, vedo i job recenti, leggo lo stack trace, retry con un click se serve.
Throughput sostenuto del backend a parità di carico:
- Prima: ~1.450 job/minuto sul nodo di queue al picco, 8 worker totali.
- Dopo: ~1.510 job/minuto, 19 worker totali distribuiti automaticamente. Throughput leggermente migliore, ma è secondario: l’auto-balance fa la differenza nel comportamento sotto picchi non lineari (una testata che rilancia 8.000 articoli per re-import).
Latenza p99 dei job di tipo sync-article:
- Prima: ~3,4s
- Dopo: ~3,1s
Praticamente invariata, com’era previsto: Horizon non rende più veloci i job, rende più visibile cosa succede.
Tempo di provisioning di una nuova coda quando aggiungo una funzione:
- Prima: editare
laravel-worker.conf, riavviare supervisord, validare consupervisorctl status, sperare di non aver introdotto un typo nel block. - Dopo: editare l’array di
config/horizon.php, eseguirephp artisan horizon:terminate(Horizon si rilancia da supervisord stesso), validare nel dashboard. Più rapido e più dichiarativo.
Cosa rifarei diversamente
Una cosa, sopra le altre: avrei attivato php artisan horizon:snapshot da subito.
Horizon storicizza le metriche aggregate di throughput e di runtime tramite uno snapshot pianificato, di default disattivato. Il dashboard live mostra gli ultimi minuti, ma per vedere “come sta evolvendo il throughput della coda embeddings sull’ultimo trimestre?” servono gli snapshot persistiti. Io l’ho attivato solo dopo sei settimane, quando mi sono accorto di non avere serie storiche per il review trimestrale di capacità. Sei settimane di dati persi. Snapshot ogni 5 minuti via scheduler, costo computazionale trascurabile, beneficio operativo reale. Dovrebbe essere il primo comando dopo composer require laravel/horizon.
Un’altra cosa, più organizzativa: avrei separato fin dall’inizio il Redis di queue dal Redis di cache. L’avevo già fatto su due istanze ma sullo stesso nodo. Il payload da 14 MB ha congestionato la rete locale fra cache e queue per qualche minuto. Oggi sono su due nodi separati, con maxmemory-policy diverso (allkeys-lru per la cache, noeviction per la queue, perché evictare un job significa perderlo).
Cosa NON ho buttato di supervisord
Supervisord rimane sul nodo, e ne sono contento.
Due demoni Python custom che non hanno motivo di stare in Horizon:
- Un consumer di stilometria che legge da una directory di output, calcola feature linguistiche con spaCy e scikit-learn, e scrive il risultato su un’altra directory. È un demone “pipe-and-filter” puro, non un job di queue Laravel: trasformazione streaming su un workload che PHP/Laravel non gestisce bene.
- Un sentinel che monitora il lock distribuito su Redis usato dalle pipeline LLM, e che fa cleanup di lock orfani dopo timeout configurato. Storicamente scritto in Python perché usa una libreria di Redis lock specifica.
Questi due demoni stanno sotto supervisord, partono al boot del nodo, hanno log su file (basta lì, non c’è ragione di Loki). Horizon è uno strumento per le queue Laravel, non un orchestratore generico. Mantenere i tool dove servono, senza forzare uniformità.
A maggio 2024 ho cambiato runner. A giugno ho fixato il payload da 14 MB. A luglio ho attivato gli snapshot. Da agosto il triage di un job fallito è un click, e la queue Laravel di AI Multisite è uno dei pezzi di infrastruttura che mi danno meno preoccupazioni operative. Non perché supervisord fosse sbagliato, ma perché Horizon era lo strumento giusto per il workload diventato troppo articolato per il livello di osservabilità che avevo. Lo sceglierei di nuovo, esattamente nello stesso modo, evitando solo il payload da 14 MB.
