Une commande, deux écritures : l’endroit où l’atomicité disparaît
Pourquoi une transaction SQL ne protège pas un envoi de message, et comment associer outbox et idempotence sans promettre l’impossible.
Le scénario tient en deux lignes : enregistrer une commande, puis envoyer un événement. En développement, les deux lignes réussissent. En production, le processus peut mourir entre les deux. La commande existe alors dans la base, mais personne ne sait qu'il faut préparer le colis.
Inverser les lignes ne supprime pas le problème. Si l'événement part avant que la transaction échoue, un consommateur peut préparer une commande qui n'existe pas. Ce n'est pas un défaut de syntaxe ou un timeout trop court. Les deux effets appartiennent à deux systèmes qui ne partagent pas notre transaction locale.
Énumérer les états avant de choisir un outil
Notons l'écriture métier et la publication. L'état attendu est . L'état initial est . Une panne peut laisser ou . Un bloc try/catch ne transforme pas ces deux opérations en une action atomique : le réseau peut cesser de répondre après que le serveur distant a déjà effectué l'action.
Il faut également distinguer « échec » et « résultat inconnu ». Si la connexion disparaît pendant un commit, le client ne sait pas toujours si le serveur a validé. Relancer aveuglément une nouvelle commande peut produire un doublon métier avant même de parler de messagerie.
Enregistrer l’intention dans la même transaction
L'outbox remplace la publication immédiate par une ligne décrivant l'événement à publier. Cette ligne et la commande sont écrites dans la même base et la même transaction. Un relayeur indépendant lit ensuite les événements et les transmet.
BEGIN;
INSERT INTO orders (id, customer_id, total_cents)
VALUES ('order-42', 'customer-7', 12500);
INSERT INTO outbox (id, aggregate_id, event_type, payload)
VALUES (
'event-42', 'order-42', 'OrderCreated',
'{"orderId":"order-42","totalCents":12500}'
);
COMMIT;Cet exemple suppose que les identifiants et les contraintes existent déjà. Il omet volontairement l'API et la gestion de concurrence pour montrer la seule propriété importante : après commit, commande et intention de publication sont présentes ensemble. Une capture du journal de transactions est une autre façon d'alimenter le relayeur ; le routeur outbox de Debezium documente ce mécanisme.
Le doublon qui reste
Le broker accepte l'événement. Puis le relayeur meurt avant d'enregistrer son succès. Au redémarrage, l'événement semble encore à envoyer. Le renvoyer est nécessaire pour éviter une perte dans le cas opposé, mais peut créer un doublon.
On choisit donc souvent une livraison au moins une fois, puis on rend l'effet du consommateur idempotent. En langage mathématique, appliquer deux fois une opération idempotente donne le même état que l'appliquer une fois :
« Définir le statut à payé » peut satisfaire cette propriété ; « ajouter 125 euros » ne la satisfait pas. La propriété dépend de l'opération et de l'état observé, pas du nom du endpoint.
Pour un consommateur SQL, l'identifiant de l'événement peut être inséré dans une table avec contrainte d'unicité. Cette insertion et l'effet métier doivent partager une transaction. Si l'identifiant existe déjà, on évite de réappliquer l'effet. Un simple test préalable « est-ce traité ? » laisse deux consommateurs concurrents répondre non en même temps.
La frontière de la garantie
Si le consommateur envoie ensuite un courriel, l'envoi sort de sa transaction SQL. Le même raisonnement recommence. Il faut une garantie du prestataire, une clé d'idempotence reconnue par celui-ci, une autre intention durable ou accepter un risque de doublon adapté au métier. Ajouter une table ne rend pas l'ensemble de l'univers transactionnel.
Une clé doit représenter la même intention, pas seulement un contenu identique. Deux achats volontaires du même panier peuvent être deux commandes valides. À l'inverse, réutiliser une clé avec un montant différent doit être détecté. L'article AWS sur les API idempotentes développe cette distinction ainsi que le cas des requêtes tardives.
Ce que le test de panne doit démontrer
Un test utile tue le relayeur après la publication puis le relance. L'événement peut apparaître deux fois ; l'effet métier doit apparaître une seule fois dans le périmètre annoncé. Un autre test lance deux consommateurs sur le même identifiant pour vérifier que la contrainte de base arbitre correctement.
La rétention des identifiants compte aussi : purger trop tôt la table de déduplication rend un ancien événement à nouveau applicable. Quant à l'ordre, deux événements d'une même commande peuvent nécessiter une clé de partition ou une vérification de version. Déduplication et ordre sont des propriétés distinctes.
La conclusion raisonnable est locale : une transaction protège les écritures qu'elle contient, un identifiant stable permet de reconnaître une répétition et le métier définit l'effet à protéger. Cette formulation est moins spectaculaire qu'« exactly once partout », mais elle se teste en coupant réellement le processus au mauvais moment.