sql >> Database teknologi >  >> NoSQL >> CouchDB

CouchDB-stilsynkronisering og konfliktløsning på Postgres med Hasura

Vi har talt om offline-først med Hasura og RxDB (i det væsentlige Postgres og PouchDB nedenunder).

Dette indlæg fortsætter med at dykke dybere ned i emnet. Det er en diskussion og guide til implementering af CouchDB stil konfliktløsning med Postgres (central backend database) og PouchDB (frontend app bruger database).

Her er, hvad vi skal tale om:

  • Hvad er konfliktløsning?
  • Kræver min app konfliktløsning?
  • Konfliktløsning med PouchDB forklaret
  • Bringer nem replikering og konflikthåndtering til pouchdb (frontend) og Postgres (backend) med RxDB og Hasura
    • Opsætning af Hasura
    • Konfiguration på klientsiden
    • Implementering af konfliktløsning
    • Brug af visninger
    • Brug af postgres-triggere
  • Tilpassede konfliktløsningsstrategier med Hasura
    • Tilpasset konfliktløsning på serveren
    • Tilpasset konfliktløsning på klienten
  • Konklusion

Hvad er konfliktløsning?

Lad os tage et Trello-bræt som eksempel. Lad os sige, at du har ændret modtageren på et Trello-kort, mens du er offline. I mellemtiden redigerer din kollega beskrivelsen af ​​det samme kort. Når du kommer online igen, vil du gerne se begge ændringer. Antag nu, at I begge ændrede beskrivelsen på samme tid, hvad skal der ske i dette tilfælde? En mulighed er blot at tage den sidste skrivning - det vil sige at tilsidesætte den tidligere ændring med den nye. En anden er at underrette brugeren og lade dem opdatere kortet med et flettet felt (som git!).

Dette aspekt med at tage flere samtidige ændringer (som kan være modstridende) og flette dem til én ændring kaldes konfliktløsning.

Hvilken slags apps kan du bygge, når du har gode replikerings- og konfliktløsningsmuligheder?

Replikering og konfliktløsningsinfrastruktur er smertefuldt at bygge ind i frontend og backend af en applikation. Men når det først er sat op, bliver nogle vigtige use-cases levedygtige! Faktisk er replikering (og dermed konfliktløsning) for visse typer applikationer afgørende for appens funktionalitet!

  1. Realtid:Ændringer foretaget af brugerne på forskellige enheder synkroniseres med hinanden
  2. Samarbejde:Forskellige brugere arbejder samtidigt på de samme data
  3. Offline-first:Den samme bruger kan arbejde med deres data, selvom appen ikke er forbundet til den centrale database

Eksempler:Trello, e-mail-klienter som Gmail, Superhuman, Google docs, Facebook, Twitter osv.

Hasura gør det super nemt at tilføje højtydende, sikre realtidsfunktioner til din eksisterende Postgres-baserede applikation. Der er ingen grund til at implementere yderligere backend-infrastruktur for at understøtte disse use-cases! I de næste par sektioner lærer vi, hvordan du kan bruge PouchDB/RxDB på frontend og parre det med Hasura for at bygge kraftfulde apps med stor brugeroplevelse.

Konfliktløsning med PouchDB forklaret

Versionsstyring med PouchDB

PouchDB - som RxDB bruger nedenunder - kommer med en kraftfuld versions- og konflikthåndteringsmekanisme. Hvert dokument i PouchDB har et versionsfelt tilknyttet. Versionsfelter har formen <depth>-<object-hash> for eksempel 2-c1592ce7b31cc26e91d2f2029c57e621 . Her angiver dybde dybden i revisionstræet. Objekt-hash er en tilfældigt genereret streng.

Et smugkig på PouchDB-revisioner

PouchDB afslører API'er for at hente revisionshistorikken for et dokument. Vi kan forespørge revisionshistorikken på denne måde:

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

Dette vil returnere et dokument, der indeholder en _revisions Mark:

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

Her ids indeholder hierarki af revisioner af revisioner (inklusive den nuværende) og start indeholder "præfiksnummeret" for den aktuelle revision. Hver gang en ny revision tilføjes start øges, og en ny hash tilføjes til starten af ​​ids array.

Når et dokument synkroniseres til en ekstern server, _revisions og _rev felter skal medtages. På denne måde har alle klienter til sidst den komplette versionshistorik. Dette sker automatisk, når PouchDB er sat op til at synkronisere med CouchDB. Ovenstående pull-anmodning aktiverer dette også ved synkronisering via GraphQL.

Bemærk, at alle klienter ikke nødvendigvis har alle revisionerne, men alle vil i sidste ende have de seneste versioner og historikken for revisions-id'erne for disse versioner.

Konfliktløsning

En konflikt vil blive opdaget, hvis to revisioner har samme overordnede eller mere blot hvis to revisioner har samme dybde. Når en konflikt opdages, vil CouchDB &PouchDB bruge den samme algoritme til automatisk at vælge en vinder:

  1. Vælg revisioner med det højeste dybdefelt, der ikke er markeret som slettet
  2. Hvis der kun er 1 sådant felt, skal du behandle det som vinderen
  3. Hvis der er mere end 1, skal du sortere revisionsfelterne i faldende rækkefølge og vælge det første.

En bemærkning om sletning: PouchDB &CouchDB sletter aldrig revisioner eller dokumenter i stedet oprettes en ny revision med et _deleted flag sat til sand. Så i trin 1 af ovenstående algoritme ignoreres alle kæder, der ender med en revision markeret som slettet.

En god egenskab ved denne algoritme er, at der ikke kræves nogen koordinering mellem klienter eller klienten og serveren for at løse en konflikt. Der kræves heller ingen yderligere markør for at markere en version som vindende. Hver klient og serveren vælger uafhængigt vinderen. Men vinderen bliver den samme revision, fordi de bruger den samme deterministiske algoritme. Selvom en af ​​klienterne mangler nogle revisioner, bliver den samme revision i sidste ende valgt som vinderen, når disse revisioner synkroniseres.

Implementering af tilpassede konfliktløsningsstrategier

Men hvad nu hvis vi ønsker en alternativ konfliktløsningsstrategi? For eksempel "flet efter felter" - Hvis to modstridende revisioner har ændret forskellige nøgler af objektet, vil vi automatisk flette ved at oprette en revision med begge nøgler. Den anbefalede måde at gøre dette på i PouchDB er at:

  1. Opret denne nye revision på enhver af kæderne
  2. Tilføj en revision med _deleted sat til true for hver af de andre kæder

Den fusionerede revision vil nu automatisk være den vindende revision i henhold til ovenstående algoritme. Vi kan lave tilpasset opløsning enten på serveren eller klienten. Når revisionerne bliver synkroniseret, vil alle klienter og serveren se den flettede revision som den vindende revision.

Konfliktløsning med Hasura og RxDB

For at implementere ovenstående konfliktløsningsstrategi skal Hasura også gemme revisionshistorikken, og at RxDB kan synkronisere revisioner, mens de replikerer ved hjælp af GraphQL.

Opsætning af Hasura

Fortsætter med Todo-appeksemplet fra det forrige indlæg. Vi bliver nødt til at opdatere skemaet for Todos-tabellen som følger:

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

Bemærk de ekstra felter:

  • _rev repræsenterer revisionen af ​​posten.
  • _parent_rev repræsenterer den overordnede revision af posten
  • _depth er dybden af ​​posten i revisionstræet
  • _revisions indeholder den komplette historik for revisioner af posten.

Den primære nøgle til tabellen er (id , _rev ).

Strengt taget har vi kun brug for _revisions felt, da de øvrige oplysninger kan udledes af det. Men at have de andre felter let tilgængelige gør konfliktdetektion og løsning lettere.

Konfiguration på klientsiden

Vi skal indstille syncRevisions til sand under opsætning af replikering


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

       ...
    }

Vi skal også tilføje et tekstfelt last_pulled_rev til RxDB-skema. Dette felt bruges internt af plugin'et for at undgå at skubbe revisioner hentet fra serveren tilbage til serveren.

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

Endelig skal vi ændre pull &push-forespørgselsbyggerne for at synkronisere revisionsrelaterede oplysninger

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

Vi henter nu felterne _rev &_revisions. Det opgraderede plugin vil bruge disse felter til at oprette lokale PouchDB-revisioner.

Push Query Builder


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

Med det opgraderede plugin, inputparameteren doc indeholder nu _rev og _revisions felter. Vi sender videre til Hasura i GraphQL-forespørgslen. Vi tilføjer felter _depth , _parent_rev til doc før du gør det.

Tidligere brugte vi en upsert til at indsætte eller opdatere en todo rekord på Hasura. Nu da hver version ender med at blive en ny rekord, bruger vi i stedet den almindelige gamle indsættelsesmutation.

Implementering af konfliktløsning

Hvis to forskellige klienter nu foretager modstridende ændringer, vil begge revisioner blive synkroniseret og til stede i Hasura. Begge klienter vil også i sidste ende modtage den anden revision. Fordi PouchDB's konfliktløsningsstrategi er deterministisk vil begge klienter derefter vælge den samme version som den "vindende revision".

Hvordan kan vi finde denne vindende revision på serveren? Vi bliver nødt til at implementere den samme algoritme i SQL.

Implementering af CouchDB's konfliktløsningsalgoritme på Postgres

Trin 1:Find bladknuder, der ikke er markeret som slettede

For at gøre dette skal vi ignorere alle versioner, der har en underordnet revision, og alle versioner, der er markeret som slettet:

    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

Trin 2:Find kæden med den maksimale dybde

Hvis vi antager, at vi har resultaterne fra ovenstående forespørgsel i en tabel (eller visning eller en med klausul) kaldet blade, kan vi finde kæden med maksimal dybde er ligetil:

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

Trin 3:Find vindende revisioner blandt revisioner med samme maksimale dybde

Igen hvis vi antager, at resultaterne fra ovenstående forespørgsel er i en tabel (eller en visning eller en med-klausul) kaldet max_depths, kan vi finde den vindende revision som følger:

    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

Oprettelse af en visning med vindende revisioner

Ved at sammensætte ovenstående tre forespørgsler kan vi oprette en visning, der viser os de vindende revisioner som følger:

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;

Da Hasura kan spore visninger og tillader forespørgsler om dem via GraphQL, kan de vindende revisioner nu eksponeres for andre kunder og tjenester.

Hver gang du forespørger på visningen, vil Postgres blot erstatte visningen med forespørgslen i visningsdefinitionen og køre den resulterende forespørgsel. Hvis du ofte forespørger på visningen, kan det ende med at føre til en masse spildte CPU-cyklusser. Vi kan optimere dette ved at bruge Postgres-udløsere og gemme de vindende revisioner i en anden tabel.

Brug af Postgres-udløsere til at beregne vindende revisioner

Trin 1:Opret en ny tabel todos_current_revisions

Skemaet vil være det samme som for todos bord. Den primære nøgle vil dog være id kolonne i stedet for (id, _rev)

Trin 2:Opret Postgres-udløser

Vi kan skrive forespørgslen til triggeren ved at starte med view-forespørgslen. Da triggerfunktionen kører for én række ad gangen, kan vi forenkle forespørgslen:

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

Det er det! Vi kan nu forespørge på de vindende versioner både på serveren og klienten.

Tilpasset konfliktløsning

Lad os nu se på implementering af tilpasset konfliktløsning med Hasura &RxDB.

Brugerdefineret konfliktløsning på serversiden

Lad os sige, at vi ønsker at flette todos efter felter. Hvordan gør vi det? Essensen nedenfor viser os dette:

Den SQL ligner meget, men den eneste del, der omhandler den faktiske fusionsstrategi, er denne:

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

Her erklærer vi en tilpasset Postgres aggregatfunktion agg_merge_revisions at flette elementer. Måden dette fungerer på ligner en 'reducer'-funktion:Postgres vil initialisere den samlede værdi til '{}' , kør derefter merge_revisions funktion med det aktuelle aggregat og det næste element, der skal flettes. Så hvis vi havde 3 modstridende versioner, der skulle slås sammen, ville resultatet være:

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

Hvis vi ønsker at implementere en anden strategi, bliver vi nødt til at ændre merge_revisions fungere. For eksempel, hvis vi ønsker at implementere strategien 'sidste skrivning vinder':

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;

Insert-forespørgslen i ovenstående essens kan køres i en post-insert-trigger for automatisk at flette konflikter, når de opstår.

Bemærk: Ovenfor har vi brugt SQL til at implementere tilpasset konfliktløsning. En alternativ fremgangsmåde er at bruge en skrive en handling:

  1. Opret en tilpasset mutation for at håndtere indsættelsen i stedet for den automatisk genererede standardindsættelsesmutation.
  2. Opret den nye revision af posten i handlingsbehandleren. Vi kan bruge Hasura-insertmutationen til dette.
  3. Hent alle revisionerne for objektet ved hjælp af en listeforespørgsel
  4. Opdag eventuelle konflikter ved at krydse revisionstræet.
  5. Skriv den flettede version tilbage.

Denne tilgang vil appellere til dig, hvis du foretrækker at skrive denne logik på et andet sprog end SQL. En anden tilgang er at oprette en SQL-visning for at vise de modstridende revisioner og implementere den resterende logik i handlingshåndteringen. Dette vil forenkle trin 4. ovenfor, da vi nu blot kan forespørge i visningen for at opdage konflikter.

Tilpasset konfliktløsning på klientsiden

Der er scenarier, hvor du har brug for brugerindgreb for at kunne løse en konflikt. For eksempel, hvis vi byggede noget som Trello-appen, og to brugere ændrede beskrivelsen af ​​den samme opgave, vil du måske vise brugeren begge versioner og lade dem oprette en fusioneret version. I disse scenarier bliver vi nødt til at løse konflikten på klientsiden.

Konfliktløsning på klientsiden er nemmere at implementere, fordi PouchDB allerede udsætter API'er for at forespørge modstridende revisioner. Hvis vi ser på todos RxDB-samling fra det forrige indlæg, her er hvordan vi kan hente de modstridende versioner:

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

Ovenstående forespørgsel vil udfylde de modstridende revisioner i _conflicts felt i resultatet. Vi kan derefter præsentere disse for brugeren til løsning.

Konklusion

PouchDB kommer med en fleksibel og kraftfuld konstruktion til versions- og konflikthåndteringsløsning. Dette indlæg viste os, hvordan man bruger disse konstruktioner med Hasura/Postgres. I dette indlæg har vi fokuseret på at gøre dette ved hjælp af plpgsql. Vi vil lave et opfølgningsindlæg, der viser, hvordan du gør dette med Actions, så du kan bruge det sprog, du vælger i backend!

Nydt denne artikel? Slut dig til os på Discord for flere diskussioner om Hasura &GraphQL!

Tilmeld dig vores nyhedsbrev for at vide, hvornår vi udgiver nye artikler.


  1. Forespørgsel med strengdatoformat i mongodb

  2. Eksporter gyldig json fra mongodb samling

  3. 'session' er udefineret, når du bruger express / redis til session store

  4. Omdøb et felt i en MongoDB-samling