4 min di lettura
Cron job in produzione: lock su Postgres, ora legale e lavori che si possono rifare
Un job programmato che in locale funziona può girare due volte, saltare un giorno al cambio dell'ora o mandare la stessa email tre volte. Come evitarlo con advisory lock, date calcolate nel fuso giusto e chiavi di idempotenza.
- Automazioni
- Backend
- PostgreSQL
«Ogni notte alle 2:30 manda i promemoria.» In locale funziona al primo colpo. In produzione, dopo qualche mese, arrivano le segnalazioni: un promemoria arrivato due volte, uno mai arrivato a fine marzo, un report calcolato sul giorno sbagliato. Non sono bug strani: sono i tre problemi classici dei job programmati. Li ho incontrati tutti costruendo i promemoria e la pulizia dati di FormaliaSuite HR.
Problema 1: due copie del server, due esecuzioni
Appena l'app gira su più istanze (per reggere il carico, o solo durante un deploy, quando vecchia e nuova versione convivono per qualche secondo), ogni istanza ha il suo timer. Risultato: lo stesso job parte due volte.
Se hai già PostgreSQL, la soluzione più semplice è un advisory lock: un lucchetto identificato da un numero, gestito dal database, che non blocca nessuna tabella.
const LOCK_PROMEMORIA = 7_310_001; // un numero fisso per ogni job
await sql.begin(async (tx) => {
const [{ ok }] = await tx`select pg_try_advisory_xact_lock(${LOCK_PROMEMORIA}) as ok`;
if (!ok) return; // un'altra istanza lo sta già facendo: esco
await inviaPromemoria(tx);
}); // il lock si libera da solo a fine transazione
Due dettagli che contano:
pg_try_advisory_xact_locknon aspetta: se il lock è occupato restituiscefalsee tu esci. È quello che vuoi per un job: non ha senso farlo due volte in fila.- La versione
_xact_si libera da sola alla fine della transazione, anche se il processo muore. Con la versione di sessione, se dimentichi lounlocko usi un connection pooler in modalità transaction (come PgBouncer o il pooler di Supabase), il lock può restare appeso o finire sulla connessione sbagliata.
Problema 2: l'ora legale
In Italia l'ora cambia l'ultima domenica di marzo (alle 2:00 si passa alle 3:00) e l'ultima domenica di ottobre (alle 3:00 si torna alle 2:00). Un cron in ora locale alle 2:30 quel giorno di marzo non esiste, e quel giorno di ottobre esiste due volte. A seconda dello scheduler il job salta o parte due volte.
Le regole che seguo:
- Programma in UTC, oppure evita la fascia 2:00–3:00 se devi usare l'ora locale.
- Calcola le date nel fuso dell'azienda, non in quello del server. Il server spesso è in UTC: a mezzanotte e mezza italiana, per lui è ancora «ieri». In PostgreSQL:
-- "oggi" per un'azienda italiana, indipendente dal fuso del server
select (now() at time zone 'Europe/Rome')::date as oggi;
- Salva istanti come
timestamptz, mai come ora locale senza fuso. La conversione si fa solo quando mostri il dato o quando ti serve «il giorno» di qualcuno.
Problema 3: il job che si può rifare
Un job prima o poi si interrompe a metà: deploy, crash, timeout. Al giro dopo riparte. Se ha mandato 40 email su 100 e riparte da capo, 40 persone ricevono la stessa email due volte.
Il job deve essere idempotente: rifarlo non cambia il risultato. Il modo più solido è di nuovo un vincolo nel database, registrato prima dell'azione:
create table promemoria_inviati (
dipendente_id uuid not null,
giorno date not null,
tipo text not null check (tipo in ('entrata', 'uscita')),
inviato_il timestamptz not null default now(),
primary key (dipendente_id, giorno, tipo)
);
insert into promemoria_inviati (dipendente_id, giorno, tipo)
values ($1, $2, 'entrata')
on conflict do nothing
returning dipendente_id; -- nessuna riga = già inviato, salto
Scrivere la riga prima dell'invio significa accettare un rischio preciso: se il processo muore tra la riga e l'email, quel promemoria non parte. Per un promemoria è il compromesso giusto (meglio uno in meno che tre uguali); per un pagamento sceglieresti il contrario, con uno stato «in corso» e un controllo successivo.
Un job a intervalli recupera da solo
Invece di «alle 9:00 manda i promemoria di entrata», i promemoria di FormaliaSuite HR girano ogni 5 minuti e chiedono al database «chi è in ritardo di almeno 30 minuti e non ha ancora ricevuto il promemoria oggi?». Se il server è rimasto giù dalle 8:50 alle 9:20, al primo giro utile recupera tutto da solo. Un cron a orario fisso, invece, quel giorno non parte e basta.
Lo stesso vale per la pulizia: «cancella tutto ciò che ha più di 10 anni» è una condizione, non un evento, e un giro saltato si recupera al giro dopo.
Sapere quando un job non è partito
Un job che fallisce rumorosamente si nota. Uno che non parte no. Due abitudini:
- Ogni job a fine giro registra durata e numero di elementi elaborati (anche solo una riga di log strutturato).
- Un heartbeat: a fine giro il job chiama un URL di un servizio di monitoraggio; se la chiamata non arriva entro l'intervallo previsto, ricevi un avviso. Servizi come Healthchecks.io fanno esattamente questo.
Checklist
- Un solo esecutore alla volta: advisory lock (o una coda con un solo consumatore).
- Orari in UTC o fuori dalla fascia 2:00–3:00; «il giorno» calcolato nel fuso dell'azienda.
- Ogni effetto esterno (email, chiamate, file) protetto da una chiave unica nel database.
- Job a intervalli con condizioni, invece di eventi a orario fisso, quando puoi.
- Log di fine giro e heartbeat per accorgerti dei giri mancati.
Se hai processi notturni che ogni tanto «fanno cose strane», quasi sempre è uno di questi tre problemi. Scrivimi e li sistemiamo.