Accueil / Articles

Concevoir un pipeline de notifications sans doublons sur SQS, SES et Redis

Comment une conception SQS à deux files d’attente, un mécanisme de secours SES vers SendGrid et un verrou atomique Redis permettent aux documents d’affaires d’être transmis aux destinataires une seule fois, même en situation de forte charge.

Éditeur : REACTAPP.TOP7 min de lecture1509 mots

Sources: How I Built a Notification Engine Handling 5M+ Events

Deux employés reçoivent le même événement au même moment. Chacun consulte le journal des e-mails, ne trouve aucune trace et envoie à nouveau. Un fournisseur se retrouve avec deux commandes d’achat identiques, et l’équipe en prend connaissance grâce à un ticket de support plutôt qu’au travers du suivi en temps réel. Cet article décrit un moteur de notification pour une plateforme de chaîne d’approvisionnement B2B qui a fini par gérer plus de cinq millions d’événements par mois, ainsi que les trois pannes de production qui l’ont façonné : pertes d’e-mails, e-mails bloqués lors de la génération en PDF et doublons.

Ce que le moteur doit fournir

Dans une solution SaaS de chaîne d’approvisionnement alimentaire, les événements métier tels que la création ou l’édition d’une commande, ou la réalisation d’une opération d’inventaire, doivent générer des documents destinés aux bonnes personnes : commandes d’achat, commandes de vente, bons de livraison, notes de réception de marchandises et factures. Un même événement peut donc se décomposer en plusieurs étapes : générer un PDF, le stocker et l’envoyer par e-mail à plusieurs destinataires. Le même moteur envoie également des messages SMS et WhatsApp pour un certain nombre d’autres cas d’usage.

Les enjeux sont concrets. Une notification qui n’arrive jamais n’est pas simplement un bug esthétique ; une commande d’achat manquante peut bloquer des marchandises en route vers un client.

Deux files d’attente : l’une décide, l’autre livre

La conception repose sur deux files d’attente Amazon SQS avec des tâches clairement séparées.

La file principale est l’endroit où les décisions sont prises. Un seul consommateur lit chaque événement reçu et détermine ce que cela implique : quel document produire, quel canal utiliser et qui doit le recevoir.

La file secondaire se contente de livrer les messages. Les agents de livraison n’appliquent aucune règle métier ; ils transmettent simplement les messages aux prestataires.

Un message par destinataire

La décision la plus importante dans ce schéma est que chaque destinataire reçoit son propre message dans la file secondaire. Une commande destinée à dix personnes devient ainsi dix messages indépendants. Si la livraison à une adresse échoue, les neuf autres sont toujours envoyées, et une nouvelle tentative cible uniquement le destinataire concerné sans avoir à réenvoyer tout le lot.

Cette séparation permet également d’isoler les changements. Le remplacement par un nouveau fournisseur ou canal ne concerne que l’aspect de livraison ; le code qui décide de ce qui doit être envoyé n’a jamais besoin de savoir comment un message est transporté.

Pourquoi pas une file d’attente par canal ?

Une file d’attente distincte pour l’e-mail, les SMS et WhatsApp permettrait à chaque canal d’avoir un débit et des mécanismes de gestion des pannes indépendants. À ce volume, cela n’était pas nécessaire, et ajouter une file d’attente lorsque c’est requis est moins coûteux que d’en gérer quatre dès le départ.

Panne numéro un : des e-mails qui n’arrivent jamais

Les fournisseurs d’e-mail connaissent des pannes, parfois de manière évidente avec une erreur, et parfois discrètement avec une réponse manquant des champs attendus. Amazon SES est le principal fournisseur dans ce cas, et une réponse sans identifiant de message est considérée comme une panne, tout comme une exception. Dans les deux cas, le même e-mail est envoyé via SendGrid à la place :

// simplified
try {
  const res = await ses.send(email);
  if (!res.MessageId) throw new Error("No MessageId from SES");
} catch (err) {
  await sendgrid.send(email); // fallback
}

Remarquez le lancer explicite lorsque MessageId est manquant : il dirige ce cas ambigu vers le même chemin de secours qu’en cas d’erreur grave. Il est important de comprendre l’équilibre entre les avantages et les inconvénients. Si SES accepte l’e-mail mais que la réponse est perdue, le mécanisme de secours peut la livrer une seconde fois, ce qui fait que cette couche privilégie le principe de « livraison au moins une fois » plutôt que celui de « livraison au plus une fois ». Pour les documents professionnels, c’est généralement le bon choix, mais c’est aussi une raison supplémentaire pour laquelle l’étape de déduplication décrite ci-dessous est importante.

Lorsque les deux fournisseurs échouent, le message est envoyé dans une file de courriers non livrés et tenté à nouveau après un certain délai. De plus, un mécanisme de réenvoi distinct s’occupe des e-mails enregistrés comme non livrés, ce qui permet de retrouver tout ce qui a pu échapper.

Deuxième problème : les PDF qui échouent sous charge

Les e-mails de documents suivent une règle stricte : rien n’est envoyé tant que le PDF n’existe pas. Les destinataires ont besoin de la commande d’achat réelle, et non d’un lien qui renvoie une erreur. Le consommateur lance un Lambda de génération de PDF, enregistre le fichier dans S3 et n’envoie l’e-mail contenant un lien vers celui-ci qu’ensuite.

Cette règle est correcte, mais elle a créé une dépendance cachée. Lorsque le système ralentissait et que la génération du PDF échouait, l’e-mail ne montrait pas d’échec visible ; il ne se produisait tout simplement jamais, car il attendait un fichier qui n’allait jamais être généré. La solution a été de prévoir des tentatives de génération du PDF indépendantes, afin qu’un échec temporaire dans la rendu ne annule plus silencieusement la notification ultérieure.

Troisième échec : le même e-mail, deux fois

Les doublons provenaient de trois sources distinctes, et chacune nécessitait une réponse différente :

  • des bugs de logique ordinaires, qui ont été corrigés en tant que tels ;
  • des tentatives de réexpédition pour des messages qui avaient en fait déjà été livrés ;
  • la course entre deux consommateurs qui traitent le même événement en même temps.
  • Pourquoi une vérification des journaux n’est pas suffisante

    Dès le début, le pipeline consultait le journal des e-mails avant d’envoyer : si un enregistrement indiquait que le message avait été envoyé, la tentative de réexpédition était ignorée. Cela fonctionne bien pour les tentatives séquentielles. Cependant, cela ne peut pas empêcher la course, car lire le journal et y écrire sont deux opérations distinctes. Deux travailleurs peuvent tous deux lire « non envoyé » avant que l’un d’eux n’écrive, et tous deux continuent. Les files d’attente standard de SQS assurent une livraison au moins une fois, donc il est normal que le même message arrive chez deux consommateurs, ce n’est pas un accident rare.

    Un verrou atomique dans Redis

    La solution consistait à mettre un verrou atomique dans Redis avant la vérification des journaux. La clé combine l’ID de la commande, l’adresse e-mail du destinataire et le type de document, et elle expire après environ cinq minutes :

    // simplified
    const key = `notif:${orderId}:${email}:${docType}`;
    const locked = await redis.set(key, "1", "NX", "EX", 300);
    if (!locked) return; // someone else already has it
    

    SET avec l’option NX écrit la clé uniquement si elle n’existe pas déjà, et EX 300 définit une expiration de 300 secondes dans la même commande. Comme la vérification d’existence et l’écriture ont lieu en une seule opération sur le serveur Redis, un seul travailleur reçoit un résultat vrai ; tous les autres travailleurs voient null et renvoient immédiatement. Cela comble l’écart laissé par la vérification des journaux.

    Quelques propriétés de cette approche méritent d’être mémorisées :

    • La granularité de la clé est importante. En incluant le type de document, une commande d’achat et une facture pour la même commande et le même destinataire ne sont pas confondues comme des doublons.
  • Le TTL agit comme une soupape de sécurité. Si un travailleur rencontre une panne après avoir pris le verrou, la clé expire et une nouvelle tentative ultérieure peut avoir lieu. La durée d’expiration doit dépasser largement le temps normal nécessaire pour envoyer des données, et idéalement celle du délai de visibilité de SQS, afin que le verrou d’un travailleur lent ne périsse pas pendant qu’il continue à envoyer des données.
  • Le verrou empêche les doublons simultanés ; le journal persistant permet néanmoins de détecter ceux qui surviennent plus tard.
  • La même idée se retrouve dans les API HTTP sous forme de clés d’idempotence ; l’article sur les clés d’idempotence dans les endpoints POST de Node.js aborde ce sujet.

    Où en est arrivé le système

    Après ces modifications, le moteur traite plus de cinq millions d’événements par mois grâce aux déclencheurs liés aux commandes, aux stocks et aux documents, avec 98,9 % de livraisons réussies et un délai p95 inférieur à 1,2 seconde.

    Que faire différemment la prochaine fois

    • Détectez les pannes avant les clients. Trop de problèmes apparaissent d’abord sous forme de réclamations, et ce n’est qu’ensuite qu’ils se retrouvent dans les journaux d’activité. Les alertes relatives aux échecs de livraison et à la profondeur de la file des messages non livrés doivent faire partie de la première version, et non d’une version ultérieure.
    • Empêchez les événements de disparaître. Certains événements ont disparu pour des raisons qui n’ont jamais été entièrement expliquées. Un système de boîte de sortie transactionnelle, qui enregistre l’événement en même temps que l’opération métier et le transmet par la suite, rend les événements perdus beaucoup moins fréquents. L’article sur les tables de boîte de sortie et de boîte d’entrée explique ce mécanisme.
    • Gardez une seule sous-file tant que c’est possible. La file de livraison partagée a tenu le choc ; elle n’a besoin d’être divisée que si un canal commence à priver les autres.

    Liste de contrôle pour créer votre propre système

    • Gardez les décisions de routage en un endroit et la livraison en un autre.
    • Émettez un message par destinataire afin qu’une seule panne ne bloque pas le reste.
    • N’ayez pas confiance en un seul fournisseur, et considérez l’absence d’ID de message comme une panne.
    • Attribuez aux messages échoués une destination : une file de courrier indésirable avec des tentatives de réexpédition.
    • N’envoyez jamais d’e-mail contenant un document avant que celui-ci n’existe, et réessayez l’étape qui le génère.
    • Rendez la déduplication atomique, car une vérification suivie d’une écriture laisse un intervalle de risque.
    • Avertissez en cas de panne afin que les clients ne fassent pas office de système de surveillance.

    Conclusion

    Aucune de ces protections n’a été conçue dès le départ ; chacune a été ajoutée après qu’un problème est survenu en production, c’est ainsi que la plupart des pipelines de livraison évoluent réellement. La leçon ne concerne pas tant une solution particulière que l’ordre de réflexion : partir du principe qu’une livraison aura au moins lieu une fois, supposer que les fournisseurs et les dépendances échoueront silencieusement, et faire en sorte que chaque étape soit soit idempotente, soit protégée par quelque chose d’atomique.