« 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).
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.
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 (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 ? ⚠️
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 :
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.
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.
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.
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 :
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.
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.
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é :
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.
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.
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.