Why NextWhy Next
Retour à la liste
💻 Dev16 min de lecture

Les tests étaient verts, mais aucune notification n'était jamais partie

Le code qui poussait des jobs dans la queue échouait à chaque fois depuis le premier jour. L'erreur était avalée, et les tests restaient verts grâce au mock. L'histoire d'un deux-points qui a tué trois fonctionnalités en silence.

L'essentiel

BullMQ interdit les deux-points dans les jobId personnalisés. Un enqueue qui violait cette règle échouait à 100% depuis le premier jour, mais l'échec était avalé par un catch, et personne ne savait que la fonctionnalité de notification était morte. Les tests remplaçaient la queue par un pur mock qui ne passait jamais par la vraie validation, donc ils restaient verts. Le même défaut existait dans deux autres queues, dont une asymptomatique depuis 12 jours. En corrigeant, un deuxième piège est apparu : un jobId fixe fait ignorer sans erreur le ré-enregistrement tant que le hash d'un job terminé subsiste, ce qui tue en silence le contrat de retry.

Sur cette page

Une alerte 500 est tombée sur Slack. Le message d'erreur ne me disait rien. Error: Custom Id cannot contain :. L'API qui permet à un administrateur d'enregistrer une réponse à la question d'un utilisateur était en train de mourir, et la stack trace pointait vers Job.validateOptions de BullMQ.

Jusque-là, c'est un rapport de bug ordinaire. L'histoire cesse de l'être une fois la cause trouvée. Le code qui levait cette erreur n'avait pas été touché par le déploiement du jour. Il échouait à chaque exécution, à 100%, depuis le jour où la fonctionnalité avait été livrée. Autrement dit, les notifications push que cette queue devait envoyer n'étaient jamais parties, pas une seule fois. Et pendant tout ce temps, les tests étaient tous verts.

La cause : un seul caractère

Le code fautif ressemblait à ceci. C'est l'endroit où l'on pousse dans la queue le job qui envoie un push à l'utilisateur quand sa question reçoit une réponse.

await this.queue.add(ANSWER_NOTIFY_JOB_NAME, data, {
  jobId: `answer-notify:${data.questionId}`, // ← ce deux-points
});

Un jobId personnalisé avait été posé pour empêcher qu'un job entre deux fois pour la même question, mais le séparateur choisi était un deux-points. BullMQ assemble ses clés Redis avec des deux-points, donc il les interdit dans les jobId personnalisés. En v5, la condition exacte est celle-ci : si le jobId contient un deux-points et que le découpage sur ce caractère ne donne pas 3 segments, l'erreur est levée. L'exception à 3 segments est une dérogation pour le format legacy des jobs repeatable, donc un jobId à 2 segments comme answer-notify:123 donne systématiquement Custom Id cannot contain :. Au passage, un jobId purement numérique est lui aussi rejeté avec Custom Id cannot be integers, donc supprimer le préfixe n'est pas une solution non plus.

Les dizaines d'autres queues du codebase utilisaient toutes des tirets. Seul ce fichier avait un deux-points.

Le bug existait depuis le premier jour, alors pourquoi a-t-il explosé aujourd'hui

Le correctif tient en une ligne : remplacer le deux-points par un tiret. La question intéressante est ailleurs. Pourquoi un code qui échouait toujours est-il resté silencieux jusqu'à devenir un 500 aujourd'hui ?

Au premier déploiement de la fonctionnalité, l'enqueue était enveloppé comme ceci.

try {
  await this.queue.add(/* ... */);
  return true;
} catch (err) {
  this.logger.error("enqueue failed", err);
  return false; // ← l'échec disparaît ici
}

On logue l'échec et on retourne false. L'appelant ne vérifie pas la valeur de retour. Donc l'exception levée à chaque fois était avalée à chaque fois, l'API renvoyait 200, et les notifications s'évaporaient en silence.

Puis un commit mergé le même jour a changé ce contrat. L'intention : quand l'enqueue échoue, ne plus avaler mais lancer l'erreur, pour pouvoir annuler le marquage "répondu" posé dans la transaction et permettre un retry. Un changement dans la bonne direction. À cet instant, l'enqueue qui échouait depuis toujours a fait du bruit pour la première fois. Le 500 n'était pas un bug créé par ce commit, c'était le premier cri d'un bug qui était déjà là.

Voilà le coût du code qui avale les erreurs. Le signal disparaît, et le bug reste en place en emportant une fonctionnalité entière avec lui.

Pourquoi les tests étaient verts

Cette queue avait des tests. Un spec du genre classique : on appelle l'enqueue et on vérifie que queue.add est invoqué avec les bons arguments. Et le queue.add de ce test était un mock qui réussissait inconditionnellement.

Le vrai add de BullMQ valide le jobId et rejette les deux-points. Le add du mock accepte n'importe quoi. Plus le mock est indulgent par rapport au contrat réel, plus le test fait passer en vert des entrées qui ne pourraient jamais passer en réalité. Quand le mock diverge du contrat réel, un test vert certifie du code mort, pas du code vivant.

En corrigeant, j'ai commencé par boucher ce trou. J'ai modifié le add du mock de queue pour qu'il exécute le vrai Job.validateOptions de BullMQ. Cette fonction est une validation pure qui tourne sans Redis, donc elle se branche telle quelle sur les tests. Puis j'ai vérifié par un test de mutation que le garde-fou en était vraiment un. J'ai remis le deux-points, j'ai vu le test devenir rouge exactement à cet endroit, et c'est seulement après que j'ai commité. Si le test reste vert quand on réintroduit le bug, ce test équivaut à ne pas exister.

Ce n'était pas un cas isolé

S'arrêter là, c'était ne corriger que la moitié. Quand la même erreur existe à un endroit, il y a de fortes chances qu'elle existe ailleurs. J'ai défini la "classe" du défaut - un jobId personnalisé contenant un deux-points - puis passé le codebase au peigne fin, avec plusieurs agents de revue en vérification croisée. Deux cas de plus sont sortis.

Le premier : la queue qui transcrit en texte les audios envoyés par les utilisateurs. jobId: "stt:${id}". De ce côté, l'erreur était encore avalée, donc pas même un 500. Vérification faite, pas un seul job de transcription n'était entré dans la queue depuis 12 jours. Les fichiers audio étaient sauvegardés normalement, l'échec ne vivait que dans les logs, et le texte restait vide à jamais sur l'écran de l'utilisateur. Totalement asymptomatique.

Le second : la queue d'emails d'information pour les événements planifiés. Le jobId comptait 4 segments séparés par des deux-points, donc ni les rappels ni les avis d'annulation n'entraient dans la queue.

J'ai unifié les producers des trois queues sur un seul builder de jobId partagé : une fonction qui joint avec des tirets, remplace les deux-points qui s'y glissent, et rejette les valeurs purement numériques. En prévention, j'ai aussi ajouté une règle de lint qui attrape précisément le pattern d'un deux-points littéral dans un jobId. Je n'ai pas migré de force la cinquantaine de jobId à tirets existants vers le nouveau builder : si le jobId d'un job repeatable change, on risque un double enregistrement du cron, donc j'ai choisi de ne viser que la classe du défaut.

Deuxième piège : un jobId fixe tue les retries en silence

L'histoire aurait pu s'arrêter là, mais la vérification de revue a fait remonter un problème plus fondamental. Une fois le deux-points corrigé, un jobId fixe comme answer-notify-123 revient à la vie, et ce jobId fixe contredisait en lui-même le contrat de retry établi le même jour.

BullMQ, quand le hash d'un job portant le même jobId subsiste dans Redis, ignore le nouvel add sans erreur et retourne le jobId existant. Un comportement que le vérificateur a confirmé en allant lire jusqu'aux scripts Lua de BullMQ. Or cette queue est en removeOnComplete: { count: 100 }, donc le hash d'un job qui vient de se terminer reste en place. Combinez les deux et voici le scénario. Le job de notification échoue une fois. Conformément au contrat, le marquage "répondu" est annulé et l'administrateur relance. Le nouvel enqueue bute sur le hash resté en place, ne fait rien, et revient comme un succès. La notification ne part jamais, et cette fois il n'y a ni erreur ni DLQ.

On supprime le catch qui avalait les échecs, et c'est le dedupe de la bibliothèque qui reprend le même rôle. Au final, j'ai retiré complètement le jobId personnalisé de cette queue. La protection contre les envois en double a déménagé côté consommateur : le processeur s'approprie de façon atomique le marquage "notification envoyée" via une mise à jour conditionnelle. Même si le job entre deux fois, seul celui qui a saisi le marquage en premier envoie le push.

Si votre design bloque les doublons avec un jobId personnalisé et ré-enqueue en cas d'échec, cela vaut la peine de vérifier que les deux mécanismes ne s'entretuent pas. Vous avez peut-être, comme nous, la combinaison où "le retry devient silencieusement un no-op".

Quels ont été les dégâts

Une fonctionnalité morte pendant 12 jours, on imagine des dégâts lourds, mais les mesures ont dit autre chose. Les notifications de réponse : la fonctionnalité n'était pas encore en usage réel en production, donc zéro perte. La queue d'emails ne contenait que des événements passés, rien à renvoyer. Le vrai périmètre de récupération : 2 audios non transcrits. Les fichiers audio étant restés, on les a retranscrits et récupérés.

On a eu de la chance. Et cette chance ne se répète pas. Si la fonctionnalité avait été en usage réel, 12 jours de notifications et de transcriptions seraient partis en fumée, et comme les jobs n'avaient jamais été créés dans la queue, aucun déploiement n'aurait rien récupéré. Écrire un script de backfill était secondaire. L'essentiel, ai-je jugé, c'était de corriger le fait qu'une fonctionnalité morte soit restée morte 12 jours sans que personne ne le sache. Donc les échecs de transcription ne restent plus confinés aux logs et remontent dans un canal d'alerte, et le chemin des notifications de réponse est passé par un outbox commité avec la transaction, pour éliminer à la racine l'état "le succès est enregistré en base mais le job n'existe pas".

Ce qui restera

Trois choses de cette affaire qui survivront au code.

Un : du code qui, dans un catch, se contente de loguer et retourne normalement rend un chemin invisible même quand il échoue à 100%. Si vous avalez, accompagnez au minimum d'une métrique qui compte les échecs ou d'une alerte ; sinon, mieux vaut lancer. Une erreur lancée fait du bruit mais se corrige, une erreur avalée vit 12 jours.

Deux : un mock doit être aussi strict que le contrat réel. Sur un mock qui réussit toujours, aucune entrée n'est validée. Si la bibliothèque expose une fonction de validation pure, branchez-la sur le mock, et vérifiez que le test devient rouge quand on réintroduit le bug. C'est à cette condition que c'est un garde-fou.

Trois : les défauts s'attrapent par classe. Dans un même codebase, la même erreur n'arrive pas qu'une fois. Une fois le premier cas trouvé, définissez la classe, ratissez tout, et allez jusqu'à empêcher son retour, par une règle de lint ou un builder partagé. C'est là qu'une découverte rapporte son plein prix.

Questions fréquentes

J'ai l'erreur 'Custom Id cannot contain :' dans BullMQ. Pourquoi ?

BullMQ v5 interdit les deux-points (:) dans les jobId personnalisés, parce que le deux-points est le séparateur utilisé pour assembler les clés Redis. La condition exacte : si le jobId contient un deux-points et que le découpage sur ce caractère ne donne pas exactement 3 segments, l'erreur est levée. L'exception à 3 segments existe pour le format legacy des jobs repeatable. Un jobId purement numérique est aussi rejeté avec 'Custom Id cannot be integers'. Utilisez un tiret comme séparateur.

Pourquoi un test avec un mock ne détecte-t-il pas ce genre de bug ?

Quand un mock se comporte différemment du contrat de l'implémentation réelle, le test certifie en vert du code qui est en réalité mort. Si vous remplacez le add de la queue par un mock qui réussit toujours, la validation d'entrée de la bibliothèque ne s'exécute jamais, et un jobId toujours rejeté passe quand même le test. Le mock doit au minimum exécuter la vraie fonction de validation de la bibliothèque, et il faut vérifier que le test devient rouge quand on réintroduit le bug (test de mutation) pour qu'il devienne un vrai garde-fou.

Articles similaires

0 commentaires