Le signal d'alerte
SchedMaster planifie des tâches récurrentes (rappels, notifications) via node-cron, géré en production par PM2. Un utilisateur m'a signalé recevoir le même rappel deux fois, à quelques secondes d'écart. Puis un deuxième utilisateur. Le pattern s'est confirmé : à chaque redéploiement récent, les tâches semblaient s'exécuter en double, parfois en triple.
Reproduire le problème
J'ai listé les process PM2 après un déploiement classique :
pm2 list
# ┌────┬─────────────────┬─────────┬─────────┬──────────┐
# │ id │ name │ pid │ status │ restarts │
# ├────┼─────────────────┼─────────┼─────────┼──────────┤
# │ 0 │ schedmaster-api │ 41022 │ online │ 4 │
# │ 3 │ schedmaster-api │ 41890 │ online │ 0 │
# └────┴─────────────────┴─────────┴─────────┴──────────┘
Deux process avec le même nom, actifs en même temps. Le script de déploiement faisait un pm2 start ecosystem.config.js à chaque déploiement au lieu d'un reload, ce qui créait une nouvelle instance sans arrêter proprement l'ancienne quand le stop précédent échouait silencieusement (par exemple si le process était déjà dans un état bizarre après un crash).
La cause racine
Chaque instance du process Node.js enregistrait ses propres jobs node-cron au démarrage. Avec deux instances actives, chaque tâche planifiée s'exécutait donc deux fois — une fois par instance — sans qu'aucune des deux ne sache que l'autre existait.
// Chaque instance du process exécute ce code au boot
cron.schedule('0 8 * * *', () => sendDailyReminders()); // x2 si deux instances tournent
Le correctif : déploiement idempotent + verrou distribué
Deux niveaux de correction. D'abord, fiabiliser le script de déploiement pour qu'il ne puisse jamais laisser deux instances actives :
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
Ensuite, en filet de sécurité pour l'avenir (si jamais un scaling horizontal amène plusieurs instances légitimes), j'ai ajouté un verrou distribué en base avant l'exécution de chaque job critique :
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));
La contrainte unique en base (jobName + date du jour) garantit qu'une seule instance peut « prendre » l'exécution d'un job donné pour une journée donnée, même si deux process tournent en parallèle par erreur.
Ce que je retiens
pm2 startrépété sansdeletepréalable n'est pas idempotent : un script de déploiement doit toujours garantir qu'il ne laisse jamais deux instances du même service actives.- Des cron jobs enregistrés en mémoire dans le process sont dangereux dès qu'on ne peut pas garantir une instance unique — un verrou distribué (en base ou via Redis) est la seule protection fiable.
pm2 listest le premier réflexe face à un comportement dupliqué : un coup d'œil suffit souvent à repérer deux process avec le même nom.- Le déploiement doit être pensé comme une opération idempotente : le rejouer plusieurs fois ne doit jamais changer l'état final.