CouchDB
 sql >> Base de données >  >> NoSQL >> CouchDB

Synchronisation de style CouchDB et résolution de conflits sur Postgres avec Hasura

Nous avons parlé d'abord hors ligne avec Hasura et RxDB (essentiellement Postgres et PouchDB en dessous).

Cet article continue d'approfondir le sujet. Il s'agit d'une discussion et d'un guide pour la mise en œuvre de la résolution des conflits de style CouchDB avec Postgres (base de données principale centrale) et PouchDB (application frontale utilisateur base de données).

Voici de quoi nous allons parler :

  • Qu'est-ce que la résolution de conflits ?
  • Mon application a-t-elle besoin d'une résolution de conflit ?
  • Résolution des conflits avec PouchDB expliquée
  • Faciliter la réplication et la gestion des conflits dans pouchdb (frontend) et Postgres (backend) avec RxDB et Hasura
    • Configurer Hasura
    • Configuration côté client
    • Mettre en œuvre la résolution des conflits
    • Utiliser les vues
    • Utiliser des déclencheurs postgres
  • Stratégies personnalisées de résolution de conflits avec Hasura
    • Résolution personnalisée des conflits sur le serveur
    • Résolution personnalisée des conflits sur le client
  • Conclusion

Qu'est-ce que la résolution de conflits ?

Prenons un tableau Trello comme exemple. Supposons que vous ayez modifié le destinataire sur une carte Trello alors que vous étiez hors ligne. Pendant ce temps, votre collègue édite la description de la même carte. Lorsque vous reviendrez en ligne, vous voudrez voir les deux changements. Supposons maintenant que vous ayez tous les deux modifié la description en même temps, que devrait-il se passer dans ce cas ? Une option consiste simplement à prendre la dernière écriture - c'est-à-dire à remplacer la modification précédente par la nouvelle. Une autre consiste à informer l'utilisateur et à le laisser mettre à jour la carte avec un champ fusionné (comme git !).

Cet aspect consistant à prendre plusieurs modifications simultanées (qui peuvent être conflictuelles) et à les fusionner en une seule modification est appelé résolution de conflit.

Quels types d'applications pouvez-vous créer une fois que vous disposez de bonnes capacités de réplication et de résolution de conflits ?

L'infrastructure de réplication et de résolution des conflits est difficile à intégrer dans le frontend et le backend d'une application. Mais une fois qu'il est configuré, certains cas d'utilisation importants deviennent viables ! En fait, pour certains types d'applications, la réplication (et donc la résolution des conflits) est essentielle à la fonctionnalité de l'application !

  1. En temps réel :les modifications apportées par les utilisateurs sur différents appareils sont synchronisées les unes avec les autres
  2. Collaboratif :différents utilisateurs travaillent simultanément sur les mêmes données
  3. Offline-first :le même utilisateur peut travailler avec ses données même lorsque l'application n'est pas connectée à la base de données centrale

Exemples :Trello, clients de messagerie comme Gmail, Superhuman, Google docs, Facebook, Twitter, etc.

Hasura facilite l'ajout de fonctionnalités hautes performances, sécurisées et en temps réel à votre application existante basée sur Postgres. Il n'est pas nécessaire de déployer une infrastructure backend supplémentaire pour prendre en charge ces cas d'utilisation ! Dans les prochaines sections, nous apprendrons comment vous pouvez utiliser PouchDB/RxDB sur le frontend et l'associer à Hasura pour créer des applications puissantes avec une expérience utilisateur exceptionnelle.

Résolution des conflits avec PouchDB expliquée

Gestion des versions avec PouchDB

PouchDB - que RxDB utilise en dessous - est livré avec un puissant mécanisme de gestion des versions et des conflits. Chaque document dans PouchDB est associé à un champ de version. Les champs de version sont de la forme <depth>-<object-hash> par exemple 2-c1592ce7b31cc26e91d2f2029c57e621 . Ici, profondeur indique la profondeur dans l'arborescence des révisions. Le hachage d'objet est une chaîne générée aléatoirement.

Un aperçu des révisions de PouchDB

PouchDB expose des API pour récupérer l'historique des révisions d'un document. Nous pouvons interroger l'historique des révisions de cette façon :

todos.pouch.get(todo.id, {
    revs: true
})

Cela renverra un document contenant un _revisions domaine:

{
  "id": "559da26d-ad0f-42bc-a172-1821641bf2bb",
  "_rev": "4-95162faab173d1e748952179e0db1a53",
  "_revisions": {
    "ids": [
      "95162faab173d1e748952179e0db1a53",
      "94162faab173d1e748952179e0db1a53",
      "9055e63d99db056a95b61936f0185c8c",
      "de71900ec14567088bed5914b2439896"
    ],
    "start": 4
  }
}

Ici ids contient la hiérarchie des révisions des révisions (y compris la version actuelle) et start contient le "numéro de préfixe" pour la révision actuelle. Chaque fois qu'une nouvelle révision est ajoutée start est incrémenté et un nouveau hachage est ajouté au début des ids tableau.

Lorsqu'un document est synchronisé avec un serveur distant, _revisions et _rev champs doivent être inclus. De cette façon, tous les clients ont finalement l'historique complet des versions. Cela se produit automatiquement lorsque PouchDB est configuré pour se synchroniser avec CouchDB. La demande d'extraction ci-dessus permet également cela lors de la synchronisation via GraphQL.

Notez que tous les clients n'ont pas nécessairement toutes les révisions, mais tous auront éventuellement les dernières versions et l'historique des identifiants de révision pour ces versions.

Résolution des conflits

Un conflit sera détecté si deux révisions ont le même parent ou plus simplement si deux révisions ont la même profondeur. Lorsqu'un conflit est détecté, CouchDB &PouchDB utiliseront le même algorithme pour sélectionner automatiquement un gagnant :

  1. Sélectionnez les révisions avec le champ de profondeur le plus élevé qui ne sont pas marquées comme supprimées
  2. S'il n'y a qu'un seul champ de ce type, considérez-le comme le gagnant
  3. S'il y en a plusieurs, triez les champs de révision par ordre décroissant et choisissez le premier.

Remarque concernant la suppression : PouchDB et CouchDB ne suppriment jamais les révisions ou les documents à la place, une nouvelle révision est créée avec un indicateur _deleted défini sur true. Ainsi, à l'étape 1 de l'algorithme ci-dessus, toutes les chaînes qui se terminent par une révision marquée comme supprimée sont ignorées.

Une fonctionnalité intéressante de cet algorithme est qu'aucune coordination n'est requise entre les clients ou le client et le serveur pour résoudre un conflit. Il n'y a pas non plus de marqueur supplémentaire requis pour marquer une version comme gagnante. Chaque client et le serveur choisissent indépendamment le gagnant. Mais le gagnant sera la même révision car ils utilisent le même algorithme déterministe. Même si l'un des clients a des révisions manquantes, lorsque ces révisions sont synchronisées, la même révision est choisie comme gagnante.

Mettre en œuvre des stratégies personnalisées de résolution des conflits

Mais que se passe-t-il si nous voulons une stratégie alternative de résolution des conflits ? Par exemple "fusionner par champs" - Si deux révisions en conflit ont modifié différentes clés de l'objet, nous voulons fusionner automatiquement en créant une révision avec les deux clés. La méthode recommandée pour le faire dans PouchDB est de :

  1. Créez cette nouvelle révision sur l'une des chaînes
  2. Ajouter une révision avec _deleted défini sur vrai à chacune des autres chaînes

La révision fusionnée sera désormais automatiquement la révision gagnante selon l'algorithme ci-dessus. Nous pouvons effectuer une résolution personnalisée sur le serveur ou le client. Lorsque les révisions seront synchronisées, tous les clients et le serveur verront la révision fusionnée comme la révision gagnante.

Résolution des conflits avec Hasura et RxDB

Pour mettre en œuvre la stratégie de résolution de conflit ci-dessus, nous aurons besoin que Hasura stocke également l'historique des révisions et que RxDB synchronise les révisions lors de la réplication à l'aide de GraphQL.

Configurer Hasura

Continuons avec l'exemple d'application Todo du post précédent. Nous devrons mettre à jour le schéma de la table Todos comme suit :

todo (
  id: text primary key,
  userId: text,
  text: text, <br/>
  createdAt: timestamp,
  isCompleted: boolean,
  deleted: boolean,
  updatedAt: boolean,
  _revisions: jsonb,
  _rev: text primary key,
  _parent_rev: text,
  _depth: integer,
)

Notez les champs supplémentaires :

  • _rev représente la révision de l'enregistrement.
  • _parent_rev représente la révision parent de l'enregistrement
  • _depth est la profondeur de l'enregistrement dans l'arborescence des révisions
  • _revisions contient l'historique complet des révisions de la notice.

La clé primaire de la table est (id , _rev ).

À proprement parler, nous n'avons besoin que des _revisions champ puisque les autres informations peuvent en être dérivées. Mais avoir les autres champs facilement disponibles facilite la détection et la résolution des conflits.

Configuration côté client

Nous devons définir syncRevisions à true lors de la configuration de la réplication


    async setupGraphQLReplication(auth) {
        const replicationState = this.db.todos.syncGraphQL({
            url: syncURL,
            headers: {
                'Authorization': `Bearer ${auth.idToken}`
            },
            push: {
                batchSize,
                queryBuilder: pushQueryBuilder
            },
            pull: {
                queryBuilder: pullQueryBuilder(auth.userId)
            },

            live: true,

            liveInterval: 1000 * 60 * 10,
            deletedFlag: 'deleted',
            syncRevisions: true,
        });

       ...
    }

Nous devons également ajouter un champ de texte last_pulled_rev au schéma RxDB. Ce champ est utilisé en interne par le plugin pour éviter de repousser les révisions récupérées du serveur vers le serveur.

const todoSchema = {
    ...
    'properties': {
        ...
        'last_pulled_rev': {
            'type': 'string'
        }
    },
    ...
};

Enfin, nous devons modifier les générateurs de requêtes pull &push pour synchroniser les informations relatives aux révisions

Pull Query Builder

const pullQueryBuilder = (userId) => {
    return (doc) => {
        if (!doc) {
            doc = {
                id: '',
                updatedAt: new Date(0).toUTCString()
            };
        }

        const query = `{
            todos(
                where: {
                    _or: [
                        {updatedAt: {_gt: "${doc.updatedAt}"}},
                        {
                            updatedAt: {_eq: "${doc.updatedAt}"},
                            id: {_gt: "${doc.id}"}
                        }
                    ],
                    userId: {_eq: "${userId}"} 
                },
                limit: ${batchSize},
                order_by: [{updatedAt: asc}, {id: asc}]
            ) {
                id
                text
                isCompleted
                deleted
                createdAt
                updatedAt
                userId
                _rev
                _revisions
            }
        }`;
        return {
            query,
            variables: {}
        };
    };
};

Nous récupérons maintenant les champs _rev &_revisions. Le plugin mis à jour utilisera ces champs pour créer des révisions PouchDB locales.

Générateur de requête push


const pushQueryBuilder = doc => {
    const query = `
        mutation InsertTodo($todo: [todos_insert_input!]!) {
            insert_todos(objects: $todo){
                returning {
                  id
                }
            }
       }
    `;

    const depth = doc._revisions.start;
    const parent_rev = depth == 1 ? null : `${depth - 1}-${doc._revisions.ids[1]}`

    const todo = Object.assign({}, doc, {
        _depth: depth,
        _parent_rev: parent_rev
    })

    delete todo['updatedAt']

    const variables = {
        todo: todo
    };

    return {
        query,
        variables
    };
};

Avec le plugin mis à jour, le paramètre d'entrée doc contient maintenant _rev et _revisions des champs. Nous passons à Hasura dans la requête GraphQL. Nous ajoutons des champs _depth , _parent_rev vers doc avant de le faire.

Auparavant, nous utilisions un upsert pour insérer ou mettre à jour un todo record sur Hasura. Maintenant, puisque chaque version finit par être un nouvel enregistrement, nous utilisons à la place l'ancienne mutation d'insertion.

Mettre en œuvre la résolution des conflits

Si deux clients différents effectuent maintenant des modifications conflictuelles, les deux révisions seront synchronisées et présentes dans Hasura. Les deux clients recevront également éventuellement l'autre révision. Parce que la stratégie de résolution des conflits de PouchDB est déterministe, les deux clients choisiront alors la même version que la "révision gagnante".

Comment trouver cette révision gagnante sur le serveur ? Nous devrons implémenter le même algorithme en SQL.

Implémentation de l'algorithme de résolution de conflits de CouchDB sur Postgres

Étape 1 : Rechercher les nœuds terminaux non marqués comme supprimés

Pour ce faire, nous devons ignorer toutes les versions qui ont une révision enfant et toutes les versions marquées comme supprimées :

    SELECT
        id,
        _rev,
        _depth
    FROM
        todos
    WHERE
        NOT EXISTS (
            SELECT
                id
            FROM
                todos AS t
            WHERE
                todos.id = t.id
                AND t._parent_rev = todos._rev)
            AND deleted = FALSE

Étape 2 :Trouver la chaîne avec la profondeur maximale

En supposant que nous ayons les résultats de la requête ci-dessus dans une table (ou une vue ou une clause with) appelée feuilles, nous pouvons trouver que la chaîne avec une profondeur maximale est simple :

    SELECT
        id,
        MAX(_depth) AS max_depth
    FROM
        leaves
    GROUP BY
        id

Étape 3 : Rechercher les révisions gagnantes parmi les révisions de même profondeur maximale

En supposant à nouveau que les résultats de la requête ci-dessus se trouvent dans une table (ou une vue ou une clause with) appelée max_depths, nous pouvons trouver la révision gagnante comme suit :

    SELECT
        leaves.id,
        MAX(leaves._rev) AS _rev
    FROM
        leaves
        JOIN max_depths ON leaves.id = max_depths.id
            AND leaves._depth = max_depths.max_depth
    GROUP BY
        leaves.id

Créer une vue avec des révisions gagnantes

En rassemblant les trois requêtes ci-dessus, nous pouvons créer une vue qui nous montre les révisions gagnantes comme suit :

CREATE OR REPLACE VIEW todos_current_revisions AS
WITH leaves AS (
    SELECT
        id,
        _rev,
        _depth
    FROM
        todos
    WHERE
        NOT EXISTS (
            SELECT
                id
            FROM
                todos AS t
            WHERE
                todos.id = t.id
                AND t._parent_rev = todos._rev)
            AND deleted = FALSE
),
max_depths AS (
    SELECT
        id,
        MAX(_depth) AS max_depth
    FROM
        leaves
    GROUP BY
        id
),
winning_revisions AS (
    SELECT
        leaves.id,
        MAX(leaves._rev) AS _rev
    FROM
        leaves
        JOIN max_depths ON leaves.id = max_depths.id
            AND leaves._depth = max_depths.max_depth
    GROUP BY
        (leaves.id))
SELECT
    todos.*
FROM
    todos
    JOIN winning_revisions ON todos._rev = winning_revisions._rev;

Étant donné que Hasura peut suivre les vues et permet de les interroger via GraphQL, les révisions gagnantes peuvent désormais être exposées à d'autres clients et services.

Chaque fois que vous interrogez la vue, Postgres remplacera simplement la vue par la requête dans la définition de la vue et exécutera la requête résultante. Si vous interrogez fréquemment la vue, cela peut entraîner de nombreux cycles CPU inutiles. Nous pouvons optimiser cela en utilisant des déclencheurs Postgres et en stockant les révisions gagnantes dans une table différente.

Utilisation des déclencheurs Postgres pour calculer les révisions gagnantes

Étape 1 :Créer une nouvelle table todos_current_revisions

Le schéma sera le même que celui des todos table. La clé primaire sera cependant le id colonne au lieu de (id, _rev)

Étape 2 :Créer un déclencheur Postgres

Nous pouvons écrire la requête pour le déclencheur en commençant par la requête de vue. Étant donné que la fonction de déclenchement s'exécutera pour une ligne à la fois, nous pouvons simplifier la requête :

CREATE OR REPLACE FUNCTION calculate_winning_revision ()
    RETURNS TRIGGER
    AS $BODY$
BEGIN
    INSERT INTO todos_current_revisions WITH leaves AS (
        SELECT
            id,
            _rev,
            _depth
        FROM
            todos
        WHERE
            NOT EXISTS (
                SELECT
                    id
                FROM
                    todos AS t
                WHERE
                    t.id = NEW.id
                    AND t._parent_rev = todos._rev)
                AND deleted = FALSE
                AND id = NEW.id
        ),
        max_depths AS (
            SELECT
                MAX(_depth) AS max_depth
            FROM
                leaves
        ),
        winning_revisions AS (
            SELECT
                MAX(leaves._rev) AS _rev
            FROM
                leaves
                JOIN max_depths ON leaves._depth = max_depths.max_depth
        )
        SELECT
            todos.*
        FROM
            todos
            JOIN winning_revisions ON todos._rev = winning_revisions._rev
    ON CONFLICT ON CONSTRAINT todos_winning_revisions_pkey
        DO UPDATE SET
            _rev = EXCLUDED._rev,
            _revisions = EXCLUDED._revisions,
            _parent_rev = EXCLUDED._parent_rev,
            _depth = EXCLUDED._depth,
            text = EXCLUDED.text,
            "updatedAt" = EXCLUDED."updatedAt",
            deleted = EXCLUDED.deleted,
            "userId" = EXCLUDED."userId",
            "createdAt" = EXCLUDED."createdAt",
            "isCompleted" = EXCLUDED."isCompleted";
    RETURN NEW;
END;
$BODY$
LANGUAGE plpgsql;

CREATE TRIGGER trigger_insert_todos
    AFTER INSERT ON todos
    FOR EACH ROW
    EXECUTE PROCEDURE calculate_winning_revision ()

C'est ça! Nous pouvons maintenant interroger les versions gagnantes à la fois sur le serveur et sur le client.

 Résolution personnalisée des conflits

Voyons maintenant comment implémenter la résolution personnalisée des conflits avec Hasura et RxDB.

 Résolution personnalisée des conflits côté serveur

Disons que nous voulons fusionner les todos par champs. Comment allons-nous faire cela? L'essentiel ci-dessous nous montre ceci :

Ce SQL ressemble à beaucoup, mais la seule partie qui traite de la stratégie de fusion réelle est la suivante :

CREATE OR REPLACE FUNCTION merge_revisions (item1 jsonb, item2 jsonb)
    RETURNS jsonb
    AS $$
BEGIN
    IF NOT item1 ? 'id' THEN
        RETURN item2;
    ELSE
        RETURN item1 || (item2 -> 'diff');
    END IF;
END;
$$
LANGUAGE plpgsql;

CREATE OR REPLACE AGGREGATE agg_merge_revisions (jsonb) (
    INITCOND = '{}',
    STYPE = jsonb,
    SFUNC = merge_revisions
);

Ici, nous déclarons une fonction d'agrégation Postgres personnalisée agg_merge_revisions pour fusionner des éléments. La façon dont cela fonctionne est similaire à une fonction 'reduce' :Postgres initialisera la valeur agrégée à '{}' , puis exécutez les merge_revisions fonction avec l'agrégat courant et le prochain élément à fusionner. Donc, si nous avions 3 versions en conflit à fusionner, le résultat serait :

merge_revisions(merge_revisions(merge_revisions('{}', v1), v2), v3)

Si nous voulons implémenter une autre stratégie, nous devrons changer le merge_revisions une fonction. Par exemple, si nous voulons implémenter la stratégie 'la dernière écriture gagne' :

CREATE OR REPLACE FUNCTION merge_revisions (item1 jsonb, item2 jsonb)
    RETURNS jsonb
    AS $$
BEGIN
    IF NOT (item1 ? 'id') THEN
        RETURN item2;
    ELSE
        IF (item2 -> 'updatedAt') > (item1 -> 'updatedAt') THEN
            RETURN item2
        ELSE
            RETURN item1
        END IF;
    END IF;
END;
$$
LANGUAGE plpgsql;

La requête d'insertion dans l'essentiel ci-dessus peut être exécutée dans un déclencheur de post-insertion pour fusionner automatiquement les conflits chaque fois qu'ils se produisent.

Remarque : Ci-dessus, nous avons utilisé SQL pour implémenter une résolution de conflit personnalisée. Une approche alternative consiste à écrire une action :

  1. Créez une mutation personnalisée pour gérer l'insertion au lieu de la mutation d'insertion générée automatiquement par défaut.
  2. Dans le gestionnaire d'action, créez la nouvelle révision de l'enregistrement. Nous pouvons utiliser la mutation d'insertion Hasura pour cela.
  3. Récupérer toutes les révisions de l'objet à l'aide d'une requête de liste
  4. Détectez tout conflit en parcourant l'arborescence des révisions.
  5. Réécrivez la version fusionnée.

Cette approche vous plaira si vous préférez écrire cette logique dans un langage autre que SQL. Une autre approche consiste à créer une vue SQL pour afficher les révisions en conflit et implémenter la logique restante dans le gestionnaire d'action. Cela simplifiera l'étape 4. ci-dessus puisque nous pouvons maintenant simplement interroger la vue pour détecter les conflits.

 Résolution personnalisée des conflits côté client

Il existe des scénarios où vous avez besoin de l'intervention de l'utilisateur pour pouvoir résoudre un conflit. Par exemple, si nous construisons quelque chose comme l'application Trello et que deux utilisateurs modifient la description de la même tâche, vous voudrez peut-être montrer à l'utilisateur les deux versions et les laisser créer une version fusionnée. Dans ces scénarios, nous devrons résoudre le conflit côté client.

La résolution des conflits côté client est plus simple à mettre en œuvre car PouchDB expose déjà les API pour interroger les révisions en conflit. Si nous regardons les todos Collection RxDB du post précédent, voici comment nous pouvons récupérer les versions en conflit :

todos.pouch.get(todo.id, {
    conflicts: true
})

La requête ci-dessus remplirait les révisions conflictuelles dans le _conflicts champ dans le résultat. Nous pouvons ensuite les présenter à l'utilisateur pour résolution.

Conclusion

PouchDB est livré avec une construction flexible et puissante pour la solution de gestion des versions et des conflits. Cet article nous a montré comment utiliser ces constructions avec Hasura/Postgres. Dans cet article, nous nous sommes concentrés sur cette opération en utilisant plpgsql. Nous ferons un post de suivi montrant comment faire cela avec Actions afin que vous puissiez utiliser la langue de votre choix sur le backend !

Vous avez aimé cet article ? Rejoignez-nous sur Discord pour plus de discussions sur Hasura &GraphQL !

Inscrivez-vous à notre newsletter pour savoir quand nous publions de nouveaux articles.