Accueil / Articles / Pourquoi les tâches @Cron de NestJS s’exécutent une seule fois par réplica et comment y remédier

Pourquoi les tâches @Cron de NestJS s’exécutent une seule fois par réplica et comment y remédier

Découvrez pourquoi les planifications @Cron en cours d’exécution se multiplient lorsque un service NestJS est étendu, et comment un déclencheur externe, des écritures idempotentes ainsi qu’un point de terminaison protégé permettent de résoudre ce problème.

1350 mots

Une tâche nocturne conçue pour créer exactement un enregistrement par entité commence à générer des doublons : des lignes identiques pour la même entité et la même date de début, écrites à quelques secondes d’intervalle, sans que l’une ou l’autre ne soit endommagée. Rien dans le code de la tâche n’a changé. Ce qui a changé, c’est que le service s’exécute désormais sur plusieurs réplicas, et que l’ordonnanceur se trouve à l’intérieur de chacun d’eux. Cet article explique pourquoi le décorateur @Cron de NestJS se comporte de cette manière, compare trois façons de le résoudre, et présente en détail la solution retenue, y compris les aspects de sécurité et de fuseau horaire qui en découlent.

Comment les doublons apparaissent

Imaginons une tâche qui fait avancer chaque entité active vers sa prochaine fenêtre temporelle définie, en créant un nouvel enregistrement par entité chaque jour. La mise en œuvre typique dans NestJS utilise le décorateur @nestjs/schedule :

@Injectable()
export class WindowGenerationService {
  @Cron('0 8 * * *') // every day at 08:00
  async generateNextWindows() {
    const entities = await this.repo.findActiveEndingSoon();
    for (const entity of entities) {
      await this.repo.createNextWindow(entity);
    }
  }
}

C’est exactement ce que la documentation suggère, et cela fonctionne sans aucun problème tant que le service est exécuté en une seule instance.

Les problèmes commencent après l’échelle horizontale. Avec trois instances, il y a trois processus qui lancent chacun le module, et @Cron enregistre son horloge dans chacun d’eux. Rien ne les coordonne : à 08:00, les trois s’exécutent simultanément. Comme la tâche ne réalise aucune vérification d’idempotence ni n’utilise de verrou, chaque exécution cherche une fenêtre existante, n’en trouve aucune, et en écrit sa propre copie.

Deux problèmes en découlent. Le premier est évident : les données dupliquées. Le second, moins apparent, concerne le gaspillage de ressources : chaque réplique effectue les mêmes tâches au même moment, ce qui exige ensuite plus d’efforts pour nettoyer les résultats. Un planificateur interne dans un service échelonné n’est pas seulement une erreur de correction ; par conception, il consomme des ressources en proportion du nombre de répliques. Rien dans le code ne laisse supposer cela, d’où le fait que ce problème se manifeste généralement sur des données de préparation plutôt qu’au cours des tests de validation.

Trois façons de le résoudre

Option 1 : un verrou distribué

Gardez @Cron, mais faites en sorte que les instances se disputent un verrou, comme un verrou consultatif de base de données ou une clé dans un cache, et ne laissez fonctionner que le gagnant. Cette méthode fonctionne, mais elle remplace une panne visible par une panne invisible. Les lignes dupliquées peuvent au moins être trouvées et supprimées. Une exécution manquée ne le permet pas : si le mécanisme de verrouillage n’est pas disponible à 08:00, ou si une instance plante en tenant le verrou avant l’expiration de sa durée de vie, la tâche ne s’exécute tout simplement pas, et personne ne s’en aperçoit avant que des problèmes ne surviennent des jours plus tard en raison d’une fenêtre manquante. Cela ajoute également une nouvelle dépendance et un nouveau mode de panne silencieuse pour compenser l’utilisation d’un chronomètre placé au mauvais niveau.

Option 2 : l’idempotence seule

Faites en sorte que la tâche puisse être exécutée plus d’une fois sans conséquence, de sorte que les exécutions supplémentaires n’ont aucun effet. C’est simple et correct, mais trois conteneurs se réveillent néanmoins chaque nuit pour effectuer du travail redondant.

Option 3 : déplacer le planification hors de l’application

L’application ne doit pas décider quand le travail s’exécute. Laissez un planificateur externe gérer le timing et envoyer une requête HTTP vers une adresse cible ordinaire ; l’application ne décide que ce qui se passe lorsqu’elle reçoit cette requête. Un déclencheur produit une seule exécution, et l’ajout de répliques ne multiplie rien. Les déclencheurs courants sont un Kubernetes CronJob, le service de planification d’un fournisseur cloud ou un planning de pipeline CI.

La conception choisie combine l’option 3 avec l’idempotence de l’option 2 en tant que filet de sécurité.

La nouvelle conception

Le décorateur @Cron est supprimé, et la logique de tâche se trouve derrière une adresse cible :

@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
  this.assertValidSecret(body.secret);
  return this.jobs.run(body.jobKey);
}

Le planificateur externe appelle cette fonction une fois à l’heure prévue. Le balanceur de charge dirige la requête vers une instance qui exécute le travail ; les autres répliques ne sont jamais impliquées. En passant une jobKey, un seul point de terminaison peut gérer plusieurs tâches.

Une amélioration pratique : une tâche qui dure des minutes peut dépasser le délai de timeout HTTP du planificateur. Pour les tâches longues, envisagez d’acknowledger rapidement la requête et d’exécuter le travail en arrière-plan, tout en évitant les chevauchements.

Conserver l’idempotence comme système de sécurité

Vérification de l’idempotence obligatoire, car la promesse de « déclenchement exact une seule fois » est généralement respectée par l’infrastructure, mais peut parfois être enfreinte : le planificateur réessaie après un timeout, quelqu’un déclenche manuellement la tâche, ou une instance redémarre en cours d’exécution.

async createNextWindow(entity: Entity) {
  const existing = await this.repo.findByEntityIdAndStartDate(
    entity.id,
    entity.nextStartDate,
  );
  if (existing) return; // already done, no-op
  await this.repo.create(/* ... */);
}

Au préalable de la création d’une fenêtre, la méthode cherche une fenêtre ayant le même identifiant d’entité et la même date de début, et retourne immédiatement si elle existe. Il convient de noter que la stratégie « vérifier puis insérer » est elle-même sujette à des problèmes de concurrence si deux exécutions coïncident exactement. La solution fiable consiste à mettre en place une contrainte d’unicité dans la base de données sur l’identifiant d’entité et la date de début, de sorte qu’un doublon concurrent échoue au moment de l’insertion plutôt que d’être enregistré. Pour en savoir plus sur la conception d’écritures résistantes aux tentatives répétées, consultez notre guide sur les clés d’idempotence dans les endpoints POST de Node.js.

Quel est le coût du changement

Un endpoint de tâche est un endpoint public

Transformer une tâche nocturne privée en une route HTTP crée un bouton que quiconque peut appuyer à volonté. Par conséquent, cette route nécessite un secret partagé, et la manière dont ce secret est comparé est importante :

private assertValidSecret(provided: string) {
  const expected = this.config.cronSecret;
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new UnauthorizedException();
  }
}

La comparaison provided === expected peut divulguer des informations via le timing : la comparaison de chaînes s’arrête souvent au premier caractère différent, ce qui permet à l’attaqueur de déduire progressivement la valeur secrète en se basant sur le temps nécessaire pour échouer. La fonction timingSafeEqual du module crypto de Node effectue quant à elle la comparaison en temps constant. Elle exige que les buffers aient une longueur identique, c’est pourquoi la longueur est vérifiée en premier ; le hachage des deux valeurs avant comparaison empêche même la révélation de leur longueur. Les auditeurs soulignent rarement ce problème, et compter sur le fait que l’endpoint reste inconnu n’est pas une stratégie fiable.

Deux autres étapes de renforcement méritent d’être prises en compte. Envoyer le secret dans un en-tête de requête plutôt que dans le corps empêche que celui-ci ne soit capturé par les outils de journalisation du corps des requêtes. De plus, en vérifiant que secret est une chaîne non vide dans RunJobDto, on évite que Buffer.from ne lance une erreur en l’absence d’entrée.

Le choix de l’heure relève d’une décision liée au produit

Le deuxième coût est facile à sous-estimer : le choix du moment d’exécution. La tâche doit s’exécuter après le début de la journée pour chaque utilisateur, et ces utilisateurs se trouvent dans plusieurs fuseaux horaires américains ; ainsi, une heure en milieu de matinée sur la côte Est correspond encore à l’aube avant dans la côte Ouest. Le planning est donc fixé sur une heure UTC précise, choisie en fonction du fuseau horaire le plus à l’ouest où l’entreprise opère, car c’est l’heure qui est sûre partout. Le crontab utilise UTC tandis que les exigences font référence à l’heure locale, et c’est la traduction entre ces deux systèmes qui détermine réellement le moment d’exécution. Notez ce raisonnement à côté du planning, car il n’est pas évident à partir de l’expression cron seule.

Points clés

  • @Cron s’exécute dans chaque processus qui charge le module, de sorte que ses exécutions se multiplient avec le nombre de réplicas sans aucun avertissement dans le code.
  • Les verrous distribués résolvent les doublons mais entraînent des exécutions manquées silencieuses ainsi qu’une dépendance supplémentaire.
  • Le planification relève de la question du « quand » et appartient à l’infrastructure externe ; l’application ne doit gérer que ce qui se passe lorsqu’elle est déclenchée.
  • Préservez l’idempotence en tout cas, de préférence grâce à une contrainte unique dans la base de données, car une seule livraison est une promesse faite par quelqu’un d’autre.
  • Un point de terminaison de tâche nécessite une protection réelle : un secret comparé à timingSafeEqual, une validation des entrées et un enregistrement des événements adapté.
  • Définissez délibérément les plannings en UTC, en fonction de la zone horaire qui impose le plus de contraintes.
  • Lectures complémentaires