Architecture

Concevez pour le webhook qui arrive deux fois

La deuxième copie d’un événement de paiement n’est pas un travail nouveau. Une clé unique, une boîte d’envoi, et une annulation qui ne modifie pas le passé.

Design for the webhook that arrives twice

La requête dangereuse n’est pas la première. C’est la même requête qui arrive encore, parce que l’expéditeur n’a pas entendu la première réponse, ou l’a entendue et a réessayé quand même. Les prestataires de paiement font cela. Les files font cela. Un navigateur fait cela quand une personne appuie deux fois sur le bouton. Si le système traite la seconde copie comme un travail nouveau, vous encaissez deux fois, vous expédiez deux fois, ou vous écrivez deux lignes de livre pour un seul enjeu.

Partez du doublon

Je conçois le gestionnaire pour que la seconde livraison soit un succès qui ne fait rien. La première fait le travail et stocke une clé : l’identifiant d’événement du prestataire, ou une clé envoyée par le client, unique dans la base. La seconde trouve la clé et renvoie le même résultat. Pas de second débit. Pas de second courriel. Pas de seconde ligne.

La contrainte d’unicité est la partie à laquelle je fais confiance. Un contrôle dans le code de l’application, juste avant l’insert, perd la course quand deux workers se réveillent ensemble. La base de données ne perd pas cette course. Le perdant reçoit un conflit et le transforme en « déjà fait ».

Première arrivéeDeuxième arrivée
RechercheLa clé est nouvelleLa clé est déjà stockée
ÉcrireUne ligne, un effet de bordAucune nouvelle ligne
RépondreAcceptéLe même déjà accepté
Si le processus meurt à mi-cheminLa boîte d’envoi doit encore le messageIl n’en doit pas un second

Le message part avec l’écriture, ou pas du tout

Envoyer le courriel, ou le message de file, dans la requête et avant le commit, c’est ainsi que vous obtenez un message pour une transaction qui a été annulée. J’écris le message sortant dans la même transaction que la ligne métier, et un worker séparé le publie. Si le processus meurt, la ligne et le message sont là tous les deux, ou aucun des deux. Le worker peut réessayer la publication. Le gestionnaire ne doit pas réessayer le débit.

Une annulation est une écriture nouvelle, pas une modification

Quand il faut défaire quelque chose, je n’écrase pas la ligne d’origine. Une écriture de contrepartie dit ce qui a changé et laisse la première ligne lisible. Une semaine plus tard, vous pouvez encore dire ce qu’on croyait à 14:02. Modifier la ligne ne dit que ce qu’on croit maintenant, et le dit sans témoin.

Si un nouvel essai peut faire arriver l’opération deux fois, l’opération n’est pas finie.

Nommez la clé avant d’écrire le gestionnaire

La clé n’est pas « un hash de toute la requête », sauf si vous avez vérifié ce que l’expéditeur change entre les essais. Un prestataire de paiement renverra le même identifiant d’événement avec un nouvel horodatage de livraison. Si l’horodatage est dans la clé, la seconde livraison a l’air neuve, et vous avez construit le bug que vous vouliez éviter. J’utilise l’identifiant que l’expéditeur promet stable. S’il n’en promet pas, j’exige que l’appelant en envoie un, et je refuse l’appel qui arrive sans.

Ensuite j’écris l’échec au même endroit que la réussite. Un conflit n’est pas une page d’erreur pour l’expéditeur. C’est la réponse attendue à un nouvel essai. L’expéditeur doit pouvoir s’arrêter. Si je réponds 500, il essaiera une troisième fois, puis une quatrième, et l’incident devient une file de travail identique.

Je ne considère pas cela terminé parce que le chemin heureux a renvoyé 200. Le test renvoie la même charge et affirme un seul effet. Puis il tue le worker entre le commit et la publication et affirme que le message part encore, une fois. Ces deux tests sont plus courts que l’incident qu’ils remplacent. Je commence là.