sql >> Databasteknik >  >> NoSQL >> CouchDB

CouchDB-stilsynkronisering och konfliktlösning på Postgres med Hasura

Vi har pratat om offline-first med Hasura och RxDB (i huvudsak Postgres och PouchDB under).

Det här inlägget fortsätter att dyka djupare in i ämnet. Det är en diskussion och guide för att implementera konfliktlösning i CouchDB-stil med Postgres (central backend-databas) och PouchDB (frontend app användare databas).

Här är vad vi ska prata om:

  • Vad är konfliktlösning?
  • Behöver min app konfliktlösning?
  • Konfliktlösning med PouchDB förklaras
  • Ge enkel replikering och konflikthantering till pouchdb (frontend) och Postgres (backend) med RxDB och Hasura
    • Konfigurera Hasura
    • Konfiguration på klientsidan
    • Implementera konfliktlösning
    • Använda vyer
    • Använda postgres-utlösare
  • Anpassade strategier för konfliktlösning med Hasura
    • Anpassad konfliktlösning på servern
    • Anpassad konfliktlösning på klienten
  • Slutsats

Vad är konfliktlösning?

Låt oss ta en Trello-bräda som exempel. Låt oss säga att du har ändrat mottagaren på ett Trello-kort när du är offline. Under tiden redigerar din kollega beskrivningen av samma kort. När du kommer tillbaka online skulle du vilja se båda ändringarna. Anta nu att ni båda ändrade beskrivningen samtidigt, vad ska hända i det här fallet? Ett alternativ är att helt enkelt ta den sista skrivningen - det vill säga åsidosätta den tidigare ändringen med den nya. En annan är att meddela användaren och låta dem uppdatera kortet med ett sammanfogat fält (som git!).

Denna aspekt av att ta flera samtidiga ändringar (som kan vara motstridiga) och slå samman dem till en förändring kallas konfliktlösning.

Vilken typ av appar kan du bygga när du har bra replikerings- och konfliktlösningsmöjligheter?

Infrastruktur för replikering och konfliktlösning är smärtsam att bygga in i frontend och backend av en applikation. Men när det väl är konfigurerat blir några viktiga användningsfall genomförbara! Faktum är att för vissa typer av applikationer är replikering (och därmed konfliktlösning) avgörande för appens funktionalitet!

  1. Realtid:Ändringar som görs av användarna på olika enheter synkroniseras med varandra
  2. Samarbete:Olika användare arbetar samtidigt på samma data
  3. Offline-first:Samma användare kan arbeta med sin data även när appen inte är ansluten till den centrala databasen

Exempel:Trello, e-postklienter som Gmail, Superhuman, Google docs, Facebook, Twitter etc.

Hasura gör det superenkelt att lägga till högpresterande, säkra realtidsfunktioner till din befintliga Postgres-baserade applikation. Det finns inget behov av att distribuera ytterligare backend-infrastruktur för att stödja dessa användningsfall! I de kommande avsnitten kommer vi att lära oss hur du kan använda PouchDB/RxDB på frontend och para ihop det med Hasura för att bygga kraftfulla appar med stor användarupplevelse.

Konfliktlösning med PouchDB förklaras

Versionshantering med PouchDB

PouchDB - som RxDB använder under - kommer med en kraftfull versionshantering och konflikthanteringsmekanism. Varje dokument i PouchDB har ett versionsfält kopplat till sig. Versionsfälten har formen <depth>-<object-hash> till exempel 2-c1592ce7b31cc26e91d2f2029c57e621 . Här anger djup djupet i revisionsträdet. Objekthash är en slumpmässigt genererad sträng.

En tjuvtitt på PouchDB-revisioner

PouchDB exponerar API:er för att hämta revisionshistoriken för ett dokument. Vi kan fråga revisionshistoriken på detta sätt:

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

Detta kommer att returnera ett dokument som innehåller en _revisions fält:

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

Här ids innehåller hierarki av revisioner av revisioner (inklusive den nuvarande) och start innehåller "prefixnummer" för den aktuella versionen. Varje gång en ny version läggs till start ökas och en ny hash läggs till i början av ids array.

När ett dokument synkroniseras till en fjärrserver, _revisions och _rev fält måste inkluderas. På så sätt har alla klienter så småningom den fullständiga versionshistoriken. Detta händer automatiskt när PouchDB är inställt för att synkronisera med CouchDB. Ovanstående pull-begäran möjliggör detta även vid synkronisering via GraphQL.

Observera att alla klienter inte nödvändigtvis har alla versioner, men alla kommer så småningom att ha de senaste versionerna och historiken för revisions-ID:n för dessa versioner.

Konfliktlösning

En konflikt kommer att upptäckas om två revisioner har samma förälder eller mer helt enkelt om två revisioner har samma djup. När en konflikt upptäcks kommer CouchDB &PouchDB att använda samma algoritm för att automatiskt välja en vinnare:

  1. Välj versioner med det största djupfältet som inte är markerade som borttagna
  2. Om det bara finns ett sådant fält, behandla det som vinnaren
  3. Om det finns fler än 1, sortera revisionsfälten i fallande ordning och välj det första.

En notering om radering: PouchDB &CouchDB tar aldrig bort revisioner eller dokument istället skapas en ny revision med flaggan _deleted inställd på true. Så i steg 1 av ovanstående algoritm ignoreras alla kedjor som slutar med en revision markerad som borttagen.

En trevlig egenskap hos denna algoritm är att det inte krävs någon samordning mellan klienter eller klienten och servern för att lösa en konflikt. Det krävs ingen extra markör för att markera en version som vinnande heller. Varje klient och servern utser vinnaren oberoende av varandra. Men vinnaren blir samma revision eftersom de använder samma deterministiska algoritm. Även om en av klienterna har några revisioner som saknas, när dessa revisioner synkroniseras, utses samma revision som vinnare.

Implementera anpassade konfliktlösningsstrategier

Men vad händer om vi vill ha en alternativ konfliktlösningsstrategi? Till exempel "sammanfoga efter fält" - Om två motstridiga versioner har modifierat olika nycklar av objektet vill vi automatiskt sammanfoga genom att skapa en revision med båda nycklarna. Det rekommenderade sättet att göra detta i PouchDB är att:

  1. Skapa den här nya versionen på någon av kedjorna
  2. Lägg till en revision med _deleted inställd på sann för var och en av de andra kedjorna

Den sammanslagna revisionen blir nu automatiskt den vinnande revisionen enligt ovanstående algoritm. Vi kan göra anpassad upplösning antingen på servern eller klienten. När revisionerna synkroniseras kommer alla klienter och servern att se den sammanslagna revisionen som den vinnande revisionen.

Konfliktlösning med Hasura och RxDB

För att implementera ovanstående konfliktlösningsstrategi kommer vi att behöva Hasura för att också lagra revisionshistoriken och för RxDB att synkronisera revisioner samtidigt som de replikerar med GraphQL.

Konfigurera Hasura

Fortsätter med Todo-appexemplet från föregående inlägg. Vi måste uppdatera schemat för Todos-tabellen enligt följande:

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

Notera de ytterligare fälten:

  • _rev representerar revideringen av posten.
  • _parent_rev representerar den överordnade revisionen av posten
  • _depth är postens djup i revisionsträdet
  • _revisions innehåller den fullständiga historiken för revisioner av posten.

Den primära nyckeln för tabellen är (ids , _rev ).

Strängt taget behöver vi bara _revisions fältet eftersom den andra informationen kan härledas från det. Men att ha de andra fälten lätt tillgängliga gör det lättare att upptäcka och lösa konflikter.

Konfiguration på klientsidan

Vi måste ställa in syncRevisions till sant när du ställer in 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 måste också lägga till ett textfält last_pulled_rev till RxDB-schema. Det här fältet används internt av plugin-programmet för att undvika att skicka revisioner som hämtats från servern tillbaka till servern.

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

Slutligen måste vi ändra pull &push-frågebyggarna för att synkronisera revisionsrelaterad information

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 hämtar nu fälten _rev &_revisions. Det uppgraderade insticksprogrammet kommer att använda dessa fält för att skapa lokala 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 den uppgraderade plugin, indataparametern doc innehåller nu _rev och _revisions fält. Vi skickar vidare till Hasura i GraphQL-frågan. Vi lägger till fält _depth , _parent_rev till doc innan du gör det.

Tidigare använde vi en upsert för att infoga eller uppdatera en todo rekord på Hasura. Nu eftersom varje version blir en ny post använder vi den vanliga gamla insättningsmutationen istället.

Implementera konfliktlösning

Om två olika klienter nu gör motstridiga ändringar kommer båda revisionerna att synkroniseras och finnas i Hasura. Båda klienterna kommer också så småningom att få den andra revisionen. Eftersom PouchDB:s konfliktlösningsstrategi är deterministisk kommer båda klienterna att välja samma version som den "vinnande revisionen".

Hur kan vi hitta denna vinnande version på servern? Vi måste implementera samma algoritm i SQL.

Implementering av CouchDB:s konfliktlösningsalgoritm på Postgres

Steg 1:Hitta lövnoder som inte är markerade som borttagna

För att göra detta måste vi ignorera alla versioner som har en underordnad version och alla versioner som är markerade som raderade:

    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

Steg 2:Hitta kedjan med maximalt djup

Om vi ​​antar att vi har resultaten från ovanstående fråga i en tabell (eller vy eller en med-sats) som kallas blad kan vi hitta kedjan med maximalt djup är rakt fram:

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

Steg 3:Hitta vinnande revisioner bland revisioner med lika maxdjup

Återigen om vi antar att resultaten från ovanstående fråga finns i en tabell (eller en vy eller en med-sats) som kallas max_depths kan vi hitta den vinnande revisionen enligt följande:

    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

Skapa en vy med vinnande revisioner

Genom att sammanställa ovanstående tre frågor kan vi skapa en vy som visar oss de vinnande revisionerna enligt följande:

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;

Eftersom Hasura kan spåra vyer och gör det möjligt att fråga dem via GraphQL, kan de vinnande revisionerna nu exponeras för andra kunder och tjänster.

När du frågar vyn kommer Postgres helt enkelt att ersätta vyn med frågan i vyns definition och köra den resulterande frågan. Om du frågar i vyn ofta kan detta leda till många bortkastade CPU-cykler. Vi kan optimera detta genom att använda Postgres-utlösare och lagra de vinnande revisionerna i en annan tabell.

Att använda Postgres-utlösare för att beräkna vinnande revisioner

Steg 1:Skapa en ny tabell todos_current_revisions

Schemat kommer att vara detsamma som för todos tabell. Den primära nyckeln kommer dock att vara ids kolumn istället för (id, _rev)

Steg 2:Skapa Postgres-utlösare

Vi kan skriva frågan för utlösaren genom att börja med vyfrågan. Eftersom triggerfunktionen kommer att köras för en rad i taget, kan vi förenkla frågan:

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 är allt! Vi kan nu fråga de vinnande versionerna både på servern och klienten.

Anpassad konfliktlösning

Låt oss nu titta på att implementera anpassad konfliktlösning med Hasura &RxDB.

Anpassad konfliktlösning på serversidan

Låt oss säga att vi vill slå samman uppgifterna efter fält. Hur gör vi det här? Sammanfattningen nedan visar oss detta:

Den SQL ser ut som mycket men den enda delen som handlar om den faktiska sammanslagningsstrategin är denna:

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

Här deklarerar vi en anpassad Postgres aggregatfunktion agg_merge_revisions för att slå samman element. Sättet det här fungerar på liknar en "reducera"-funktion:Postgres initierar det sammanlagda värdet till '{}' , kör sedan merge_revisions funktion med det aktuella aggregatet och nästa element som ska slås samman. Så om vi hade tre motstridiga versioner som skulle slås samman skulle resultatet bli:

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

Om vi ​​vill implementera en annan strategi måste vi ändra merge_revisions fungera. Om vi ​​till exempel vill implementera strategin "sista skriv vinner":

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;

Infogningsfrågan i ovanstående sammanfattning kan köras i en utlösare efter infogning för att automatiskt slå samman konflikter när de uppstår.

Obs! Ovan har vi använt SQL för att implementera anpassad konfliktlösning. Ett alternativt tillvägagångssätt är att använda en skriv en åtgärd:

  1. Skapa en anpassad mutation för att hantera infogningen istället för den automatiskt genererade standardmutationen för infogningen.
  2. Skapa den nya revisionen av posten i åtgärdshanteraren. Vi kan använda Hasura-insertmutationen för detta.
  3. Hämta alla revisioner för objektet med hjälp av en listfråga
  4. Detektera eventuella konflikter genom att gå igenom revisionsträdet.
  5. Skriv tillbaka den sammanslagna versionen.

Detta tillvägagångssätt kommer att tilltala dig om du föredrar att skriva den här logiken på ett annat språk än SQL. Ett annat tillvägagångssätt är att skapa en SQL-vy för att visa de motstridiga revisionerna och implementera den återstående logiken i åtgärdshanteraren. Detta kommer att förenkla steg 4 ovan eftersom vi nu helt enkelt kan fråga vyn för att upptäcka konflikter.

Anpassad konfliktlösning på klientsidan

Det finns scenarier där du behöver ingripande från användaren för att kunna lösa en konflikt. Till exempel, om vi byggde något som Trello-appen och två användare ändrade beskrivningen av samma uppgift, kanske du vill visa användaren båda versionerna och låta dem skapa en sammanslagen version. I dessa scenarier kommer vi att behöva lösa konflikten på klientsidan.

Konfliktlösning på klientsidan är enklare att implementera eftersom PouchDB redan exponerar API:er för att söka motstridiga revisioner. Om vi ​​tittar på todos RxDB-samling från föregående inlägg, här är hur vi kan hämta de motstridiga versionerna:

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

Ovanstående fråga skulle fylla i de motstridiga versionerna i _conflicts fältet i resultatet. Vi kan sedan presentera dessa för användaren för upplösning.

Slutsats

PouchDB kommer med en flexibel och kraftfull konstruktion för versionshantering och konflikthanteringslösning. Det här inlägget visade oss hur man använder dessa konstruktioner med Hasura/Postgres. I det här inlägget har vi fokuserat på att göra detta med plpgsql. Vi kommer att göra ett uppföljningsinlägg som visar hur du gör detta med Actions så att du kan använda det språk du väljer i backend!

Gillade den här artikeln? Följ med oss ​​på Discord för fler diskussioner om Hasura &GraphQL!

Anmäl dig till vårt nyhetsbrev för att veta när vi publicerar nya artiklar.


  1. MongoDB Databas Automation Basics Använda Chef

  2. Ansluter Heroku App till Atlas MongoDB molntjänst

  3. Anslut till redis från en annan container i docker

  4. Kraftigt nedgång i prestanda med MongoDB Change Streams