sql >> Database >  >> NoSQL >> CouchDB

CouchDB-stijlsynchronisatie en conflictoplossing op Postgres met Hasura

We hebben het gehad over offline-eerst met Hasura en RxDB (hoofdzakelijk Postgres en PouchDB eronder).

Dit bericht gaat dieper in op het onderwerp. Het is een discussie en gids voor het implementeren van conflictoplossing in CouchDB-stijl met Postgres (centrale backend-database) en PouchDB (frontend-app gebruiker database).

Hier gaan we het over hebben:

  • Wat is conflictoplossing?
  • Heeft mijn app conflictoplossing nodig?
  • Conflictoplossing met PouchDB uitgelegd
  • Eenvoudige replicatie en conflictbeheer naar pouchdb (frontend) en Postgres (backend) met RxDB en Hasura
    • Hasura instellen
    • Instellingen aan de clientzijde
    • Conflictoplossing implementeren
    • Weergaven gebruiken
    • Postgres-triggers gebruiken
  • Aangepaste strategieën voor conflictoplossing met Hasura
    • Aangepaste conflictoplossing op de server
    • Aangepaste conflictoplossing op de klant
  • Conclusie

Wat is conflictoplossing?

Laten we als voorbeeld een Trello-bord nemen. Stel dat je de toegewezen persoon op een Trello-kaart hebt gewijzigd terwijl je offline was. Ondertussen bewerkt je collega de omschrijving van dezelfde kaart. Wanneer u weer online komt, wilt u beide wijzigingen zien. Stel nu dat jullie allebei de beschrijving tegelijkertijd hebben gewijzigd, wat zou er in dit geval moeten gebeuren? Een optie is om gewoon de laatste keer te schrijven - dat is de eerdere wijziging overschrijven met de nieuwe. Een andere is om de gebruiker op de hoogte te stellen en hem de kaart te laten updaten met een samengevoegd veld (zoals git!).

Dit aspect van het nemen van meerdere gelijktijdige wijzigingen (die tegenstrijdig kunnen zijn) en deze samenvoegen tot één wijziging, wordt conflictoplossing genoemd.

Wat voor soort apps kun je bouwen als je eenmaal goede mogelijkheden voor replicatie en conflictoplossing hebt?

Infrastructuur voor replicatie en conflictoplossing is lastig in te bouwen in de frontend en backend van een applicatie. Maar als het eenmaal is ingesteld, worden enkele belangrijke use-cases levensvatbaar! In feite is voor bepaalde soorten toepassingen replicatie (en dus conflictoplossing) van cruciaal belang voor de functionaliteit van de app!

  1. Realtime:wijzigingen die door de gebruikers op verschillende apparaten zijn aangebracht, worden met elkaar gesynchroniseerd
  2. Samenwerken:verschillende gebruikers werken tegelijkertijd aan dezelfde gegevens
  3. Offline-first:dezelfde gebruiker kan met zijn gegevens werken, zelfs als de app niet is verbonden met de centrale database

Voorbeelden:Trello, e-mailclients zoals Gmail, Superhuman, Google docs, Facebook, Twitter enz.

Hasura maakt het super eenvoudig om krachtige, veilige, realtime mogelijkheden toe te voegen aan uw bestaande op Postgres gebaseerde applicatie. Het is niet nodig om extra backend-infrastructuur te implementeren om deze use-cases te ondersteunen! In de volgende paragrafen leren we hoe je PouchDB/RxDB aan de frontend kunt gebruiken en kunt koppelen met Hasura om krachtige apps te bouwen met een geweldige gebruikerservaring.

Conflictoplossing met PouchDB uitgelegd

Versiebeheer met PouchDB

PouchDB - dat RxDB daaronder gebruikt - wordt geleverd met een krachtig mechanisme voor versiebeheer en conflictbeheer. Aan elk document in PouchDB is een versieveld gekoppeld. Versievelden hebben de vorm <depth>-<object-hash> bijvoorbeeld 2-c1592ce7b31cc26e91d2f2029c57e621 . Hier geeft diepte de diepte aan in de revisieboom. Object-hash is een willekeurig gegenereerde string.

Een voorproefje van PouchDB-revisies

PouchDB stelt API's bloot om de revisiegeschiedenis van een document op te halen. We kunnen de revisiegeschiedenis als volgt opvragen:

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

Hiermee wordt een document geretourneerd met een _revisions veld:

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

Hier ids bevat hiërarchie van revisies van revisies (inclusief de huidige) en start bevat het "prefixnummer" voor de huidige revisie. Elke keer dat er een nieuwe revisie wordt toegevoegd start wordt verhoogd en er wordt een nieuwe hash toegevoegd aan het begin van de ids array.

Wanneer een document wordt gesynchroniseerd met een externe server, _revisions en _rev velden moeten worden opgenomen. Zo hebben alle clients uiteindelijk de volledige versiegeschiedenis. Dit gebeurt automatisch wanneer PouchDB is ingesteld om te synchroniseren met CouchDB. Het bovenstaande pull-verzoek maakt dit ook mogelijk bij het synchroniseren via GraphQL.

Houd er rekening mee dat niet alle clients noodzakelijkerwijs alle revisies hebben, maar dat ze uiteindelijk allemaal de nieuwste versies en de geschiedenis van de revisie-ID's voor deze versies zullen hebben.

Conflictoplossing

Er wordt een conflict gedetecteerd als twee revisies hetzelfde bovenliggende item hebben of eenvoudiger als twee revisies dezelfde diepte hebben. Wanneer een conflict wordt gedetecteerd, gebruiken CouchDB &PouchDB hetzelfde algoritme om automatisch een winnaar te kiezen:

  1. Selecteer revisies met het hoogste diepteveld die niet zijn gemarkeerd als verwijderd
  2. Als er maar 1 zo'n veld is, behandel het dan als de winnaar
  3. Als er meer dan 1 zijn, sorteert u de revisievelden in aflopende volgorde en kiest u de eerste.

Een opmerking over verwijdering: PouchDB &CouchDB verwijderen nooit revisies of documenten, maar er wordt een nieuwe revisie gemaakt met een _deleted-vlag ingesteld op true. Dus in stap 1 van het bovenstaande algoritme worden alle ketens die eindigen op een revisie die als verwijderd is gemarkeerd, genegeerd.

Een leuke eigenschap van dit algoritme is dat er geen coördinatie nodig is tussen clients of de client en de server om een ​​conflict op te lossen. Er is ook geen extra marker nodig om een ​​versie als winnend te markeren. Elke client en de server kiezen onafhankelijk de winnaar. Maar de winnaar zal dezelfde revisie zijn omdat ze hetzelfde deterministische algoritme gebruiken. Zelfs als een van de klanten enkele revisies mist, wordt uiteindelijk, wanneer die revisies worden gesynchroniseerd, dezelfde revisie als de winnaar gekozen.

Aangepaste strategieën voor conflictoplossing implementeren

Maar wat als we een alternatieve strategie voor conflictoplossing willen? Bijvoorbeeld "samenvoegen op velden" - Als twee conflicterende revisies verschillende sleutels van het object hebben gewijzigd, willen we automatisch samenvoegen door een revisie te maken met beide sleutels. De aanbevolen manier om dit in PouchDB te doen is:

  1. Maak deze nieuwe revisie op een van de ketens
  2. Voeg een revisie toe met _deleted ingesteld op true voor elk van de andere ketens

De samengevoegde revisie zal nu automatisch de winnende revisie zijn volgens het bovenstaande algoritme. We kunnen aangepaste resolutie doen op de server of op de client. Wanneer de revisies worden gesynchroniseerd, zien alle clients en de server de samengevoegde revisie als de winnende revisie.

Conflictoplossing met Hasura en RxDB

Om de bovenstaande strategie voor conflictoplossing te implementeren, hebben we Hasura nodig om ook de revisiegeschiedenis op te slaan en voor RxDB om revisies te synchroniseren tijdens het repliceren met GraphQL.

Hasura instellen

Doorgaan met het voorbeeld van de Todo-app uit het vorige bericht. We zullen het schema voor de Todos-tabel als volgt moeten bijwerken:

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,
)

Let op de extra velden:

  • _rev vertegenwoordigt de revisie van het record.
  • _parent_rev vertegenwoordigt de bovenliggende revisie van het record
  • _depth is de diepte van het record in de revisieboom
  • _revisions bevat de volledige geschiedenis van revisies van het record.

De primaire sleutel voor de tabel is (id , _rev ).

Strikt genomen hebben we alleen de _revisions . nodig veld, aangezien de andere informatie daaruit kan worden afgeleid. Maar als de andere velden direct beschikbaar zijn, wordt conflictdetectie en -oplossing eenvoudiger.

Instelling aan clientzijde

We moeten syncRevisions instellen naar true tijdens het instellen van replicatie


    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,
        });

       ...
    }

We moeten ook een tekstveld toevoegen last_pulled_rev naar RxDB-schema. Dit veld wordt intern door de plug-in gebruikt om te voorkomen dat revisies die van de server zijn opgehaald, terug naar de server worden gepusht.

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

Ten slotte moeten we de pull &push-querybuilders wijzigen om revisiegerelateerde informatie te synchroniseren

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: {}
        };
    };
};

We halen nu de velden _rev &_revisions op. De geüpgradede plug-in zal deze velden gebruiken om lokale PouchDB-revisies te maken.

Push Querysamensteller


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
    };
};

Met de geüpgradede plug-in, de invoerparameter doc bevat nu _rev en _revisions velden. We geven door aan Hasura in de GraphQL-query. We voegen velden toe _depth , _parent_rev naar doc voordat u dit doet.

Eerder gebruikten we een upsert om een ​​todo in te voegen of bij te werken opnemen op Hasura. Omdat elke versie een nieuw record wordt, gebruiken we in plaats daarvan de gewone oude insert-mutatie.

Conflictoplossing implementeren

Als twee verschillende clients nu tegenstrijdige wijzigingen aanbrengen, worden beide revisies gesynchroniseerd en aanwezig in Hasura. Beide klanten zullen uiteindelijk ook de andere revisie ontvangen. Omdat de conflictoplossingsstrategie van PouchDB deterministisch is, zullen beide klanten dezelfde versie kiezen als de "winnende revisie".

Hoe kunnen we deze winnende revisie op de server vinden? We zullen hetzelfde algoritme in SQL moeten implementeren.

CouchDB's algoritme voor conflictoplossing op Postgres implementeren

Stap 1:bladknooppunten zoeken die niet zijn gemarkeerd als verwijderd

Om dit te doen, moeten we alle versies met een onderliggende revisie en alle versies die als verwijderd zijn gemarkeerd, negeren:

    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

Stap 2:Vind de ketting met de maximale diepte

Ervan uitgaande dat we de resultaten van de bovenstaande zoekopdracht in een tabel (of weergave of een met-clausule) hebben met de naam bladeren, kunnen we de keten met maximale diepte vinden is rechttoe rechtaan:

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

Stap 3:Winnende revisies vinden tussen revisies met gelijke maximale diepte

Nogmaals, ervan uitgaande dat de resultaten van de bovenstaande zoekopdracht zich in een tabel (of een weergave of een met-clausule) bevinden met de naam max_depths, kunnen we de winnende revisie als volgt vinden:

    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

Een weergave maken met winnende revisies

Door de bovenstaande drie zoekopdrachten samen te stellen, kunnen we een weergave maken die ons de winnende revisies als volgt laat zien:

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;

Omdat Hasura weergaven kan volgen en ze via GraphQL kan opvragen, kunnen de winnende revisies nu worden blootgesteld aan andere klanten en services.

Telkens wanneer u de weergave opvraagt, zal Postgres de weergave eenvoudigweg vervangen door de query in de weergavedefinitie en de resulterende query uitvoeren. Als u de weergave vaak opvraagt, kan dit leiden tot veel verspilde CPU-cycli. We kunnen dit optimaliseren door Postgres-triggers te gebruiken en de winnende revisies in een andere tabel op te slaan.

Postgres-triggers gebruiken om winnende revisies te berekenen

Stap 1:Maak een nieuwe tabel todos_current_revisions

Het schema zal hetzelfde zijn als dat van de todos tafel. De primaire sleutel is echter de id kolom in plaats van (id, _rev)

Stap 2:Postgres-trigger maken

We kunnen de query voor de trigger schrijven door te beginnen met de view-query. Omdat de triggerfunctie voor één rij tegelijk wordt uitgevoerd, kunnen we de query vereenvoudigen:

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 ()

Dat is het! We kunnen nu de winnende versies opvragen, zowel op de server als op de client.

Aangepaste conflictoplossing

Laten we nu eens kijken naar het implementeren van aangepaste conflictoplossing met Hasura &RxDB.

Aangepaste conflictoplossing aan de serverzijde

Laten we zeggen dat we de taken per velden willen samenvoegen. Hoe gaan we dit doen? De essentie hieronder laat ons dit zien:

Die SQL lijkt veel, maar het enige deel dat zich bezighoudt met de daadwerkelijke samenvoegstrategie is dit:

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
);

Hier declareren we een aangepaste Postgres-aggregatiefunctie agg_merge_revisions elementen samen te voegen. De manier waarop dit werkt is vergelijkbaar met een 'reduce'-functie:Postgres initialiseert de geaggregeerde waarde naar '{}' en voer vervolgens de merge_revisions . uit functie met het huidige aggregaat en het volgende element dat moet worden samengevoegd. Dus als we 3 conflicterende versies hadden die moesten worden samengevoegd, zou het resultaat zijn:

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

Als we een andere strategie willen implementeren, moeten we de merge_revisions . wijzigen functie. Als we bijvoorbeeld de 'last write wins'-strategie willen implementeren:

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;

De invoegquery in de bovenstaande kern kan worden uitgevoerd in een trigger na het invoegen om conflicten automatisch samen te voegen wanneer ze zich voordoen.

Opmerking: Hierboven hebben we SQL gebruikt om aangepaste conflictoplossing te implementeren. Een alternatieve benadering is om een ​​actie te schrijven:

  1. Maak een aangepaste mutatie om de invoeging af te handelen in plaats van de standaard automatisch gegenereerde invoegmutatie.
  2. Maak in de actie-handler de nieuwe revisie van het record. Hiervoor kunnen we de Hasura-insertmutatie gebruiken.
  3. Haal alle revisies voor het object op met behulp van een lijstquery
  4. Detecteer eventuele conflicten door de revisieboom te doorlopen.
  5. Schrijf de samengevoegde versie terug.

Deze aanpak zal u aanspreken als u deze logica liever in een andere taal dan SQL schrijft. Een andere benadering is om een ​​SQL-weergave te maken om de conflicterende revisies te tonen en de resterende logica in de actie-handler te implementeren. Dit vereenvoudigt stap 4. hierboven, omdat we nu eenvoudig de weergave kunnen opvragen voor het detecteren van conflicten.

Aangepaste conflictoplossing aan de clientzijde

Er zijn scenario's waarin u tussenkomst van de gebruiker nodig hebt om een ​​conflict op te lossen. Als we bijvoorbeeld iets als de Trello-app aan het bouwen waren en twee gebruikers de beschrijving van dezelfde taak hebben gewijzigd, wil je de gebruiker misschien beide versies laten zien en ze een samengevoegde versie laten maken. In deze scenario's moeten we het conflict aan de kant van de klant oplossen.

Het oplossen van conflicten aan de clientzijde is eenvoudiger te implementeren omdat PouchDB API's al blootstelt aan conflicterende revisies. Als we kijken naar de todos RxDB-verzameling uit het vorige bericht, hier is hoe we de conflicterende versies kunnen ophalen:

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

De bovenstaande query zou de conflicterende revisies in de _conflicts . vullen veld in het resultaat. We kunnen deze dan aan de gebruiker voorleggen voor een oplossing.

Conclusie

PouchDB wordt geleverd met een flexibele en krachtige constructie voor versiebeheer en conflictbeheer. Dit bericht liet ons zien hoe we deze constructies kunnen gebruiken met Hasura/Postgres. In dit bericht hebben we ons erop gericht dit te doen met behulp van plpgsql. We zullen een vervolgbericht plaatsen waarin we laten zien hoe u dit kunt doen met Actions, zodat u de taal van uw keuze op de backend kunt gebruiken!

Genoten van dit artikel? Sluit je aan bij Discord voor meer discussies over Hasura &GraphQL!

Meld u aan voor onze nieuwsbrief om te weten wanneer we nieuwe artikelen publiceren.


  1. Realtime gegevensstreaming met MongoDB-wijzigingsstreams

  2. MongoDB-server niet toegankelijk in lokaal netwerk ondanks binding ip

  3. Ondersteunt MongoDB drijvende-kommatypen?

  4. Nieuwe functies voor back-upbeheer en beveiliging voor MySQL en PostgreSQL:ClusterControl Release 1.6.2