2026-07-14
Pattern per job queue asincrone in produzione: retry, dead letter e concorrenza
Job queue in produzione: retry con backoff esponenziale, dead letter queue, idempotenza tra worker, graceful shutdown e metriche minime da monitorare.
Una job queue asincrona in produzione ha bisogno di almeno tre elementi per reggere il traffico reale: retry con backoff controllato, dead letter queue per i job che non recuperano, e gestione della concorrenza tra worker. Senza questi tre pezzi, i job falliti spariscono in silenzio o si accumulano fino a bloccare il sistema. È la configurazione minima che applichiamo in Snowinch prima di considerare una queue pronta per la produzione.
Il problema che emerge dopo il primo deploy
In sviluppo la queue funziona sempre. I job vengono elaborati, i task completati, nessun problema visibile. In produzione arriva il primo errore transitorio, un servizio esterno che non risponde, un timeout di rete, un picco di carico, e si scopre che il sistema non è costruito per gestirlo.
I job falliti rimangono bloccati in stato di errore senza retry automatico. Oppure vengono ritentati all’infinito con la stessa frequenza, saturando risorse e nascondendo il problema reale. Oppure spariscono silenziosamente perché nessuno ha configurato dove vanno i job che non recuperano.
Questi non sono casi limite, sono il comportamento normale di un sistema sotto pressione. La differenza tra una queue che regge e una che cede è nella configurazione, non nell’architettura di base.
Retry con backoff esponenziale
Il retry immediato è quasi sempre sbagliato. Se un job fallisce perché il servizio esterno è sovraccarico, ritentarlo subito aggiunge carico a un sistema già in difficoltà. Il backoff esponenziale, attendere un intervallo crescente tra un tentativo e l’altro, dà al sistema dipendente il tempo di recuperare.
// Esempio con BullMQ, verifica la versione installata per le opzioni disponibili
// https://docs.bullmq.io/guide/retrying-failing-jobs
const queue = new Queue('notifications', { connection: redisConnection });
await queue.add('send-email', payload, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000, // primo retry dopo 2s, poi 4s, 8s, 16s, 32s
},
});
Il numero di tentativi dipende dal tipo di job. Per operazioni critiche (pagamenti, notifiche che non possono essere perse) si può salire fino a 8–10 tentativi con delay massimo di qualche minuto. Per operazioni non critiche (aggiornamenti di cache, statistiche) anche 2–3 tentativi sono sufficienti.
Un dettaglio importante: il backoff si calcola dal momento del primo fallimento, non dall’orario di schedulazione originale. Un job schedulato per le 10:00 che fallisce può essere ritentato alle 10:00:02, 10:00:06, 10:00:14, non alle 10:02, 10:04, 10:08. La distinzione conta quando i job hanno requisiti temporali.
Dead letter queue
Quando un job esaurisce tutti i tentativi senza successo, deve andare da qualche parte. La dead letter queue (DLQ) è quella destinazione, non un cestino, ma un archivio ispezionabile da cui è possibile fare replay manuale o automatico dopo aver risolto il problema sottostante.
// Worker che sposta i job falliti in DLQ con contesto
const worker = new Worker('notifications', async (job) => {
await processJob(job.data);
}, {
connection: redisConnection,
});
worker.on('failed', async (job, error) => {
if (job && job.attemptsMade >= job.opts.attempts) {
// Job esaurito, sposta in DLQ con contesto dell'errore
const dlq = new Queue('notifications-dlq', { connection: redisConnection });
await dlq.add('failed-job', {
originalJob: job.data,
error: error.message,
failedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade,
});
}
});
La DLQ richiede monitoring. Un job in DLQ non è un problema risolto, è un problema in attesa di essere esaminato. Senza un alert sul volume della DLQ, i job falliti si accumulano invisibilmente.
-- Se usi Postgres come storage per la queue (es. con pg-boss)
-- alert quando la DLQ supera una soglia (adatta al tuo volume)
SELECT count(*) FROM jobs
WHERE queue_name = 'notifications-dlq'
AND created_at > now() - interval '1 hour';
Concorrenza tra worker e idempotenza
Con più istanze del worker in esecuzione in parallelo, due worker possono ricevere lo stesso job quasi simultaneamente, durante un failover, un restart, o per un bug nel sistema di locking della queue. Il job viene elaborato due volte.
La soluzione non è ridurre la concorrenza, ma rendere i job idempotenti: elaborare lo stesso job due volte deve produrre lo stesso risultato di elaborarlo una volta sola.
async function processJob(job: Job) {
const jobId = job.id;
// Verifica atomica: il job è già stato elaborato?
const alreadyProcessed = await db.transaction(async (trx) => {
const existing = await trx('processed_jobs')
.where({ job_id: jobId })
.first();
if (existing) return true;
// Inserisce il lock prima di procedere
await trx('processed_jobs').insert({
job_id: jobId,
processed_at: new Date(),
});
return false;
});
if (alreadyProcessed) {
// Non è un errore, è il comportamento corretto
return;
}
// Elaborazione effettiva del job
await doActualWork(job.data);
}
Questo pattern è lo stesso dell’idempotenza lato webhook descritto nell’articolo sui webhook Stripe, la logica si applica identicamente alle job queue.
Graceful shutdown
Quando un worker viene spento, per un deploy, un restart, una riduzione delle istanze, i job in elaborazione in quel momento possono essere interrotti a metà. Un graceful shutdown aspetta che i job correnti finiscano prima di chiudere il processo.
const worker = new Worker('notifications', processor, {
connection: redisConnection,
});
// Gestione SIGTERM, segnale inviato da Kubernetes, Docker, systemd al momento dello shutdown
process.on('SIGTERM', async () => {
// Smette di prendere nuovi job immediatamente
await worker.close();
// Attende che i job in corso completino (con timeout)
process.exit(0);
});
Il timeout del graceful shutdown deve essere inferiore al timeout configurato dall’orchestratore (Kubernetes, per esempio, aspetta di default 30 secondi prima di inviare SIGKILL). Se il job impiega più del timeout, viene interrotto comunque, per questo i job lunghi devono essere progettati per essere riprendibili o devono essere spezzati in job più piccoli.
Monitoring minimo
Una queue senza monitoring è una scatola nera. Le metriche che servono, indipendentemente dallo stack:
- Profondità della queue: quanti job in attesa. Un valore crescente senza fine indica che i worker non tengono il passo.
- Tasso di fallimento per tipo di job: non solo «quanti job falliscono» ma «quali job falliscono». Un tipo specifico con tasso alto indica un problema di implementazione, non di infrastruttura.
- Volume DLQ per ora: alert quando supera una soglia, i valori dipendono dal dominio e dal volume normale del sistema.
- Latenza di elaborazione: tempo tra inserimento in queue e completamento. Un aumento improvviso indica worker sotto pressione o job più lenti del normale.
Cosa questo articolo non copre
Le queue distribuite su più datacenter, la garanzia exactly-once (che richiede coordination overhead significativo), e i pattern di fan-out su larga scala, sono problemi reali ma emergono a volumi e complessità che richiedono una discussione separata. Per la maggior parte dei sistemi che crescono da MVP a produzione, i pattern descritti qui sono sufficienti e non richiedono infrastruttura aggiuntiva oltre Redis o Postgres.
Sintesi operativa
- Retry con backoff esponenziale, non immediato, il delay crescente protegge i sistemi dipendenti sotto pressione.
- La DLQ non è un cestino: è un archivio ispezionabile che richiede monitoring attivo e un processo di replay.
- I job devono essere idempotenti, l’elaborazione doppia è un caso normale, non eccezionale, in qualsiasi sistema distribuito.
- Il graceful shutdown evita interruzioni a metà elaborazione: il timeout deve essere calibrato sul tempo massimo di esecuzione dei job.
- Monitora profondità queue, tasso di fallimento per tipo, volume DLQ e latenza, senza queste quattro metriche la queue è una scatola nera.