Il segnale d'allarme

SchedMaster pianifica attività ricorrenti (promemoria, notifiche) tramite node-cron, gestito in produzione con PM2. Un utente mi ha segnalato di ricevere lo stesso promemoria due volte, a pochi secondi di distanza. Poi un secondo utente. Il pattern si è confermato: a ogni redeploy recente, le attività sembravano essere eseguite due volte, a volte tre.

Riprodurre il problema

Ho elencato i processi PM2 dopo un deploy classico:

pm2 list
# ┌────┬─────────────────┬─────────┬─────────┬──────────┐
# │ id │ name            │ pid     │ status  │ restarts │
# ├────┼─────────────────┼─────────┼─────────┼──────────┤
# │ 0  │ schedmaster-api │ 41022   │ online  │ 4        │
# │ 3  │ schedmaster-api │ 41890   │ online  │ 0        │
# └────┴─────────────────┴─────────┴─────────┴──────────┘

Due processi con lo stesso nome, attivi contemporaneamente. Lo script di deploy eseguiva un pm2 start ecosystem.config.js a ogni deploy invece di un reload, il che creava una nuova istanza senza arrestare correttamente la vecchia quando il stop precedente falliva silenziosamente (ad esempio se il processo era già in uno stato anomalo dopo un crash).

La causa radice

Ogni istanza del processo Node.js registrava i propri job node-cron all'avvio. Con due istanze attive, ogni attività pianificata veniva quindi eseguita due volte — una per istanza — senza che nessuna delle due sapesse dell'esistenza dell'altra.

// Chaque instance du process exécute ce code au boot
cron.schedule('0 8 * * *', () => sendDailyReminders()); // x2 si deux instances tournent

La correzione: deploy idempotente + lock distribuito

Due livelli di correzione. Innanzitutto, rendere affidabile lo script di deploy affinché non possa mai lasciare due istanze attive:

pm2 delete schedmaster-api || true   # on ignore l'erreur si le process n'existe pas encore
pm2 start ecosystem.config.js --name schedmaster-api
pm2 save

Poi, come rete di sicurezza per il futuro (nel caso in cui un eventuale scaling orizzontale porti più istanze legittime), ho aggiunto un lock distribuito sul database prima dell'esecuzione di ogni job critico:

async function runOnce(jobName, fn) {
    const lock = await prisma.jobLock.create({
        data: { jobName, executedAt: new Date() },
    }).catch(() => null); // échoue si un verrou existe déjà pour ce créneau (contrainte unique jobName+date)

    if (!lock) {
        console.log(`Job ${jobName} déjà exécuté par une autre instance, skip.`);
        return;
    }

    await fn();
}

cron.schedule('0 8 * * *', () => runOnce('daily-reminders', sendDailyReminders));

Il vincolo univoco sul database (jobName + data del giorno) garantisce che una sola istanza possa «prendere» l'esecuzione di un dato job per un dato giorno, anche se due processi girano in parallelo per errore.

Cosa ne ricavo

  • pm2 start ripetuto senza delete preventivo non è idempotente: uno script di deploy deve sempre garantire di non lasciare mai due istanze dello stesso servizio attive.
  • I cron job registrati in memoria nel processo sono pericolosi non appena non si può garantire un'istanza unica — un lock distribuito (sul database o tramite Redis) è l'unica protezione affidabile.
  • pm2 list è il primo riflesso di fronte a un comportamento duplicato: uno sguardo spesso basta a individuare due processi con lo stesso nome.
  • Il deploy va pensato come un'operazione idempotente: rieseguirlo più volte non deve mai cambiare lo stato finale.