Gestion des conflits

Imaginons que vous soyez assis à une terrasse de café et que vous travailliez sur votre nouveau livre. J. Chris vient vers vous et vous parle de son nouveau téléphone ; et son nouveau numéro de téléphone. Il vous le donne et vous modifiez immédiatement votre carnet d’adresses sur votre portable.

Votre carnet d’adresses utilise CouchDB, donc, une fois à la maison, vous n’avez qu’à synchroniser la base avec votre ordinateur de bureau et le tour est joué : vous avez le nouveau numéro de J. Chris partout. Génial, n’est-ce pas ? En outre, CouchDB dispose d’un mécanisme de réplication continue, ce qui permet de maintenir tout un parc d’ordinateurs cohérent dès que la connexion réseau est disponible.

Maintenant, changeons un peu le scénario. J. Chris n’avait pas prévu de vous trouver au café, aussi vous a-t-il envoyé un courriel. À ce moment précis, vous n’utilisiez pas le WiFi puisque vous vouliez vous concentrer sur votre livre ; vous lisez donc son message une fois de retour chez vous. Hélas, c’était une journée harassante, ce qui vous a fait oublier que vous aviez déjà mis à jour le numéro sur votre portable. Aussi, quand vous prenez connaissance du message, vous modifiez immédiatement le numéro dans le carnet d’adresses de votre ordinateur de bureau. Ajoutez ceci : vous vous étiez trompé en saisissant le numéro dans votre ordinateur portable. Ce dernier a donc un mauvais numéro.

Vous vous retrouvez alors avec un document dans chaque base de données et de part et d’autre des données différentes. Cette situation est un conflit. Les conflits sont supposés se produire dans les systèmes distribués ; ils sont un état « naturel » de vos données. Comment le mécanisme de réplication de CouchDB gère-t-il ce cas ?

Lorsque vous répliquez deux bases CouchDB et que vous avez des modifications conflictuelles, CouchDB s’en rend compte et marque les documents en conflit par l’attribut spécial "_conflicts":true. Ensuite, CouchDB détermine quelle version deviendra la dernière révision (souvenez-vous que les documents sont versionnés) : ce sera la version « gagnante » tandis que l’autre sera la version « perdante » et aura un numéro de version antérieure.

CouchDB ne tente pas de fusionner les révisions conflictuelles. Votre application décide de quelle manière les conflits doivent être résolus. Le choix de la version gagnante est arbitraire. Par exemple, dans le cas du numéro de téléphone, il n’existe aucun moyen de déterminer quel est le bon numéro, donc quelle est la bonne révision. Ce n’est pas propre à CouchDB : aucun autre logiciel ne saurait le faire (le gestionnaire de contacts de votre téléphone ne vous a-t-il jamais demandé quel contact il devait importer, et à partir de quelle source ?).

Le mécanisme de réplication garantit que les modifications conflictuelles sont détectées et que toutes les instances de CouchDB feront le même choix pour déterminer le vainqueur du perdant, sans avoir à dialoguer entre elles. En effet, c’est un algorithme déterministe qui prend la décision. Ainsi, après la réplication, toutes les instances possèdent les mêmes données ; on dit alors qu’elles se trouvent dans un état cohérent. De cette manière, vous pouvez demander un document à n’importe quelle instance, vous obtiendrez la même réponse.

Toutefois, que CouchDB ait ou non choisi la version dont votre application a besoin, vous devez résoudre le conflit, tout comme vous le feriez dans un système de gestion des versions tel que Subversion. Pour ce faire, créez une nouvelle version du document en prenant ce qui vous plaît de la première révision et le reste de la seconde, puis sauvegardez-la. Voilà, vous avez créé la dernière révision. C’est fait, il n’y a plus qu’à répliquer pour la retrouver partout. Bien entendu, cette nouvelle version pourrait bien être en conflit avec un autre, auquel cas vous devez vous en occuper aussi, mais, au bout du compte, vous retrouverez un état cohérent et sans conflit.

Le demi-cerveau

Ce scénario est intéressant parce que nous y avons apporté la solution pour la BBC et que cette solution est désormais en production. L’infrastructure est la suivante : pour garantir que le site web de l’entreprise est disponible 24h/24 et 7j/7, en considérant l’éventualité de la perte d’un centre de données, il est hébergé à plusieurs endroits. La perte d’un centre de données est certes rare, mais elle peut être provoquée par un « simple » incident réseau, auquel cas le centre de données est toujours en vie, mais demeure injoignable.

Le scénario dit du demi-cerveau consiste à avoir deux centres de données (par souci de simplicité, nous nous limiterons à deux) opérationnels pour les utilisateurs finaux, mais qui ne parviennent plus à communiquer entre eux (puisque le lien réseau entre les deux centres n’est pas le même que ceux qui desservent les clients).

La connexion réseau entre les deux centres de données sert à les synchroniser pour que l’un des deux puisse prendre le relais au cas où l’autre défaille. Si ce lien réseau s’effondre, vous vous retrouvez avec deux moitiés du système qui agissent de manière autonome : deux demi-cerveaux.

Tant que tous les utilisateurs finaux peuvent accéder à leurs données, ce scénario n’est pas inquiétant. C’est quand vous recouvrez le lien réseau entre les deux centres et que vous tentez de les synchroniser que les choses se corsent. Alors, la résolution arbitraire des conflits que propose CouchDB par défaut est susceptible de provoquer des effets indésirables du point de vue de l’utilisateur. Les données pourraient revenir à un état antérieur et donner l’impression que la modification n’a pas été prise en compte, qu’elle n’a pas été sauvegardée, alors qu’elle l’a été.

Exemple de résolution de conflits

Examinons pas à pas un exemple quant à la manière dont les conflits apparaissent et la manière dont on peut les résoudre. La figure 1, Exemple de résolution des conflits, étape 1 illustre l’infrastructure initiale : nous avons deux bases de données CouchDB et nous répliquons A vers B. Pour simplifier les choses, nous considérons que la synchronisation est déclenchée à la demande et non continue, tout comme nous ne répliquons pas de B vers A. Tous les autres scénarios de réplications peuvent être ramenés à ce schéma, ce qui nous permet d’expliquer tout ce qui est nécessaire.

Figure 1. Exemple de résolution des conflits, étape 1

Nous commençons par créer un document dans la base A (figure 2, Exemple de résolution des conflits, étape 1). Notez le recours à une image pour identifier une version précise d’un document. Puisque nous n’utilisons pas la réplication permanente, la base B n’est pas informée de la création du document pour l’instant.

Figure 2. Exemple de résolution des conflits, étape 2

Maintenant, déclenchons la réplication de A vers B (figure 3, Exemple de résolution des conflits, étape 3). Notre document est copié sur la base B. Plus précisément, la dernière révision de notre document est copiée.

Figure 3. Exemple de résolution des conflits, étape 3

Maintenant, nous modifions ce document sur la base B (figure 4, Exemple de résolution des conflits, étape 2). Nous modifions quelques valeurs et, lorsque nous les soumettons, CouchDB émet une nouvelle version. Notez qu’à cette version correspond une nouvelle image. Le nœud A n’est pas informé de ce changement.

Figure 4. Exemple de résolution des conflits, étape 4

Maintenant, nous modifions également notre document dans la base A en altérant d’autres valeurs. (figure 5, Exemple de résolution des conflits, étape ). Voyez-vous la nouvelle image correspondant à cette autre version ? Cela signifie simplement que deux versions différentes du même document se trouvent dans chaque base de données.

Figure 5. Exemple de résolution des conflits, étape 5

Maintenant, nous déclenchons la réplication de A vers B comme tout à l’heure (figure 6, Exemple de résolution des conflits, étape 6). Par ailleurs, que les deux bases soient sur le même ou sur différents serveurs ne fait aucune différence.

Figure 6. Exemple de résolution des conflits, étape 6

Lors de la réplication, CouchDB détecte qu’il existe deux versions différentes du même document et génère un conflit (figure 7,Exemple de résolution des conflits, étape 7). Un conflit de document signifie qu’il existe à présent deux dernières versions de celui-ci.

Figure 7. Exemple de résolution des conflits, étape 7

Enfin, nous indiquons à CouchDB quelle version nous souhaitons voir être la plus récente en résolvant le conflit (figure 8, Exemple de résolution des conflits, étape 8). Désormais, les deux bases ont les mêmes données.

Figure 8. Exemple de résolution des conflits, étape 8

D’autres issues sont possibles. On peut choisir l’autre version et répliquer cette décision vers la base A, ou encore créer une nouvelle version dans la base B qui reprend certains éléments de A (on procède à une fusion, en anglais « merge ») et répliquer ces données vers A.

Accommoder les conflits

Après ces belles images qui expliquent le scénario, mettons les mains dans le cambouis et examinons les appels à l’API qui en découlent. Nous poursuivons ici le chapitre 4, Les fondamentaux de l’API et utilisons curl en ligne de commande pour forger les requêtes.

Tout d’abord, nous créons deux bases de données que nous répliquerons. Celles-ci se situent sur la même instance de CouchDB, mais elles pourraient être sur différents serveurs : CouchDB n’y accorde pas d’importance. De plus, pour nous épargner de longues lignes, nous créons une variable contenant l’URL de base de l’instance à laquelle nous voulons parler. Ensuite, nous créons deux bases, db et db-replica :

HOST="http://127.0.0.1:5984"

> curl -X PUT $HOST/db
{"ok":true}

> curl -X PUT $HOST/db-replica
{"ok":true}

L’étape suivante consiste à créer un document simple {"count":1} dans db et déclenchons la réplication vers db-replica :

> curl -X PUT $HOST/db/foo -d '{"count":1}'
{"ok":true,"id":"foo","rev":"1-74620ecf527d29daaab9c2b465fbce66"}

> curl -X POST $HOST/_replicate -d '{"source":"db","target":"http://127.0.0.1:5984/db-replica"}'
{"ok":true,...,"docs_written":1,"doc_write_failures":0}]}

Nous éludons une partie du résultat de la requête de réplication (référez-vous au chapitre 16, Réplication pour plus de détails). Si vous observez "docs_written":1 et "doc_write_failures":0, c’est que notre document est arrivé à db-replica. Nous pouvons alors passer le compteur à deux ({"count":2}) dans db-replica. Notez au passage que nous devons désormais inclure l’attribut _rev.

> curl -X PUT $HOST/db-replica/foo -d '{"count":2,"_rev":"1-74620ecf527d29daaab9c2b465fbce66"}'
{"ok":true,"id":"foo","rev":"2-de0ea16f8621cbac506d23a0fbbde08a"}

Ensuite, nous générons le conflit ! Nous modifions le document sur db en {"count":3}. Notre document se retrouve logiquement en conflit, mais CouchDB ne le sait pas avant que nous répliquions :

> curl -X PUT $HOST/db/foo -d '{"count":3,"_rev":"1-74620ecf527d29daaab9c2b465fbce66"}'
{"ok":true,"id":"foo","rev":"2-7c971bb974251ae8541b8fe045964219"}

> curl -X POST $HOST/_replicate -d '{"source":"db","target":"http://127.0.0.1:5984/db-replica"}'
{"ok":true,..."docs_written":1,"doc_write_failures":0}]}

Pour observer le conflit, nous créons une vue simple dans db-replica. La fonction de subdivision (map en anglais) est la suivante :

function(doc) {
  if(doc._conflicts) {
    emit(doc._conflicts, null);
  }
}

Quand nous parcourons cette vue, nous obtenons :

{"total_rows":1,"offset":0,"rows":[
{"id":"foo","key":["2-7c971bb974251ae8541b8fe045964219"],"value":null}
]}

La clé key correspond à l’attribut doc._conflicts de notre document situé dans db-replica. Il s’agit d’une liste de toutes les versions conflictuelles. Nous observons que la version soumise à db ({"count":3}) est en conflit. L’algorithme décidant quelle version l’emporte a choisi notre première modification ({"count":2}). Pour nous en assurer, nous récupérons le document à partir de db-replica :

> curl -X GET $HOST/db-replica/foo
{"_id":"foo","_rev":"2-de0ea16f8621cbac506d23a0fbbde08a","count":2}

Pour résoudre le conflit, nous devons choisir quelle version doit être conservée.

De quelle manière CouchDB décide-t-il quelle version utiliser ?

CouchDB garantit que chaque instance qui est confrontée au même conflit choisit les mêmes versions gagnantes et perdantes. Ceci est le résultat de l’application d’un algorithme déterministe qui choisit le vainqueur. L’application ne doit pas de baser sur l’implantation de l’algorithme et doit toujours résoudre les conflits. Cependant, nous allons tout de même vous expliquer comment il fonctionne.

Chaque version inclut la liste des versions précédentes. La version qui dispose de la liste la plus longue gagne. Si les listes sont les mêmes, les attributs _rev sont comparés par le tri ASCII et le plus élevé l’emporte. Ainsi, dans notre exemple, 2-de0ea16f8621cbac506d23a0fbbde08a l’emporte sur 2-7c971bb974251ae8541b8fe045964219.

L’avantage de cet algorithme est qu’il ne nécessite aucun dialogue entre les nœuds CouchDB pour prendre une décision. Nous avons déjà expliqué que le réseau est faillible et s’en affranchir pour la résolution des conflits rend CouchDB très robuste.

Disons que nous voulons conserver la valeur la plus grande. Cela implique que nous ne sommes pas d’accord avec le choix qu’a fait CouchDB. Pour ce faire, nous modifions le document avec la valeur que nous souhaitons, puis supprimons la version qui ne nous satisfait pas :

> curl -X DELETE $HOST/db-replica/foo?rev=2-de0ea16f8621cbac506d23a0fbbde08a
{"ok":true,"id":"foo","rev":"3-bfe83a296b0445c4d526ef35ef62ac14"}

> curl -X PUT $HOST/db-replica/foo -d '{"count":3,"_rev":"2-7c971bb974251ae8541b8fe045964219"}'
{"ok":true,"id":"foo","rev":"3-5d0319b075a21b095719bc561def7122"}

CouchDB génère une nouvelle version qui reflète notre choix. Notez que le 3- n’a pas été incrémenté cette fois-ci. Nous n’avons pas créé une nouvelle version du document ; nous avons supprimé une version conflictuelle. Afin de nous assurer que tout s’est bien passé, nous vérifions que notre modification a été prise en compte dans le document.

> curl -X GET $HOST/db-replica/foo
{"_id":"foo","_rev":"3-5d0319b075a21b095719bc561def7122","count":3}

Nous vérifions aussi que notre document n’est plus en conflit en consultant notre vue des conflits et en constatant qu’elle est vide :

{"total_rows":0,"offset":0,"rows":[
]}

Enfin, nous répliquons de db-replica vers db en interchangeant source et target (destination) dans notre requête _replicate :

> curl -X POST $HOST/_replicate -d '{"target":"db","source":"http://127.0.0.1:5984/db-replica"}'

Nous constatons que notre version est arrivée dans db :

> curl -X GET $HOST/db/foo
{"_id":"foo","_rev":"3-5d0319b075a21b095719bc561def7122","count":3}

Et voilà, c’est fini.

Numéros de version déterministes

Examinons ce numéro de version (revision ID en anglais) : 3-5d0319b075a21b095719bc561def7122. Certaines parties semblent familières. La première est un entier suivi d’un tiret (3-). Cet entier est incrémenté à chaque nouvelle version du document. Différentes instances incrémentent ce nombre de manière autonome. Lors de la réplication, CouchDB découvre qu’il existe deux versions différentes (comme dans notre exemple précédent) en comparant la seconde partie du numéro.

La seconde partie est une empreinte MD5 des attributs suivant : le corps JSON, les pièces jointes et le drapeau _deleted. Cela permet à CouchDB d’accélérer les opérations de réplication dans le cas où vous avez modifié de la même manière le document sur plusieurs instances. Les anciennes versions (0.9 et antérieures) généraient des nombres aléatoires et si vous apportiez la même modification sur deux instances, vous aviez deux numéros différents, ce qui générait un conflit non souhaitable. CouchDB 0.10 et ses versions supérieures recourent à ces numéros de version déterministes.

Pour illustrer ceci, créons deux documents, a et b, avec le même contenu :

> curl -X PUT $HOST/db/a -d '{"a":1}'
{"ok":true,"id":"a","rev":"1-23202479633c2b380f79507a776743d5"}

> curl -X PUT $HOST/db/b -d '{"a":1}'
{"ok":true,"id":"b","rev":"1-23202479633c2b380f79507a776743d5"}

Les deux identifiants de version sont les mêmes, conséquence de l’algorithme déterministe utilisé par CouchDB.

Conclusion

Ce chapitre conclut notre exploration du système de gestion des conflits. Vous devriez maintenant être capable de concevoir des environnements distribués qui gèrent les conflits de manière adéquate.