À dire : En AP7, un seul serveur gardait le tableau de contributions, et c'est ce qui le rendait juste. Nous avons fini sur une question : que faire si ce serveur devient inaccessible ? Aujourd'hui, chaque poste gardera sa propre copie du tableau et continuera à compter sans les autres. Toute la difficulté sera de réunir ces copies ensuite, sans rien perdre et sans rien compter deux fois. Ouvrez le projet compteur-replique pendant que je vous présente le déroulé. Repères : avant le lancement, quiz d'ouverture sur AP7 (quiz/quiz-8-genserver, chapitre 10 du poly), dix minutes de réponse et cinq de correction, pris sur la marge du créneau. 180 min, pause comprise. Trois processus de la même VM, aucun réseau ni compte à configurer. L'exposé d'ouverture dure 20 min jusqu'à la première consigne.
À dire : Le projet suit le même rythme qu'en AP7 : un court exposé, un palier du README, une reprise. Nous commencerons par une fonction pure, sans aucun processus, que nous pourrons tester seule ; nous la placerons ensuite dans trois GenServers, un par poste. La dernière partie se joue dans IEx : nous retiendrons volontairement les échanges, puis nous les reprendrons dans le désordre. Mais d'abord, pourquoi voudrait-on plusieurs copies ? Repères : 2 min. Le squelette est autonome : on peut commencer même si AP7 n'est pas terminé, l'API et la supervision sont fournies. Les lois portent sur les états complets, pas seulement sur le total.
À dire : Le compteur d'AP7 avait un seul propriétaire, et tous les clients devaient le joindre : dans un train, ou si le réseau coupe, plus personne ne peut contribuer. Beaucoup d'applications que vous utilisez font un autre choix, comme une application de notes sur téléphone, un éditeur partagé ou un panier d'achat hors ligne. Chaque appareil garde sa propre copie, une réplique, qu'il modifie sans attendre les autres, et les copies se réconcilient quand la connexion revient. Aujourd'hui, Alice, Bob et Carol tiendront chacun leur réplique du tableau. Voyons ce qu'une coupure change pour eux. Repères : 3 min. Les noms désignent des postes, pas des personnes authentifiées. Un compteur unique partagé pourrait sérialiser toutes les écritures, mais ne répondrait pas au besoin hors connexion.
À dire : Lisons ce diagramme de séquence comme en AP6 : une colonne par poste, le temps qui descend. Alice et Bob partent de 5. Pendant la coupure, aucun message ne passe : chacun ajoute 1 et annonce 6, sans que l'un ait tort. Quand la connexion revient, les copies s'échangent leurs états ; or un réseau peut retarder un message, le livrer deux fois si l'expéditeur l'a renvoyé faute de réponse, ou livrer deux messages dans l'ordre inverse de leur envoi. Vous avez vu en AP6 qu'un délai dépassé n'annule rien : renvoyer est donc naturel, et les doublons aussi. Les deux copies devraient finir à 7, quel que soit ce désordre. Essayons les deux règles qui viennent naturellement à l'esprit. Repères : 3 min. Dans le TP, la coupure est simulée : on ne livre aucun snapshot, puis on les livre à la main, en double et en retard. Aucun câble n'est débranché.
À dire : Première idée : additionner les totaux reçus. Alice a 6, reçoit le 6 de Bob, et obtient 12 : le 5 de départ, que les deux connaissaient, est compté deux fois, et un doublon ajouterait encore 6 ! Deuxième idée : garder le plus grand total, qui ne craint pas les doublons ; mais max(6, 6) vaut 6, et l'ajout de l'un des deux a disparu. Faites-moi d'autres propositions : soustraire la valeur commune, dater les totaux, garder le plus récent. Chacune échoue dès qu'un message est répété ou qu'un troisième poste apparaît. Ce qui manque, c'est de savoir qui a apporté quoi. Repères : 4 min, en recueillant deux ou trois propositions, dont celles notées à la fin d'AP7. Dater le dernier total revient à garder le plus récent : il perd l'ajout concurrent. Soustraire le 5 commun suppose de connaître l'état commun, ce qui ne tient plus avec trois postes et des relais.
À dire : L'idée est de garder, au lieu d'un seul nombre, un compteur par auteur : une map dont les clés sont les postes. Alice n'augmente jamais que sa propre composante ; les autres composantes de sa copie viennent des états qu'elle a reçus. Le total se calcule en additionnant toutes les composantes. Une composante absente vaut zéro : ici, Carol n'a encore rien ajouté. Avec cette représentation, fusionner devient une question posée composante par composante. Repères : 2 min. Un identifiant par écrivain : deux postes qui partageraient un identifiant violeraient le contrat. Ce compteur s'appelle un G-Counter, pour grow-only counter : pas de décrément ni de remise à zéro.
À dire : À gauche, la copie d'Alice connaît alice 3 et bob 1 ; au milieu, celle de Bob connaît alice 2 et bob 2 ; chacune annonce 4. Pour chaque auteur, gardons la plus grande valeur connue : max(3, 2) vaut 3 pour Alice, max(1, 2) vaut 2 pour Bob, d'où un total de 5. Pourquoi le maximum est-il juste ici ? Parce qu'une composante ne fait qu'augmenter : la plus grande valeur connue pour un auteur est aussi la plus récente, et elle contient déjà les plus petites. Recevoir deux fois le même état ne change donc rien, contrairement à la somme. Attention, il s'agit d'un maximum par auteur, pas du maximum des totaux de tout à l'heure. Repères : 3 min. Faire calculer la fusion ligne par ligne avant de révéler la dernière case.
À dire : Trois fonctions de Map vous suffisent. Map.update prend une valeur initiale pour une clé absente, et sinon applique la fonction à la valeur existante : la première ligne crée :b avec 10, la deuxième double la valeur présente. Map.values suivi d'Enum.sum additionne les valeurs d'une map. Map.merge réunit deux maps ; quand une clé est présente des deux côtés, c'est votre fonction qui choisit la valeur, ici la somme. Remarquez que cette somme est précisément la règle naïve : à vous de choisir la bonne. Repères : 2 min. Résultats vérifiés dans Elixir. Maps et Enum viennent d'AP4 ; ne pas donner la fusion complète.
À dire : Trente-cinq minutes pour le premier palier, puis nous reprendrons. Ces trois fonctions sont pures : aucun processus, seulement des maps en entrée et en sortie. Pour chaque fonction, écrivez d'abord un exemple d'entrée et la sortie attendue, puis lancez mix test --only palier_1. Repères : 35 min d'atelier, puis 5 min de reprise. Résultat attendu : les six tests du palier 1 passent. Au besoin, rappeler Map.update/4 et Map.merge/3, sans donner la fusion complète. Les tests du palier montrent aussi les deux fusions naïves qui échouent.
À dire : La première fusion rend %{alice: 4, bob: 2} : l'état ancien d'Alice n'apporte rien de nouveau, et le maximum garde 4. La seconde rend %{alice: 2, bob: 3} : les deux contributions indépendantes sont conservées, là où le maximum des totaux n'aurait gardé que 3. Une composante ne recule jamais, puisque le maximum d'une valeur et d'une autre est au moins aussi grand que chacune. Reste à comprendre pourquoi cette fusion supporte tous les désordres du réseau. Repères : 5 min, comptées avec l'atelier. Ce sont des cas des tests du palier 1 ; faire prédire avant de les lire.
À dire : Revenons aux désordres du réseau : chacun correspond à une loi que notre fusion doit respecter. Un doublon ne doit rien changer : fusionner un état avec lui-même doit le rendre intact, c'est l'idempotence. Deux livraisons inversées doivent donner le même résultat, c'est la commutativité. Enfin, un relais qui a déjà fusionné deux états avant de transmettre le résultat ne doit pas changer l'issue, c'est l'associativité. Ces trois mots viennent de l'algèbre, et l'addition donne un bon contre-exemple : elle est commutative et associative, mais pas idempotente, puisque 2 + 2 ne vaut pas 2. Repères : exposé de la partie 2 : 6 min en tout, dont 3 pour cette slide. La monotonie des composantes et un écrivain unique par identifiant sont aussi des hypothèses du modèle, pas des conséquences des trois lois.
À dire : Vérifions les trois lois sur max, avec des nombres. max(3, 3) vaut 3 : idempotence. max(2, 5) et max(5, 2) valent 5 : commutativité. Regrouper autrement trois maxima donne 4 des deux côtés : associativité. Notre merge applique max séparément à chaque auteur, une composante absente valant zéro ; les lois passent donc de max aux maps, composante par composante. Les tests du palier 2 essaient toutes les combinaisons d'un petit domaine de maps : ils cherchent un contre-exemple, ce qui ne constitue pas une preuve pour toutes les maps. Repères : 3 min. Les lois ne suffisent pas seules à la convergence : il faut aussi que chaque information finisse par circuler.
À dire : Ce palier demande surtout d'expliquer. Si votre fusion du palier 1 est juste, les tests passent sans rien changer ; votre travail est de dire, pour chaque test, quel désordre du réseau il couvre. Écrivez vos explications dans un commentaire ou un fichier de notes. Repères : 15 min d'atelier, puis 4 min de reprise avant la pause. Commande : mix test --include palier_2. La somme viole l'idempotence. La map vide est l'élément neutre, vérifié avec l'idempotence.
À dire : Dix minutes de pause. Avant de partir, vérifiez que les tests des paliers 1 et 2 passent. Au retour, nous plaçons la fusion dans trois processus, un par poste. Repères : 10 min.
À dire : Plaçons maintenant chaque copie dans un processus, comme en AP7. Une réplique est un GenServer dont l'état contient son identifiant, qui ne change jamais, et sa map de composantes. Un ajout local appelle votre increment avec l'identifiant de la réplique ; la réception d'un état distant appelle votre merge. Le calcul reste dans les fonctions pures, que nous avons testées seules ; le processus se contente de garder le résultat d'une demande à la suivante. C'est la séparation d'AP7 : la réponse part au client, l'état suivant reste dans le serveur. Repères : exposé de la partie 3 : 8 min en tout, dont 3 pour cette slide. L'API, la supervision et les clauses :snapshot et :value sont fournies.
À dire : Pour échanger, une réplique fournit un snapshot, c'est-à-dire une copie de sa map à un instant donné. Comme toutes nos valeurs sont immuables, ce snapshot ne suivra pas les modifications ultérieures : ici, state est relié à une nouvelle map, mais snapshot garde l'ancienne. C'est précisément ce qui nous permet de simuler un réseau : un snapshot gardé dans une variable est un message en transit, que nous livrerons plus tard, en retard ou en double. Il n'existe aucune fenêtre ouverte sur l'état vivant d'un autre processus. Repères : 3 min. Valeurs vérifiées dans Elixir. Un snapshot pris chez Bob avant une fusion ne contiendra jamais ce que Bob apprend ensuite.
À dire : Les deux clauses à écrire suivent la même forme : calculer la nouvelle map avec une fonction de GCounter, puis rendre la réponse et l'état suivant. La syntaxe avec la barre verticale construit une nouvelle map identique à l'ancienne, sauf pour le champ indiqué ; l'identifiant reste donc inchangé. La clause :snapshot, fournie, montre une requête qui ne modifie pas l'état. Repères : 2 min. La même règle pure sert au test et au serveur : aucune fusion ne doit être réécrite dans Replica.
À dire : Une demi-heure pour le troisième palier. Pour chaque clause, demandez-vous quel processus l'exécute, ce que reçoit le client et ce que garde la réplique. Une fois les tests verts, lisez les deux derniers tests du fichier : ils annoncent les deux expériences de la fin de séance. Repères : 32 min d'atelier, puis 5 min de reprise. Résultat attendu : les tests des trois paliers passent. Pièges : incrémenter une composante autre que state.id ; répondre le total mais garder l'ancienne map. Si le groupe prend du retard, fournir les callbacks en reprise et faire la coupure collectivement.
À dire : Dans IEx, l'application démarre trois répliques nommées :alice, :bob et :carol. Simuler une coupure ne demande aucun câble : il suffit de ne faire aucun appel à merge pendant que chacun compte. Prendre les snapshots avant la moindre fusion revient à capturer les messages en transit ; les livrer ensuite à chaque réplique, c'est la reconnexion. Après cette trace, chaque réplique connaît alice 2, bob 3 et carol 1, et annonce 6. Vous pourrez ensuite livrer dans un autre ordre, deux fois, ou garder un ancien snapshot pour le livrer après un récent ; prédisez les trois maps avant chaque livraison. Repères : 5 min. Trace du README, vérifiée : %{alice: 2, bob: 3, carol: 1} et 6 partout. Partir d'une session IEx fraîche. Attendre les réponses de merge avant les lectures.
À dire : Un quart d'heure d'expériences dans IEx, en suivant les deux dernières sections du README. Dessinez les flèches avec les maps transportées avant de lancer les appels, comme un diagramme de séquence. Pour le relais, attention : un snapshot de Bob gardé dans une variable ne se met pas à jour tout seul. Repères : 15 min d'atelier, puis 5 min de reprise avec les deux schémas suivants. Toutes les mises à jour doivent atteindre les répliques, éventuellement indirectement. Rejouer un ancien snapshot après la convergence pour distinguer vieillissement et perte d'information.
À dire : Lisons les trois colonnes. Au départ, Alice connaît 2, 0, 0, Bob 0, 1, 0 et Carol rien. Après un envoi d'Alice vers Bob, puis de Bob vers Carol, Bob et Carol connaissent 2, 1, 0 ; un dernier envoi de Bob vers Alice rend les trois copies égales. Carol n'a jamais rien reçu d'Alice, et pourtant elle connaît ses ajouts : le snapshot pris chez Bob après sa fusion les relaie. Les répliques convergent, c'est-à-dire finissent par porter le même état, dès que chaque information a fini par circuler, directement ou non. Repères : reprise comprise dans les 5 min qui suivent l'atelier. Le test « un relais transmet aussi les contributions reçues » sert de point de comparaison.
À dire : L'état local connaît alice 3 et bob 2. Il reçoit un doublon, alice 3 et bob 2, puis un ancien snapshot, alice 2 et bob 1 : le maximum par composante garde alice 3 et bob 2, et le total reste 5. Que ferait une somme des snapshots reçus ? 5 + 5 + 3, bien loin des cinq contributions réelles ! Remarquez enfin qu'un message ancien n'est pas inutile par principe : il peut encore apporter une composante que la réplique n'a jamais reçue. Repères : reprise comprise dans les 5 min. Choisir seulement le snapshot le plus récent perdrait cette garantie : poser la question si le temps le permet.
À dire : Converger ne veut pas dire être toujours d'accord : après un ajout local, deux répliques annoncent des totaux différents tant que l'information n'a pas circulé. Et nos états vivent seulement en mémoire : un ajout perdu avant tout échange ne peut être reconstruit par personne. Pire, relancer une réplique à zéro sous le même identifiant n'est pas sûr : si Bob connaît déjà alice 2, une Alice relancée qui ajoute 1 produit alice 1, que le maximum ignore. C'est ce que montre le dernier test du palier 3. Repères : ouverture et bilan : 15 min en tout, dont 3 pour cette slide. Ne pas montrer un crash suivi d'un reset comme une récupération sûre. Stockage durable ou identité d'incarnation : à poser en questions, sans ajouter d'infrastructure au TP. Le compteur totalise des contributions ; il ne garantit pas une capacité globale, comme une dernière place libre.
À dire : Notre compteur n'a ni position ni suppression ; un éditeur de texte partagé, si. Partons de ab : Alice insère X à l'indice 0, Bob insère Y à l'indice 1, chacun sur sa copie. Si l'on applique d'abord Alice, ab devient Xab, et l'indice 1 de Bob désigne maintenant a au lieu de b : on obtient XYab. Dans l'autre ordre, aYb puis XaYb. Les deux copies divergent, alors que chaque opération était correcte sur son texte de départ ! Repères : 4 min. Faire les insertions à la main au tableau ; les positions sont celles du texte de départ.
À dire : Deux familles de solutions existent. La transformation opérationnelle, OT, adapte une opération à celles déjà appliquées : ici, décaler l'indice de Bob après l'insertion d'Alice suffirait. Un CRDT de séquence, comme notre G-Counter est un CRDT de compteur, donne plutôt à chaque caractère un identifiant stable et des règles d'ordre, si bien que la position ne dépend plus de l'histoire. Le projet éditeur concurrent d'AP9 vous fait écrire le cœur d'un tel éditeur ; l'interface web et les GenServers sont fournis. Repères : 4 min. Décaler l'indice ne suffit pas en général : égalités, suppressions, causalité et intention compliquent la suite. C'est l'un des sujets du catalogue ; le choix porte sur le sujet et le projet choisi est noté.
À dire : En trois séances, nous sommes partis de deux processus qui s'écrivent, puis d'un serveur qui protège son état, pour arriver à des copies qui se réconcilient sans coordinateur. Pour vérifier que tout cela tient, racontez à votre voisin une trace complète, y compris ce que les copies ne savent pas pendant la coupure. Gardez votre projet et vos explications. Le quiz d'ouverture d'AP9 portera sur cette séance : relisez le chapitre 11 du poly. Les deux lectures sont pour les curieux. Repères : 4 min. Modalités du cycle précisées en cours.