Les échanges

« Dans les terres sauvages d’Azeroth, même les plus puissants chamans savent que la force réside dans l’harmonie des éléments. Chaque guerrier agit de manière autonome, mais tous sont prêts à s’unir pour former un tout indivisible, synchronisant leurs actions avec la précision d’une horde en marche. »

— Thrall, Chef de Guerre de la Horde

La synchronisation est un mécanisme d’échange d’états entre un client et un serveur. L’objectif étant qu’à la fin, le serveur et tous les clients aient les mêmes états pour tous les acteurs participants à la synchronisation. Ici on parle d’une architecture distribuée où chaque participant est une copie du serveur de référence.

Les états proviennent des acteurs du système. On utilise (dans notre système) généralement un jargon comme goblin ou elf. Dans les deux cas, on peut simplement parler d’acteurs (pas un acteur comme Orlando Bloom, bien qu’il soit possible de faire des analogies). Les acteurs qui nous intéressent sont des entités qui ont un état qui doit être partagé avec tout le monde.

Un acteur encapsule un état et un comportement. Les communications entre les acteurs sont toujours asynchrones en envoyant et en recevant des messages avec uniquement du contenu sérialisable.

Prenons Yeti (un logiciel de gestion de demandes, tickets, …), ce logiciel a de nombreux acteurs comme par exemple les demandes. Une demande est un acteur de type Elf, qui a un état sérialisable (persistant). Dans notre jargon interne on parle d’Archetype. Ici il faut simplement se dire que c’est un acteur qui contient des informations sauvegardées sur l’ordinateur local. Quand le programme est arrêté, les données de l’acteur ne sont pas perdues. C’est très important car il existe aussi les acteurs de type Spirit qui peuvent exister uniquement quand le programme est en cours de fonctionnement (volatile).

🥖 Local first

L’architecture est donc distribuée. Chaque client a localement les contenus de tous les acteurs. Le programme peut fonctionner sans connexion réseau. C’est le cas avec Yeti où toutes les fonctionnalités qui ne dépendent pas de services externes, sont disponibles même si le serveur Yeti n’est pas joignable. On parle alors de local-first car avant tout, les données sont manipulées localement et ensuite (si possible) elles sont transmises à (dans notre cas) un serveur. Par exemple, Git est une application local-first typique.

🌐 Le partage

Pour partager les données il existe de nombreuses architectures. Ici je vais vous présenter les mécaniques qui ont été mises en place avec les Elfes. L’infrastructure est assez classique. Il y a des clients et un serveur. Les clients sont tous identiques et fonctionnent en local-first. Le serveur est différent car il sert de référence pour les clients. Néanmoins il partage la plupart des mêmes acteurs que les clients tout est étant plus léger car il ne contient pas les acteurs dédiés à l’interface graphique (ceci dit il peut contenir des acteurs spécifiques au serveur).

📀 Les états

Les états (ou “states” en anglais) sont simplement les données de l’acteur. Par exemple, avec une demande Yeti (qui est un acteur), on y trouve une description et une liste de tâches (je simplifie pour l’exemple). La description est sauvegardée (persistée) sur l’ordinateur tout comme la liste des tâches (ici les tâches sont aussi des acteurs, mais restons simple). L’état complet est sauvegardé dans une base de données SQLite, qui ressemble un peu à cela :

actions
- id
- type
- payload

(Cette table est simplifiée pour aider à la compréhension du mécanisme.) Il y a l’identifiant de l’acteur, le type d’action et les données de l’action.

Actions ? Mais de quoi parle-t-on ? ⚠️

📈 Les actions

Pour comprendre les actions, je dois détailler un peu plus les “Elf” (acteurs). Ceux-ci sont constitués de deux parties bien visibles : il y a les quêtes et les reducers.

Les quêtes sont les APIs de l’acteur. Pour faire quelque chose avec l’acteur, il faut lui appeler une quête. Par exemple, avec les demandes Yeti, on peut imaginer une quête qui se nomme close et qui permet de fermer la demande. Fermer une demande doit changer le statut de celle-ci puis envoyer un mail (c’est un exemple).

La quête close fait deux choses :

  1. Elle demande à son reducer de changer le statut.
  2. Elle envoie un mail (plus précisément, elle demande à un autre acteur d’envoyer un mail).

L’étape une est dites pure car les reducers sont des fonctions pures. Ce qui veut dire que pour un même état et de même paramètres, le reducer rendra toujours un même nouvel état. Les reducers sont prédictibles.

L’étape deux montre que les quêtes ne sont pas pures car elles peuvent avoir des effets de bord. Ici l’effet de bord est de faire appel à un autre acteur pour envoyer un mail. Cette étape n’est pas prévisible car peut être qu’il est impossible d’envoyer le mail.

Je ne vais pas m’attarder plus longtemps sur les effets de bord. Ce qui nous intéresse ici est avant tout la modification de l’état de l’acteur avec le reducer.

Ici, la quête close a un reducer du même nom. Quand ce reducer est appelé, un champ “status” est modifié en “closed”. Cette opération passe par mécanisme qui suit le pattern d’architecture flux (que je ne vais pas détailler ici).

L’appel du reducer close commence par la création d’une action (ce que je décris ici, c’est de la mécanique non-visible pour celui qui développe un Elf). Une action est composée principalement de trois attributs :

{
  "type": "close",
  "payload": {},
  "meta": {}
}

Intéressons-nous aux attributs type et payload. Le type permet de faire la distinction entre tous les reducers de l’acteur. Le payload contient les données sérialisables que l’on souhaite donner au reducer. Dans l’exemple du close, il n’y a pas de payload particulier à transmettre car le type de l’action suffit.

Ci-dessus, j’ai mis en évidence “sérialisable” car de telles actions doivent pouvoir être enregistrées dans une base de données.

🧪 Middlewares

Nous avons donc une action qui a basculé un statut sur ‘closed’. Mais nous sommes encore loin de pouvoir synchroniser quoi que ce soit. Ici, je vais brièvement aborder les middlewares. L’idée est assez simple, un middleware peut se mettre au milieu d’un flux.

Dans notre cas, on peut injecter un middleware dans le flux d’actions pour (par exemple) envoyer une copie de l’action à une base de données. Ceci permet à notre action close d’exister sous la forme sérialisée dans notre base de données SQLite. L’idée est simple : si j’ai l’action, il me suffit de la jouer à nouveau pour reconstruire l’état d’un acteur. Maintenant vous avez une des briques essentielles de la synchronisation. Un middleware récupère toutes les actions de tous les acteurs sérialisables et les stock dans une (ou des) base(s) de données SQLite.

Comprenez bien que ce mécanisme est utile pour la synchronisation mais qu’il existe avant tout pour gérer la persistance localement de tous les acteurs.

🪟 Actions store

Nos bases de données sont des actions stores. Chaque mutation de l’état d’un acteur va toujours provoquer l’insertion de la dite action comme expliqué précédemment.

Ce qui veut dire que les actions stores ne font pas que conserver l’état de chaque acteur, mais ils conservent tous les états dans le temps. L’axe temporel offre de nombreux avantages. Il permet de comprendre comment évolue un acteur, il offre un moyen pour retrouver des contenus précédents (par exemple pour offrir des outils d’« annulation » à l’édition). Cet axe temporel est également indispensable pour le mécanisme de synchronisation.

L’axe temporel n’est pas représenté par un timestamp, mais uniquement par un compteur qui s’incrémente à chaque insertion.

💱 Les transactions

Revenons à notre exemple de la demande sur laquelle nous appelons la quête close. Il manque encore quelque chose d’essentiel. Cette action ne fait rien de plus que de dire qu’il faut changer le statut. Bien, mais cette action ne contient aucune donnée. Elle a un type et payload est un objet vide. En gardant cette action dans l’actions store, on ne connaît pas du tout l’état de l’acteur. C’est simplement parce que je n’ai pas abordé la question des transactions. Ici, il n’est pas question de discuter des transactions SQL qui concernent la couche SQLite. Les transactions de l’actions store sont plus haut niveau et voici comment cela fonctionne.

Il existe des types particuliers d’actions qui se nomment create et persist.

Ces deux types d’action sont réservés aux usages suivants :

  • create : naissance d’une entité
  • persist : instantané d’une entité

Lorsqu’un acteur utilise un identifiant nouveau dans l’application (par exemple une nouvelle demande Yeti), un reducer create va être appelé pour donner naissance à cet acteur. Ce create ne peut pas être appelé plus d’une fois par identifiant d’acteur. C’est l’action qui va donner l’état initial de l’acteur. Néanmoins, comme pour l’action close, le payload ne contient pas l’état complet de ce nouvel acteur.

La seconde action, par contre, est plus intéressante. C’est une action de fin de transaction car elle est la seule action à contenir l’instantané de l’état de l’acteur.

Voici un exemple pour bien comprendre de quoi on parle.

Type Description
0 create début de transaction
1 persist fin de transaction
2 change début
3 change
4 persist fin
5 close début
6 persist fin

Les actions qui ne sont pas de type persist permettent de modifier l’état de l’acteur. Tant qu’il n’y a pas de persist, la transaction est ouverte. L’action persist prend l’état mémoire de l’acteur et sans le modifier. Dans la base de donnée, pour chaque persist on y trouve l’état complet en tant que payload.

C’est très important de bien comprendre cette mécanique. Quand un acteur est monté dans l’application, son état en mémoire va être nourri par le contenu du dernier persist connu, le concernant. Quand on a terminé de modifier l’état d’un acteur, il faut explicitement demander la génération de l’action persist. Ainsi on ferme la transaction.

♻️ Synchroniser

Nous en savons assez désormais pour attaquer le sujet principal de cet exposé. Comment exploiter tout cela pour synchroniser les états entre les clients et le serveur ?

L’idée générale est relativement simple. Ceci dit, même si l’idée est simple, la réalisation devient complexe dès le moment où on introduit la gestion d’erreur comme par exemple, comment retomber sur ces pattes en cas de crash aux différentes étapes de la synchronisation.

Restons-en à l’idée générale où aucun crash n’est possible et où le serveur ne peut pas ne pas être joignable.

Au commencement il y a le bootstrap.

  1. Le client n’a encore jamais eu de synchronisation avec le serveur.
  2. Le client donne son identifiant de commit le plus récent qu’il a au serveur s’il en a au moins un.

Le serveur …

STOP ⛔

À peine commencé, il y a un nouvel aspect que l’on n’a pas encore abordé.

Ce sont les identifiants de commit. En effet, ils n’ont pas été abordé car ils n’ont aucune utilité quand le logiciel fonctionne seul (sans serveur de référence). Ici, il ne faut pas comparer les identifiants de commit avec ceux de Git. Dans Git, l’identifiant est calculé par somme (avec l’aide d’une fonction de hashage) de l’ensemble de l’information contenu dans le commit (en-tête et contenu). Dans notre cas, un identifiant de commit n’est rien de plus qu’un UUID v4.

Les identifiants de commit sont générés par le serveur pour chaque synchronisation. Avec ces identifiants il est possible de savoir depuis quand remonte la dernière synchronisation, afin de traiter uniquement ce qui est nouveau.

Ainsi, quand le client ne donne aucun commit ID alors il va être nécessaire de tout lui envoyer. C’est ce qui se nomme ici le bootstrap.

Continuons où on s’était arrêté :

  1. Le client n’a pas de commit ID.
  2. Le serveur lui retourne un stream de “row” SQL que le client insère directement dans la base de données.

Dans ce scénario, le client ne donne rien au serveur et la base de données locale est vide. Le stream SQL est un lot de toutes les dernières actions persist connues par le serveur. Toutes ces actions ont un ID de commit associé.

Nous pouvons compléter notre petit modèle de la base de données :

actions
- id
- type
- payload
- commit

Chaque action peut être associée à un ID de commit provenant du serveur.

Le bootstrap est terminé, l’application se débloque. Imaginons que nous modifions la description d’une demande. Voyons chaque étape (actions store) jusqu’à la fin de la synchronisation .

id type payload commit
demande 42 persist {…} abcd

On modifie :

demande 42 change {bli…} null

Encore :

demande 42 change {bla…} null

On a modifié deux fois la demande. Que se passe-t-il avec la synchronisation ?

→ Rien du tout… ici la transaction est toujours ouverte. Pour fermer la transaction, il nous faut une action persist. Et seulement à ce moment-là, il est envisageable de démarrer une synchronisation.

id type commit
demande 42 persist abcd connu par le serveur
demande 42 change null
demande 42 change null
demande 42 persist null fin de transaction

On peut imaginer plusieurs acteurs :

id type commit
tâche 84 persist efgh connu par le serveur
demande 42 persist abcd connu par le serveur
demande 42 change null
demande 42 change null
tâche 84 close null
demande 42 persist null fin de transaction 42
tâche 84 persist null fin de transaction 84

Une même base de donnée va abriter différents types d’acteurs en même temps.

Le client va démarrer une synchronisation. Il va chercher toutes les actions non-persist puis il va les envoyer au serveur.

id type commit
tâche 84 persist efgh
demande 42 persist abcd
demande 42 change null actions non-persist
demande 42 change null actions non-persist
tâche 84 close null actions non-persist
demande 42 persist null
tâche 84 persist null

Pour préparer l’envoi, le client prépare les actions avec des ID de commit à 0.

id type commit
demande 42 change 0000
demande 42 change 0000
tâche 84 close 0000

Le serveur reçoit ces actions, et les joue dans ses reducers (et non les quêtes, car ici on ne veut surtout pas d’effets de bord).

Cette étape va provoquer la création de deux actions persist (on est toujours du côté du serveur). Un identifiant de commit est généré pour ce lot.

Sur le serveur :

id type commit
demande 42 change null
demande 42 change null
tâche 84 close null
demande 42 persist 4444 états définitifs
tâche 84 persist 4444 états définitifs

Tous les persist sont alors retournés au client qui va les insérer dans sa base de données.

Les actions non-persist à 0000 sont alors mises à jour avec le commit ID.

id type commit
tâche 84 persist efgh
demande 42 persist abcd
demande 42 change 4444
demande 42 change 4444
tâche 84 close 4444
demande 42 persist null
tâche 84 persist null
demande 42 persist 4444
tâche 84 persist 4444

Voici ce que les clients obtiennent en fin de synchronisation. Les actions persist du serveur viennent surcharger celles que le client a créées localement. Voyons ce que cela donne pour un client qui n’est pas à la source des modifications :

id type commit
tâche 84 persist efgh
demande 42 persist abcd
demande 42 persist 4444
tâche 84 persist 4444

La synchronisation d’actions non-persists mène à une fusion côté serveur. C’est un peu comme envoyer des deltas, mais ici on envoie des actions qui décrivent ce qui doit changer. Pour terminer, le serveur génère le nouvel instantané persist à envoyer à tous les clients.

Quand le serveur termine le traitement d’un lot d’actions pour un acteur, et avant de générer l’action persist, il va appeler la quête “beforePersistOnServer” si celle-ci existe pour l’acteur en question. Cette quête permet d’effectuer des effets de bords spécifiques au serveur, et (ou) d’y effectuer des modifications de l’état si nécessaire. C’est ainsi la seule quête qui peut être exécutée lors d’une synchronisation.

⛑️ Faire une bonne action

L’écriture des reducers demande de faire un petit peu attention à ce qui peut se passer lors d’une fusion sur le serveur. Faire une bonne action c’est faire des actions qui sont granulaires. Prennons l’exemples d’une liste, telle que la liste des tâches d’une demande.

Imaginons une implémentation incorrecte :

/* quête */
liste_des_tâches = état.liste_des_tâches;
liste_des_tâches.ajoute(la_nouvelle_tâche);
appel_le_reducer(liste_des_tâches);

/* Reducer argument: liste_des_tâches */
état.liste_des_tâches = liste_des_tâches;

Dans cet exemple nous avons une quête qui va récupérer la liste des tâches depuis l’état de son acteur, ajouter une tâche dans la liste et donner la nouvelle liste au reducer. Cette implémentation pose deux problèmes.

  1. Depuis une quête, on ne devrait jamais chercher à modifier un état, mais uniquement le lire
  2. L’appel du reducer prend la liste entière en paramètre

La conséquence de cette implémentation est que l’action qui va être jouée sur le serveur, va complètement remplacer la liste précédente par une nouvelle qui a été calculée depuis le client. Il n’y a donc pas de fusion pour cette liste, car le dernier à synchroniser impose sa vérité.

Voici une implémentation correcte :

/* quête */
appel_le_reducer(la_nouvelle_tâche);

/* Reducer argument: la_nouvelle_tâche */
état.liste_des_tâches.ajoute(la_nouvelle_tâche);

Dans ce second exemple, tout ce fusionne correctement car pour chaque synchronisation il y a un ajout effectué dans le reducer et pas depuis la quête. Le serveur va alors effectivement fusionner l’ajout des nouvelles tâches provenant de tous les clients.