Sémantiques de livraison
Le chapitre précédent a posé un mot sans le creuser : l'acquittement. Tout se joue sur son instant. Acquitter trop tôt, c'est risquer de perdre un message ; acquitter trop tard, c'est risquer de le traiter deux fois. Il n'y a pas de troisième porte, et ce chapitre explique pourquoi.
Au chapitre deux, on a vu ce que devient un message : effacé après acquittement chez le courtier, conservé et relisible chez le journal. On a même glissé une phrase lourde de conséquences : « acquitté égale effacé ». Mais on a laissé un trou. Reviens au facteur. À quel instant exact note-t-il « livré » ? Au moment où il te tend l’enveloppe, ou seulement après que tu as signé ? Entre ces deux instants se cache la possibilité d’un accident, et selon le choix, ton message est soit perdu, soit reçu deux fois. Ce chapitre place cet instant sous le microscope et te donne le vocabulaire exact pour décrire ce qu’un système promet vraiment quand il dit « je te livre ce message ».
Le facteur et l’instant de l’acquittement
Reprends le facteur du chapitre deux. Il porte une lettre, te la remet, et à un moment il note dans son carnet « distribué », puis il jette sa copie. Cette note, c’est l’ acquittement Acquittement Signal par lequel un consommateur confirme qu'un message a été pris en charge, autorisant le courtier à l'effacer. Son instant est décisif : acquitter avant de traiter expose à la perte (au plus une fois), acquitter après un traitement réussi expose au doublon en cas de redélivraison (au moins une fois). Tant qu'aucun acquittement n'arrive, le courtier peut redélivrer le message. Source : RabbitMQ, Consumer Acknowledgements : le signal qui dit « c’est réglé, on peut effacer ». Une fois la copie jetée, plus personne ne pourra rejouer cette lettre. L’acquittement n’est donc pas un détail administratif : c’est le geste qui autorise l’oubli.
Toute la question tient en un mot : quand ? Deux instants sont possibles, et un seul accident sépare leurs conséquences.
Premier choix, le facteur note « distribué » au moment précis où il te tend l’enveloppe, avant même que tu l’aies prise en main. S’il jette sa copie à cet instant et que tu laisses tomber la lettre dans une flaque juste après, elle est perdue : il n’en garde aucune trace, personne ne te la rejouera. Tu as reçu la lettre au plus une fois, peut-être zéro.
Second choix, le facteur attend ta signature avant de noter « distribué ». Mais imagine que la foudre tombe juste après que tu as signé, et avant qu’il ait pu inscrire ta signature dans son carnet. De son point de vue, la lettre n’est pas acquittée : il reviendra demain te livrer la même. Tu la recevras donc au moins une fois, parfois deux.
Perdre une fois, ou recevoir deux fois. Ce n’est pas un dilemme technique mineur : c’est le coeur des sémantiques de livraison, et on va maintenant lui donner ses noms exacts.
Les trois promesses
Un système de messages déroule toujours, pour un message donné, trois actions : il le délivre au consommateur, le consommateur le traite (c’est l’effet réel : débiter une carte, réserver un stock, envoyer un courriel), et quelqu’un acquitte. Ce qui change tout, c’est l’ordre de ces actions et ce qu’on fait en cas de panne. Trois disciplines existent, et seulement trois.
La première place l’acquittement avant le traitement. On efface le message dès qu’on le reçoit, puis on le traite. Si une panne survient entre l’acquittement et la fin du traitement, le message est déjà effacé : personne ne le rejouera, l’effet n’aura jamais lieu. C’est la livraison au plus une fois Livraison au plus une fois Garantie selon laquelle un message est traité zéro ou une fois, jamais davantage (en anglais at-most-once). On l'obtient en acquittant le message dès sa réception, avant de le traiter : une panne survenant entre l'acquittement et la fin du traitement perd le message, car il a déjà été effacé. On ne duplique jamais, mais on peut perdre. Adaptée aux flux où la perte occasionnelle est sans conséquence. Source : Kleppmann, 2017 (« at-most-once ») : le message est traité zéro ou une fois, jamais plus. On ne duplique jamais, mais on peut perdre.
La deuxième place l’acquittement après un traitement réussi, et redélivre tant qu’aucun acquittement n’est revenu. Si une panne survient après le traitement mais avant l’acquittement, le système, ne voyant pas d’acquittement, redélivre le message, et l’effet se reproduit. C’est la livraison au moins une fois Livraison au moins une fois Garantie selon laquelle un message est traité une fois ou plus, jamais zéro (en anglais at-least-once). On l'obtient en acquittant seulement après un traitement réussi et en redélivrant tant qu'aucun acquittement n'arrive : une panne survenant après le traitement mais avant l'acquittement provoque une redélivraison, donc un doublon. On ne perd jamais, mais on peut dupliquer ; c'est pourquoi le traitement doit être idempotent. Source : Kleppmann, 2017 (« at-least-once ») : le message est traité une fois ou plus, jamais zéro. On ne perd jamais, mais on peut dupliquer.
La troisième est le rêve de tout le monde : « exactly-once », exactement une fois, ni perte ni doublon. C’est la garantie qu’on aimerait toujours avoir. Le problème, c’est qu’au niveau de la livraison, elle est impossible. La section suivante explique pourquoi, et la section d’après montre comment on s’en approche quand même.
Pourquoi « exactly-once » de livraison est impossible
Pour comprendre l’obstacle, on raconte une vieille énigme : le problème des deux généraux Problème des deux généraux Résultat classique d'informatique théorique : sur un canal de communication non fiable, où chaque message peut se perdre, aucun protocole à nombre fini de messages ne permet à deux parties d'être certaines, ensemble, d'un même accord. Appliqué au messaging, il prouve que la livraison exactly-once est impossible, car l'acquittement lui-même peut se perdre : l'émetteur doit choisir entre risquer la perte ou risquer le doublon. Source : Akkoyunlu et al., 1975 .
Deux armées alliées campent sur deux collines, séparées par une vallée où se tient l’ennemi. Pour gagner, elles doivent attaquer au même moment : si une seule charge, elle est écrasée. Les généraux ne peuvent communiquer qu’en envoyant des messagers à travers la vallée, et ces messagers peuvent être capturés en chemin. Le premier général envoie « attaque à l’aube ». Mais il ne saura jamais si le messager est passé, alors il a besoin d’un accusé de réception. Le second renvoie « d’accord, à l’aube ». Sauf que ce second messager peut être capturé lui aussi : le second général ne sait donc pas si son accusé est arrivé, et n’osera pas attaquer sans en être sûr. Il faudrait alors un accusé de l’accusé, puis un accusé de cet accusé, à l’infini. On démontre qu’aucun protocole à nombre fini de messages ne garantit que les deux généraux soient certains, ensemble, du même plan.
Le lien avec nos messages est direct. L’émetteur ne peut jamais être absolument sûr que le consommateur a traité le message exactement une fois, parce que l’acquittement lui-même, le dernier messager, peut se perdre. Devant cette incertitude, il n’a que deux conduites possibles : ne pas réessayer, et risquer la perte (au plus une fois) ; ou réessayer, et risquer le doublon (au moins une fois). Il n’existe pas de troisième conduite pure au niveau de la livraison. C’est exactement pour cela que « exactly-once delivery » est un mythe : ce n’est pas une limite des outils d’aujourd’hui, c’est une impossibilité de fond.
La sortie : au moins une fois, plus l’idempotence
Si on ne peut pas garantir la livraison exacte, on déplace le problème. On accepte les doublons à la livraison (donc au moins une fois), mais on les rend inoffensifs au traitement. Traiter deux fois le même message doit produire le même résultat que le traiter une seule fois. Cette propriété s’appelle l’ idempotence Idempotence Propriété d'un traitement dont l'exécution répétée produit le même résultat qu'une exécution unique. Appliquée au messaging, elle rend les doublons inoffensifs : un message déjà traité est reconnu et son effet n'est pas refait. C'est le mécanisme qui transforme une livraison au moins une fois en livraison effectivement une fois. La façon concrète de fabriquer un consommateur idempotent (clé de déduplication, atomicité) fait l'objet du chapitre suivant. Source : Hohpe & Woolf, 2003 , et c’est elle qui sauve la mise.
Une image simple : appuyer sur le bouton « éteindre » d’un ascenseur déjà éteint ne change rien ; appuyer une deuxième fois sur « appeler l’étage 3 » alors qu’il est déjà demandé ne le demande pas deux fois. L’effet est le même, qu’on appuie une ou dix fois. Un traitement idempotent, c’est pareil : le second passage d’un message déjà vu ne refait pas l’effet, il le reconnaît et passe son tour.
La combinaison « au moins une fois plus traitement idempotent » porte un nom : la livraison effectivement une fois Livraison effectivement une fois Combinaison d'une livraison au moins une fois et d'un traitement idempotent, qui rend l'effet observable identique à un traitement unique (en anglais effectively-once). On ne supprime pas les doublons de livraison, on les neutralise : un message déjà traité est reconnu et son effet n'est pas refait. C'est l'approximation réaliste du exactly-once, impossible au niveau de la livraison à cause du problème des deux généraux. Source : Kleppmann, 2017 (« effectively-once »). On ne supprime pas les doublons de livraison, on les neutralise. Du point de vue de l’effet observable, le paiement n’est prélevé qu’une fois, même si le message est arrivé deux fois.
Comment fabrique-t-on concrètement un traitement idempotent, et comment garde-t-on la trace des messages déjà vus sans rouvrir le problème ? C’est tout le sujet du chapitre suivant : on le nomme ici, on le construira là-bas.
Observe le moment de la panne
Le composant ci-dessous fait tourner le même message, débiter un paiement, dans les trois disciplines à la fois. Tu choisis l’instant de la panne, et tu compares.
Commence sans panne : les trois prélèvent une fois, tout va bien. Place ensuite la panne après réception, avant traitement : regarde la livraison au plus une fois perdre le paiement (zéro prélèvement), tandis que les deux autres redélivrent et finissent à un. Place enfin la panne après traitement, avant acquittement, le moment vraiment révélateur : la livraison au moins une fois prélève deux fois (le doublon), pendant que la livraison effectivement une fois reconnaît le message déjà vu et reste à un. C’est toute la leçon du chapitre, en un sélecteur.
Choisis le moment de la panne, puis compare les trois disciplines sur le même message « débiter le paiement ». Le cas décisif est la panne après traitement, avant acquittement.
Moment de la panne
Au plus une fois
Acquitte avant de traiter
Au moins une fois
Acquitte après le traitement
Effectivement une fois
Au moins une fois, plus traitement idempotent
Trois questions à te poser en jouant :
- Avec la panne « après réception, avant traitement », pourquoi la livraison au plus une fois est-elle la seule à perdre le message, alors qu’elle n’a même pas encore prélevé ?
- Avec la panne « après traitement, avant acquittement », la livraison au moins une fois et la livraison effectivement une fois redélivrent toutes les deux. Qu’est-ce qui fait que l’une prélève deux fois et l’autre une seule ?
- Quelle est la seule colonne qui affiche « une fois exactement » quel que soit le moment de la panne, et qu’a-t-il fallu lui ajouter pour y arriver ?
Côté code : les trois modes d’Hexeract, le filet de Wolverine
Voyons ces disciplines dans du vrai code. Et la surprise, c’est qu’elles ne sont pas des théories : ce sont des réglages qu’on pose à la main.
Hexeract, le framework Rust de messaging qui nous sert de fil rouge, expose exactement ces choix sous la forme d’un mode d’acquittement réglable sur le travailleur. Son énumération AckMode a trois variantes, et leurs noms parlent d’eux-mêmes.
// Au moins une fois (le defaut) : on acquitte APRES un traitement reussi.
// Le courtier redelivre tant qu'aucun acquittement n'arrive,
// donc un doublon est possible : le handler DOIT etre idempotent.
let worker = RabbitMqWorkerBuilder::new(connection)
.queue("orders.charge-payment")
.register_handler::<ChargePayment, _>(ChargePaymentHandler)
.ack_mode(AckMode::Manual)
.max_attempts(5) // nombre de redelivraisons avant d'abandonner
.build()?;
// Au plus une fois : on acquitte DES la reception, avant le handler.
// Une panne apres l'acquittement et avant la fin du handler perd le message.
let logger = RabbitMqWorkerBuilder::new(connection)
.queue("analytics.click")
.register_handler::<ClickEvent, _>(RecordClickHandler)
.ack_mode(AckMode::AckOnReceive)
.build()?;
Le commentaire de la variante AckMode::Manual dans le code d’Hexeract dit textuellement que les handlers doivent être idempotents, parce que des doublons peuvent survenir. C’est la preuve par le code de ce chapitre : choisir « au moins une fois » oblige à prévoir l’idempotence côté traitement. Une troisième variante, AckMode::Unacknowledged, pousse la logique au bout : le courtier n’attend aucun acquittement, c’est du tout-ou-rien sans filet, à réserver aux cas où la perte est acceptable.
Wolverine, son équivalent en .NET, est instructif parce qu’il fournit le filet d’idempotence prêt à l’emploi. Sur un transport Kafka, on demande au consommateur de persister chaque message reçu avant de valider sa position de lecture, ce qui donne une livraison au moins une fois ; et la même persistance sert à reconnaître un identifiant déjà traité pour l’écarter. La livraison effectivement une fois devient une seule ligne.
// Au moins une fois : le message est persiste avant de valider l'offset.
// La meme boite de reception durable deduplique par identifiant de message,
// donc un message deja traite est ecarte : effectivement une fois.
opts.ListenToKafkaTopic("orders.charge-payment")
.UseDurableInbox();
// On peut aussi reessayer finement sur une exception transitoire.
chain.OnException<TransientException>()
.RetryWithCooldown(100.Milliseconds(), 250.Milliseconds());
La documentation de Wolverine le formule clairement : avec la boîte de réception durable, le système écarte automatiquement tout message dont il détecte qu’il a déjà été traité, en comparant son identifiant à ceux déjà vus. Deux écosystèmes, une même leçon : on choisit explicitement sa sémantique de livraison, et le effectively-once n’est jamais magique, c’est de l’au-moins-une-fois plus une déduplication.
Exercices
Prends une feuille et un crayon. Les corrigés sont juste en dessous, à ne regarder qu’après avoir essayé.
Exercice 1 : la bonne promesse pour le bon message
Pour notre commande e-commerce, deux flux très différents. (a) Un flux d’événements de clics alimente un tableau de bord d’analytique : on en reçoit des milliers par minute, et perdre un clic de temps en temps ne change rien à la tendance. (b) Le prélèvement du paiement de la commande : il ne faut ni l’oublier (le client ne paierait pas), ni le faire deux fois (double débit, client furieux). Pour chacun, dis quelle sémantique de livraison tu choisis et pourquoi, en une phrase appuyée sur l’instant de l’acquittement.
Exercice 2 : l’accident au pire moment
Un travailleur reçoit le message « débiter 49 euros », exécute le prélèvement avec succès, puis sa machine s’éteint brutalement avant d’avoir pu acquitter. Décris ce qui se passe ensuite (i) si le travailleur est en livraison au plus une fois, (ii) s’il est en livraison au moins une fois sans idempotence, (iii) s’il est en livraison au moins une fois avec un traitement idempotent. Pour chaque cas, conclus par le nombre de fois où les 49 euros sont effectivement prélevés.
En une phrase
L’instant de l’acquittement par rapport au traitement décide de la garantie : acquitter avant donne au plus une fois (on peut perdre), acquitter après donne au moins une fois (on peut dupliquer), le exactly-once de livraison est impossible (problème des deux généraux), et on l’imite par une livraison au moins une fois doublée d’un traitement idempotent, l’effectively-once.
1. Qu'est-ce qui détermine la sémantique de livraison d'un message ?
2. Pourquoi le « exactly-once » de livraison est-il impossible ?
3. Comment obtient-on en pratique un effet appliqué une seule fois malgré les doublons de livraison ?
Vers le chapitre suivant
On sait maintenant choisir sa promesse de livraison, et on a vu que la seule façon sûre de ne ni perdre ni dupliquer passe par l’idempotence. Mais on l’a invoquée sans dire comment on la fabrique : comment un travailleur reconnaît-il un message « déjà traité » ? Avec quelle clé, stockée où, et validée comment sans rouvrir le problème des deux généraux à l’intérieur de sa propre base ? Autre angle mort : la livraison au moins une fois redélivre les messages, mais dans quel ordre les rejoue-t-elle ? Si « Commande livrée » repasse avant « Commande payée », l’idempotence seule ne suffit plus. Le chapitre quatre, « Ordre et idempotence », construit le consommateur idempotent, sa clé de déduplication, et traite l’ordre par clé et par partition, ce morceau qu’on a soigneusement mis de côté au chapitre deux.
Sources
- Akkoyunlu, E. A., Ekanadham, K. & Huber, R. V. (1975). « Some Constraints and Tradeoffs in the Design of Network Communications. » ACM SIGOPS Operating Systems Review 9(5), 67-74. Formulation d’origine du problème des deux généraux. DOI 10.1145/1067629.806523
- Kleppmann, M. (2017). Designing Data-Intensive Applications, chapitre 9 « Consistency and Consensus » et chapitre 11 « Stream Processing » (sémantiques de livraison, idempotence, exactly-once). O’Reilly. Référence de l’éditeur
- Apache Kafka. Documentation : Message Delivery Semantics et Exactly-Once Semantics. kafka.apache.org
- RabbitMQ. Consumer Acknowledgements and Publisher Confirms. rabbitmq.com
- Wolverine. Durable Inbox and Idempotent Message Delivery. Documentation officielle. Durabilité, Idempotence