Schedulare lavori in background senza i problemi di cron
Scopri pattern per schedulare lavori in background usando workflow e una tabella jobs per inviare promemoria, riepiloghi giornalieri e pulizie in modo affidabile.

Perché cron sembra semplice finché non lo è
Cron è ottimo il primo giorno: scrivi una riga, scegli un orario, te ne dimentichi. Per un server e un compito, spesso funziona.
I problemi emergono quando fai affidamento sulla schedulazione per comportamenti reali del prodotto: promemoria, riepiloghi giornalieri, pulizie o job di sincronizzazione. La maggior parte delle storie di “esecuzione mancata” non sono dovute a cron che fallisce. Sono tutto il resto intorno: un riavvio del server, un deploy che ha sovrascritto il crontab, un job che è durato più del previsto o una discrepanza di orologio o fuso orario. E una volta che esegui più istanze dell'app, puoi ottenere il fallimento opposto: duplicati, perché due macchine pensano di dover eseguire lo stesso compito.
Anche i test sono un punto debole. Una riga di cron non ti dà un modo pulito per eseguire “cosa succederebbe alle 9:00 di domani” in un test ripetibile. Così la schedulazione si trasforma in controlli manuali, sorprese in produzione e ricerca nei log.
Prima di scegliere un approccio, sii chiaro su cosa stai schedulando. La maggior parte del lavoro in background rientra in alcune categorie:
- Promemoria (inviare a un orario specifico, una sola volta)
- Riepiloghi giornalieri (aggregare dati e poi inviare)
- Attività di pulizia (cancellare, archiviare, scadere)
- Sincronizzazioni periodiche (pull o push di aggiornamenti)
A volte puoi evitare la schedulazione del tutto. Se qualcosa può avvenire al momento di un evento (un utente si registra, un pagamento va a buon fine, uno status cambia), il lavoro guidato dagli eventi è solitamente più semplice e affidabile del lavoro guidato dal tempo.
Quando hai bisogno del tempo, l'affidabilità dipende soprattutto da visibilità e controllo. Vuoi un posto dove registrare cosa deve essere eseguito, cosa è stato eseguito e cosa è fallito, oltre a un modo sicuro per ritentare senza creare duplicati.
Il pattern di base: scheduler, tabella jobs, worker
Un modo semplice per evitare i mal di testa di cron è dividere le responsabilità:
- Uno scheduler decide cosa deve essere eseguito e quando.
- Un worker esegue il lavoro.
Mantenere ruoli separati aiuta in due modi. Puoi cambiare la tempistica senza toccare la logica di business e puoi cambiare la logica di business senza rompere la schedulazione.
Una tabella jobs diventa la fonte di verità. Invece di nascondere lo stato dentro un processo server o una riga di cron, ogni unità di lavoro è una riga: cosa fare, per chi, quando deve essere eseguita e cosa è successo l'ultima volta. Quando qualcosa va storto, puoi ispezionarla, ritentare o annullarla senza indovinare.
Un flusso tipico assomiglia a questo:
- Lo scheduler scansiona i job dovuti (per esempio,
run_at <= nowestatus = queued). - Reclama un job in modo che solo un worker lo prenda.
- Un worker legge i dettagli del job ed esegue l'azione.
- Il worker registra il risultato nella stessa riga.
L'idea chiave è rendere il lavoro riprendibile, non magico. Se un worker va in crash a metà, la riga del job dovrebbe comunque dirti cosa è successo e cosa fare dopo.
Progettare una tabella jobs che rimane utile
Una tabella jobs dovrebbe rispondere a due domande rapidamente: cosa deve essere eseguito dopo e cosa è successo l'ultima volta.
Inizia con un piccolo set di campi che coprano identità, tempistica e progresso:
- id, type: un id unico più un tipo corto come
send_reminderodaily_summary. - payload: JSON validato con solo ciò che il worker necessita (per esempio
user_id, non l'intero oggetto utente). - run_at: quando il job diventa eleggibile per l'esecuzione.
- status:
queued,running,succeeded,failed,canceled. - attempts: incrementato ad ogni tentativo.
Poi aggiungi alcune colonne operative che rendono la concorrenza sicura e gli incidenti più facili da gestire. locked_at, locked_by e locked_until permettono a un worker di reclamare un job così non viene eseguito due volte. last_error dovrebbe essere un messaggio breve (ed eventualmente un codice errore), non un dump completo dello stack trace che gonfia le righe.
Infine conserva timestamp che aiutano sia il supporto sia il reporting: created_at, updated_at e finished_at. Questi permettono di rispondere a domande tipo “Quanti promemoria sono falliti oggi?” senza scorrere i log.
Gli indici contano perché il sistema ti chiede costantemente “qual è il prossimo?”. Due che solitamente ripagano:
(status, run_at)per recuperare rapidamente i job dovuti(type, status)per ispezionare o mettere in pausa una famiglia di job durante problemi
Per i payload, preferisci JSON piccoli e mirati e validalo prima di inserire il job. Memorizza identificatori e parametri, non snapshot dei dati di business. Tratta la forma del payload come un contratto API in modo che job in coda più vecchi funzionino ancora dopo che cambi l'app.
Ciclo di vita del job: stati, locking e idempotenza
Un job runner rimane affidabile quando ogni job segue un piccolo e prevedibile ciclo di vita. Quel ciclo di vita è la tua rete di sicurezza quando due worker partono insieme, un server si riavvia a metà esecuzione o devi ritentare senza creare duplicati.
Una semplice macchina a stati è di solito sufficiente:
- queued: pronto per l'esecuzione a partire da
run_at - running: reclamato da un worker
- succeeded: completato e non deve più essere eseguito
- failed: terminato con errore e necessita attenzione
- canceled: fermato intenzionalmente (ad esempio, l'utente ha annullato)
Reclamo dei job senza duplicare il lavoro
Per evitare duplicati, il reclamo di un job deve essere atomico. L'approccio comune è un lock con timeout (una lease): un worker reclama un job impostando status=running e scrivendo locked_by più locked_until. Se il worker va in crash, il lock scade e un altro worker può reclamarlo.
Un set di regole pratiche per il reclamo:
- reclama solo job
queuedil cuirun_at <= now - imposta
status,locked_byelocked_untilnella stessa update - reclama i job
runningsolo quandolocked_until < now - mantieni la lease corta ed estendila se il job è lungo
Idempotenza (l'abitudine che ti salva)
Idempotenza significa: se lo stesso job viene eseguito due volte, il risultato è comunque corretto.
Lo strumento più semplice è una chiave unica. Per esempio, per un riepilogo giornaliero puoi imporre un job per utente per giorno con una chiave come summary:user123:2026-01-25. Se avviene un inserimento duplicato, punta allo stesso job invece di crearne un secondo.
Marca il successo solo quando l'effetto collaterale è veramente completato (email inviata, record aggiornato). Se ritenti, il percorso di retry non deve creare una seconda email o una scrittura duplicata.
Retry e gestione dei fallimenti senza drammi
I retry sono il punto in cui i sistemi di job diventano affidabili o si trasformano in rumore. L'obiettivo è semplice: ritentare quando un errore è probabilmente temporaneo, fermarsi quando non lo è.
Una politica di retry di default di solito include:
- tentativi massimi (per esempio, 5 tentativi totali)
- strategia di ritardo (ritardo fisso o backoff esponenziale)
- condizioni di stop (non ritentare per errori tipo “input non valido”)
- jitter (un piccolo offset casuale per evitare picchi di retry)
Invece di inventare uno stato nuovo per i retry, puoi spesso riutilizzare queued: imposta run_at al tempo del prossimo tentativo e rimetti il job in coda. Questo mantiene la macchina a stati piccola.
Quando un job può fare progressi parziali, trattalo come normale. Memorizza un checkpoint così un retry può continuare in sicurezza, o nel payload del job (per esempio last_processed_id) o in una tabella correlata.
Esempio: un job di riepilogo giornaliero genera messaggi per 500 utenti. Se fallisce all'utente 320, memorizza l'ultimo id processato con successo e ritenta da 321. Se memorizzi anche un record summary_sent per utente per giorno, una nuova esecuzione può saltare gli utenti già completati.
Logging che aiuta davvero
Logga quanto basta per fare debug in pochi minuti:
- id del job, tipo e numero di tentativo
- input chiave (user/team id, intervallo di date)
- tempi (started_at, finished_at, next run time)
- riepilogo breve dell'errore (più stack trace se disponibile)
- conteggio degli effetti collaterali (email inviate, righe aggiornate)
Passo passo: costruire un loop scheduler semplice
Un loop di scheduler è un piccolo processo che si sveglia a intervalli fissi, cerca lavoro dovuto e lo consegna. L'obiettivo è affidabilità noiosa, non precisione perfetta. Per molte app, “svegliati ogni minuto” è sufficiente.
Scegli la frequenza di risveglio in base a quanto sono sensibili al tempo i job e quanto carico può sostenere il database. Se i promemoria devono essere quasi in tempo reale, esegui ogni 30–60 secondi. Se i riepiloghi giornalieri possono sbandare un po', ogni 5 minuti va bene ed è più economico.
Un loop semplice:
- Svegliati e prendi l'ora corrente (usa UTC).
- Seleziona i job dovuti dove
status = 'queued'erun_at <= now. - Reclama i job in modo sicuro così solo un worker può prenderli.
- Passa ciascun job reclamato a un worker.
- Dormi fino al tick successivo.
Il passo di reclamo è dove molti sistemi si rompono. Vuoi segnare un job come running (e memorizzare locked_by e locked_until) nella stessa transazione che lo seleziona. Molti database supportano letture “skip locked” così più scheduler possono girare senza pestarsi i piedi a vicenda.
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
Mantieni la dimensione del batch piccola (tipo 50–200). Batch più grandi possono rallentare il database e rendere i crash più dolorosi.
Se lo scheduler va in crash a metà batch, la lease ti salva. I job bloccati in running diventano eleggibili di nuovo dopo locked_until. Il tuo worker dovrebbe essere idempotente così un job reclamato di nuovo non crea email duplicate o addebiti doppi.
Pattern per promemoria, riepiloghi giornalieri e pulizie
La maggior parte dei team finisce con tre tipi di lavoro in background: messaggi che devono essere inviati in orario, report che girano con una cadenza e pulizie che mantengono storage e performance in salute. La stessa tabella jobs e il loop worker possono gestirli tutti.
Promemoria
Per i promemoria, memorizza tutto il necessario per inviare il messaggio nella riga del job: a chi è rivolto, quale canale (email, SMS, Telegram, in-app), quale template e l'orario esatto di invio. Il worker dovrebbe poter eseguire il job senza “andare a cercare” contesto aggiuntivo.
Se molti promemoria sono dovuti contemporaneamente, aggiungi rate limiting. Limita i messaggi al minuto per canale e lascia che i job extra attendano il prossimo run.
Riepiloghi giornalieri
I riepiloghi giornalieri falliscono quando la finestra temporale è sfocata. Scegli un cutoff stabile (per esempio, 08:00 nel fuso orario dell'utente) e definisci chiaramente la finestra (per esempio, “ieri 08:00 fino a oggi 08:00”). Memorizza il cutoff e il fuso orario dell'utente con il job così i rerun producono lo stesso risultato.
Mantieni ogni job di riepilogo piccolo. Se deve processare migliaia di record, spezzalo in chunk (per team, per account o per range di id) e metti in coda job di follow-up.
Pulizie
La pulizia è più sicura quando separi “cancellare” da “archiviare”. Decidi cosa può essere rimosso definitivamente (token temporanei, sessioni scadute) e cosa deve essere archiviato (log di audit, fatture). Esegui la pulizia in batch prevedibili per evitare lock lunghi e picchi di carico improvvisi.
Tempo e fusi orari: la fonte nascosta di bug
Molti fallimenti sono bug temporali: un promemoria arriva un'ora prima, un riepilogo salta il lunedì o la pulizia viene eseguita due volte.
Un buon default è memorizzare i timestamp di schedulazione in UTC e conservare separatamente il fuso orario dell'utente. Il tuo run_at dovrebbe essere un singolo istante UTC. Quando un utente dice “9:00 AM ora mia”, converti in UTC al momento della schedulazione.
L'ora legale è dove le configurazioni ingenue si rompono. “Ogni giorno alle 9:00” non è la stessa cosa di “ogni 24 ore”. Nei cambi DST, le 9:00 locali mappano a orari UTC diversi e alcuni orari locali non esistono (spring forward) oppure si ripetono (fall back). L'approccio più sicuro è calcolare la prossima occorrenza locale ogni volta che rischeduli, poi convertirla in UTC.
Per un riepilogo giornaliero, decidi cosa significa “giorno” prima di scrivere codice. Un giorno del calendario (mezzanotte-mezzanotte nel fuso orario dell'utente) corrisponde alle aspettative umane. “Ultime 24 ore” è più semplice ma deriva e sorprende le persone.
I dati tardivi sono inevitabili: un evento arriva dopo un retry, o una nota viene aggiunta pochi minuti dopo mezzanotte. Decidi se gli eventi tardivi appartengono a “ieri” (con un periodo di grazia) o a “oggi” e mantieni quella regola coerente.
Un buffer pratico può prevenire mancate esecuzioni:
- scansiona job dovuti fino a 2–5 minuti fa
- rendi il job idempotente così i rerun sono sicuri
- registra l'intervallo di tempo coperto nel payload così i riepiloghi restano coerenti
Errori comuni che causano esecuzioni mancanti o duplicate
La maggior parte dei problemi deriva da poche assunzioni prevedibili.
La più grande è assumere un'esecuzione “esattamente una volta”. Nei sistemi reali, i worker si riavviano, le chiamate di rete scadono e i lock possono perdersi. Tipicamente ottieni una consegna “almeno una volta”, il che significa che i duplicati sono normali e il tuo codice deve tollerarli.
Un altro errore è fare prima gli effetti collaterali (inviare email, addebitare la carta) senza un controllo di dedup. Una guardia semplice spesso risolve: un timestamp sent_at, una chiave unica come (user_id, reminder_type, date) o un token di dedup memorizzato.
La visibilità è il passo successivo. Se non puoi rispondere a “cosa è bloccato, da quando e perché”, finirai per indovinare. I dati minimi da tenere vicini sono status, conteggio tentativi, prossimo orario schedulato, ultimo errore e id del worker.
Gli errori che emergono più spesso:
- progettare job come se fossero eseguiti una sola volta e poi rimanerne sorpresi dai duplicati
- scrivere effetti collaterali senza un controllo di dedup
- eseguire un unico job enorme che prova a fare tutto e scade a metà
- ritentare all'infinito senza un limite
- saltare la visibilità base della coda (nessuna vista chiara di backlog, fallimenti, item a lunga esecuzione)
Un esempio concreto: un job di riepilogo giornaliero scorre 50.000 utenti e scade a 20.000. Al retry ricomincia dall'inizio e invia i riepiloghi di nuovo ai primi 20.000 a meno che tu non tenga traccia del completamento per utente o non lo divida in job per utente.
Checklist rapida per un sistema di job affidabile
Un job runner è “finito” solo quando puoi fidarti che funzioni alle 2 di notte.
Assicurati di avere:
- Visibilità della coda: contatori per queued vs running vs failed, più il job queued più vecchio.
- Idempotenza per default: assumi che ogni job possa essere eseguito due volte; usa chiavi uniche o marker “già processato”.
- Politica di retry per tipo di job: retry, backoff e una chiara condizione di stop.
- Memorizzazione del tempo coerente: tieni
run_atin UTC; converti solo all'input e alla visualizzazione. - Lock recuperabili: una lease così i crash non lasciano job in esecuzione per sempre.
Inoltre limita batch size (quanti job reclami alla volta) e concorrenza dei worker (quanti ne eseguono contemporaneamente). Senza limiti, un picco può sovraccaricare il database o privare altro lavoro delle risorse.
Un esempio realistico: promemoria e riepiloghi per un piccolo team
Un piccolo tool SaaS ha 30 account cliente. Ogni account vuole due cose: un promemoria alle 9:00 per i task aperti e un riepilogo giornaliero alle 18:00 di cosa è cambiato oggi. Hanno anche bisogno di una pulizia settimanale così il database non si riempie di log vecchi e token scaduti.
Usano una tabella jobs più un worker che interroga i job dovuti. Quando un nuovo cliente si registra, il backend programma le prime esecuzioni dei promemoria e dei riepiloghi in base al fuso orario del cliente.
I job vengono creati in pochi momenti comuni: alla registrazione (creare schedulazioni ricorrenti), su certi eventi (mettere in coda notifiche one-off), al tick dello scheduler (inserire esecuzioni future) e il giorno di manutenzione (mettere in coda pulizie).
Un martedì, il provider email ha un outage temporaneo alle 8:59. Il worker prova a inviare i promemoria, riceve un timeout e rischedula quei job impostando run_at con backoff (per esempio, 2 minuti, poi 10, poi 30), incrementando attempts ogni volta. Poiché ogni job promemoria ha una chiave di idempotenza come account_id + date + job_type, i retry non producono duplicati se il provider recupera a metà.
La pulizia gira settimanalmente in piccoli batch, così non blocca altro lavoro. Invece di cancellare un milione di righe in un job, cancella fino a N righe per esecuzione e si rischedula finché non ha finito.
Quando un cliente si lamenta “non ho ricevuto il mio riepilogo”, il team controlla la tabella jobs per quell'account e giorno: lo stato del job, il conteggio tentativi, i campi di lock correnti e l'ultimo errore restituito dal provider. Questo trasforma “avrebbe dovuto inviare” in “ecco esattamente cosa è successo”.
Prossimi passi: implementa, osserva, poi scala
Scegli un tipo di job e costruiscilo end-to-end prima di aggiungerne altri. Un singolo job promemoria è un buon punto di partenza perché tocca tutto: schedulazione, reclamo del lavoro dovuto, invio di un messaggio e registrazione degli esiti.
Inizia con una versione di cui ti puoi fidare:
- crea la tabella
jobse un worker che processa un tipo di job - aggiungi un loop scheduler che reclama ed esegue i job dovuti
- memorizza abbastanza payload da eseguire il job senza indovinare
- logga ogni tentativo e risultato così “È stato eseguito?” è una domanda da 10 secondi
- aggiungi una via manuale per rieseguire i job falliti così il recupero non richiede un deploy
Una volta che gira, rendilo osservabile per le persone. Anche una vista admin di base ripaga velocemente: cerca job per stato, filtra per tempo, ispeziona payload, annulla un job bloccato, riesegui uno specifico id di job.
Se preferisci costruire questo tipo di flusso scheduler-worker con logica backend visuale, AppMaster (appmaster.io) può modellare la tabella jobs in PostgreSQL e implementare il loop claim-process-update come Business Process, pur generando codice sorgente reale per il deploy.


