Vai al contenuto
Tutti gli articoli
iten

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
Cron job in produzione: lock su Postgres, ora legale e lavori che si possono rifare

«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_lock non aspetta: se il lock è occupato restituisce false e 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 lo unlock o 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.

Leggi anche