02Architecture5 min

Le même bug de concurrence m'est apparu dans trois projets différents

Un compteur qui additionne sous charge perd des incréments, et un qui soustrait peut facturer deux fois la même chose. Quand le compteur, c'est de l'argent, ce n'est pas un détail qu'on répare plus tard.

Par Salva Castells

Image de couverture : kisnikee, CC0.
Dans cet article

Tu lances cent requêtes concurrentes contre la même clé d'API. Le compteur d'utilisations affiche quatre-vingt-sept. Aucune erreur dans les logs, aucune requête n'a échoué, personne ne s'est plaint. Il manque simplement treize incréments.

Ce défaut m'est sorti trois fois, dans des projets qui ne partagent pas une ligne de code, toujours pour la même raison : le compteur a été écrit comme s'il n'y avait qu'un seul processus en train d'écrire.

Le motif : lire, additionner et écrire en trois étapes

Le code qui le produit n'a jamais l'air dangereux. Dans une API de transcription, authentifier une clé faisait deux choses : vérifier qu'elle n'était pas révoquée et mettre à jour useCount et lastUsedAt. Lire la ligne, ajouter un en mémoire, écrire le résultat.

Avec un utilisateur à la fois, ça marche. Avec des workers en batch qui tapent sur la même clé, non : entre la lecture et l'écriture, une autre requête entre, lit la même valeur et écrit le même résultat. Deux utilisations, un incrément. Mesuré avec k6 à 10, 50 et 100 utilisateurs virtuels, la perte était entre 8 et 14 pour cent.

Le correctif tient en une instruction :

UPDATE api_key
SET use_count = use_count + 1,
    last_used_at = now()
WHERE id = $1 AND revoked_at IS NULL
RETURNING use_count;

Ce qui compte, ce n'est pas seulement le use_count + 1, qui est l'évidence. C'est que le revoked_at IS NULL est à l'intérieur du même UPDATE. Avant, la vérification de révocation et l'incrément étaient deux allers-retours distincts, il existait donc une fenêtre où quelqu'un révoquait la clé juste après l'avoir validée et l'utilisation était comptée quand même. Avec le filtre à l'intérieur, soit la ligne remplit la condition et s'incrémente, soit rien ne revient et on lève un 401. Il n'y a pas d'état intermédiaire à gérer.

Après le changement, neuf exécutions du test de concurrence (trois niveaux fois trois répétitions), zéro perte.

Le cas inverse : facturer deux fois la même chose

Le deuxième cas était l'inverse. Dans un moteur de recherche sémantique, chaque recherche consomme une unité de quota et enregistre une ligne avec ce qu'elle a coûté : modèle d'embeddings, tokens, modèle de rerank, tokens d'entrée et de sortie.

Il y avait une déduplication : si tu répétais la même recherche, la réservation renvoyait duplicate, réutilisait la ligne d'origine et ne facturait pas de quota une deuxième fois.

Le problème, c'est que la recherche dédupliquée s'exécutait bel et bien. Elle rappelait les embeddings, rappelait le rerank, et le fournisseur le refacturait. Ce coût n'avait nulle part où aller : la ligne d'origine portait déjà le sien, et l'écraser aurait effacé la dépense de la première tentative. Il disparaissait donc. Le panneau d'administration disait que les recherches coûtaient moins cher qu'elles ne coûtaient.

La tentation était d'ajouter une autre ligne dans la même table avec un statut différent. Mauvaise idée, et pour deux raisons précises :

  • Toute requête de quota compte les lignes sans filtrer. Une ligne en plus là-dedans facturerait du quota que personne n'a consommé.
  • La recherche du doublon ne filtre pas non plus par statut. Elle prend la ligne la plus récente avec ce hash. La nouvelle ligne deviendrait l'ancre de la déduplication suivante, une ligne qui n'a jamais été une unité de quota.

La solution a été une table à part, unbilled_search_spend, avec les mêmes colonnes d'usage et une référence à la réservation avec laquelle le doublon a été résolu. Cette référence est d'audit, avec ON DELETE set null, et ce n'est pas une référence de quota. Le coût se voit dans les rapports, mais il n'entre pas dans le coût par recherche facturée, qui est le chiffre utilisé pour décider des prix.

Le troisième : deux soldes lus séparément

Le troisième était un SaaS de génération d'images. J'ai ajouté des crédits quotidiens par-dessus ceux de l'abonnement et la génération a cessé de marcher pour qui n'avait que les quotidiens. L'endpoint vérifiait profile.subCredits < estimatedCost et renvoyait un 402, en ignorant totalement dailyCredits. Les crédits existaient, ils s'affichaient dans la barre latérale, et le serveur disait qu'il n'y en avait pas assez.

Ce n'est pas un bug de concurrence, c'est le fait d'avoir coupé un compteur en deux et d'en avoir oublié un. Mais la racine est la même : le solde a cessé d'être un nombre pour devenir le résultat d'une opération éparpillée dans le code, refaite à la main à chaque endroit.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Cette ligne apparaît une fois dans la vérification et une autre au calcul du solde restant renvoyé au client. Deux endroits où il faut penser à additionner. Le jour où apparaîtra un troisième type de crédit, ce sera trois.

Et celui qui n'est pas un bug, mais une politique jamais écrite

Le quatrième cas est différent mais appartient à la même famille. Le webhook d'invoice.paid écrasait le solde à chaque renouvellement :

balanceCents: pricing.balanceCents, // écrasait le solde précédent

Tout ce qui n'avait pas été consommé le mois précédent s'évaporait le jour du renouvellement. Sur quarante utilisateurs d'un plan à 1499 centimes de solde, la médiane de ce qui se perdait était de 310 centimes, et le 90e centile montait à 890. Près de 60 pour cent du plan jeté dans certains cas.

Ce qui rend ça intéressant, c'est que personne ne l'avait décidé. Ce n'était pas la politique, c'était la conséquence d'avoir écrit une affectation au lieu d'une addition. Maintenant ça cumule avec un plafond de trois mois :

const nextBalance = Math.min(
  current.balanceCents + pricing.balanceCents,
  pricing.balanceCents * 3
);

Le plafond n'est pas cosmétique : sans lui, un compte à usage sporadique accumule douze mois de solde puis le dépense en un pic, ce qui est exactement ce que tu ne veux pas quand, en amont, tu paies un fournisseur d'IA à l'usage.

La règle

Tout compteur qui représente de l'argent s'écrit avec trois choses dès le premier jour. Un : l'incrément et ses conditions vivent dans une seule instruction, SET x = x + 1 WHERE ..., jamais dans un read-modify-write. Deux : s'il y a déduplication, le coût réel a un endroit où atterrir même si le quota n'est pas facturé. Trois : la politique sur ce qui reste au renouvellement est écrite quelque part, ne serait-ce que dans un commentaire, parce que sinon c'est l'opérateur d'affectation qui l'écrit à ta place.

Aucune des trois ne coûte du temps quand c'est fait au début. Les trois coûtent un incident quand c'est fait après.