sql >> Databáze >  >> NoSQL >> CouchDB

Synchronizace stylu CouchDB a řešení konfliktů na Postgres s Hasura

Mluvili jsme o offline – nejprve s Hasura a RxDB (v podstatě Postgres a PouchDB pod nimi).

Tento příspěvek se nadále ponoří hlouběji do tématu. Jedná se o diskuzi a průvodce implementací řešení konfliktů stylu CouchDB s Postgres (centrální backendová databáze) a PouchDB (uživatel frontendové aplikace databáze).

Zde je to, o čem budeme mluvit:

  • Co je řešení konfliktů?
  • Potřebuje moje aplikace řešení konfliktů?
  • Vysvětlení řešení konfliktů s PouchDB
  • Přinášíme snadnou replikaci a správu konfliktů do pouchdb (frontend) a Postgres (backend) pomocí RxDB a Hasura
    • Nastavení Hasura
    • Nastavení na straně klienta
    • Provádění řešení konfliktů
    • Použití zobrazení
    • Použití postgresových spouštěčů
  • Vlastní strategie řešení konfliktů s Hasura
    • Vlastní řešení konfliktů na serveru
    • Vlastní řešení konfliktů na klientovi
  • Závěr

Co je řešení konfliktů?

Vezměme si jako příklad desku Trello. Řekněme, že jste v režimu offline změnili příjemce na kartě Trello. Mezitím váš kolega upraví popis stejné karty. Až se vrátíte online, budete chtít vidět obě změny. Nyní předpokládejme, že jste oba změnili popis současně, co by se mělo stát v tomto případě? Jednou z možností je jednoduše vzít poslední zápis – to znamená přepsat dřívější změnu novým. Dalším je upozornit uživatele a nechat ho aktualizovat kartu pomocí sloučeného pole (jako git!).

Tento aspekt provedení více současných změn (které mohou být konfliktní) a jejich sloučení do jedné změny se nazývá řešení konfliktů.

Jaký druh aplikací můžete vytvářet, když máte dobré možnosti replikace a řešení konfliktů?

Infrastruktura replikace a řešení konfliktů je obtížné zabudovat do frontendu a backendu aplikace. Ale jakmile je to nastaveno, některé důležité případy použití se stanou životaschopnými! U určitých druhů aplikací je replikace (a tedy řešení konfliktů) pro funkčnost aplikace zásadní!

  1. V reálném čase:Změny provedené uživateli na různých zařízeních se vzájemně synchronizují
  2. Spolupráce:Různí uživatelé současně pracují na stejných datech
  3. Offline-first:Stejný uživatel může pracovat se svými daty, i když aplikace není připojena k centrální databázi

Příklady:Trello, E-mailové klienty jako Gmail, Superhuman, Dokumenty Google, Facebook, Twitter atd.

Hasura velmi usnadňuje přidávání vysoce výkonných, bezpečných funkcí v reálném čase do vaší stávající aplikace založené na Postgres. Pro podporu těchto případů použití není potřeba nasazovat další backendovou infrastrukturu! V několika následujících sekcích se naučíme, jak můžete použít PouchDB/RxDB na frontendu a spárovat jej s Hasurou, abyste mohli vytvářet výkonné aplikace se skvělým uživatelským zážitkem.

Vysvětlení řešení konfliktů s PouchDB

Správa verzí pomocí PouchDB

PouchDB – který RxDB používá vespod – přichází s výkonným mechanismem pro správu verzí a konfliktů. Každý dokument v PouchDB má přidružené pole verze. Pole verze mají tvar - například 2-c1592ce7b31cc26e91d2f2029c57e621 . Hloubka zde udává hloubku ve stromu revizí. Objekt hash je náhodně generovaný řetězec.

Malý náhled do revizí PouchDB

PouchDB zpřístupňuje rozhraní API pro načtení historie revizí dokumentu. Můžeme se dotazovat na historii revizí tímto způsobem:

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

Tím se vrátí dokument obsahující _revisions pole:

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

Zde id obsahuje hierarchii revizí revizí (včetně aktuální) a start obsahuje "číslo prefixu" pro aktuální revizi. Pokaždé, když je přidána nová revize, start se zvýší a na začátek id se přidá nový hash pole.

Když je dokument synchronizován se vzdáleným serverem, _revisions a _rev pole je třeba zahrnout. Tímto způsobem mají všichni klienti nakonec kompletní historii verzí. K tomu dojde automaticky, když je PouchDB nastaveno na synchronizaci s CouchDB. Výše uvedený požadavek na stažení to umožňuje i při synchronizaci přes GraphQL.

Všimněte si, že všichni klienti nemusí mít nutně všechny revize, ale všichni budou mít nakonec nejnovější verze a historii ID revizí pro tyto verze.

Řešení konfliktů

Konflikt bude detekován, pokud mají dvě revize stejného rodiče nebo jednodušeji, pokud kterékoli dvě revize mají stejnou hloubku. Když je zjištěn konflikt, CouchDB a PouchDB použijí stejný algoritmus k automatickému výběru vítěze:

  1. Vyberte revize s nejvyšší hloubkou pole, které nejsou označeny jako smazané
  2. Pokud existuje pouze 1 takové pole, považujte je za vítězné
  3. Pokud je jich více než 1, seřaďte pole revize v sestupném pořadí a vyberte první.

Poznámka o smazání: PouchDB &CouchDB nikdy neodstraňují revize nebo dokumenty, místo toho se vytvoří nová revize s příznakem _deleted nastaveným na hodnotu true. Takže v kroku 1 výše uvedeného algoritmu jsou všechny řetězce, které končí revizí označenou jako odstraněná, ignorovány.

Jednou z pěkných vlastností tohoto algoritmu je to, že k vyřešení konfliktu není nutná žádná koordinace mezi klienty nebo klientem a serverem. K označení verze jako vítězné také není potřeba žádná další značka. Každý klient a server nezávisle vybírají vítěze. Ale vítězem bude stejná revize, protože používají stejný deterministický algoritmus. I když jednomu z klientů chybí nějaké revize, nakonec když jsou tyto revize synchronizovány, bude vybrána stejná revize jako vítězná.

Implementace vlastních strategií řešení konfliktů

Ale co když chceme alternativní strategii řešení konfliktů? Například "sloučit podle polí" - Pokud dvě konfliktní revize změnily různé klíče objektu, chceme automaticky sloučit vytvořením revize s oběma klíči. Doporučený způsob, jak to udělat v PouchDB, je:

  1. Vytvořte tuto novou revizi na kterémkoli z řetězců
  2. Přidat revizi s _deleted nastaveným na true ke každému z ostatních řetězců

Sloučená revize bude nyní automaticky vítěznou revizí podle výše uvedeného algoritmu. Můžeme provést vlastní rozlišení buď na serveru nebo na klientovi. Když se revize synchronizují, všichni klienti a server uvidí sloučenou revizi jako vítěznou revizi.

Řešení konfliktů s Hasura a RxDB

K implementaci výše uvedené strategie řešení konfliktů budeme potřebovat, aby Hasura také ukládala historii revizí a aby RxDB synchronizoval revize při replikaci pomocí GraphQL.

Nastavení Hasura

Pokračujeme příkladem aplikace Todo z předchozího příspěvku. Budeme muset aktualizovat schéma pro tabulku Todos následovně:

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

Všimněte si dalších polí:

  • _rev představuje revizi záznamu.
  • _parent_rev představuje nadřazenou revizi záznamu
  • _depth je hloubka záznamu ve stromu revizí
  • _revisions obsahuje kompletní historii revizí záznamu.

Primární klíč pro tabulku je (id , _rev ).

Přesně řečeno, potřebujeme pouze _revisions pole, protože z něj lze odvodit ostatní informace. Ale mít pohotově dostupná ostatní pole usnadňuje detekci a řešení konfliktů.

Nastavení na straně klienta

Musíme nastavit syncRevisions na hodnotu true při nastavování replikace


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

       ...
    }

Potřebujeme také přidat textové pole last_pulled_rev do schématu RxDB. Toto pole je interně používáno zásuvným modulem, aby se zabránilo odesílání revizí načtených ze serveru zpět na server.

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

Nakonec musíme změnit tvůrce dotazů pull &push pro synchronizaci informací souvisejících s revizí

Vytažení nástroje pro tvorbu dotazů

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

Nyní načteme pole _rev &_revisions. Upgradovaný plugin použije tato pole k vytvoření místních revizí PouchDB.

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

S upgradovaným pluginem vstupní parametr doc nyní obsahuje _rev a _revisions pole. V dotazu GraphQL předáme Hasurovi. Přidáme pole _depth , _parent_rev do doc než tak učiníte.

Dříve jsme používali upsert k vložení nebo aktualizaci todo záznam na Hasura. Nyní, protože každá verze končí jako nový záznam, používáme místo toho obyčejnou starou inzertní mutaci.

Implementace řešení konfliktů

Pokud nyní dva různí klienti provedou konfliktní změny, budou obě revize synchronizovány a přítomny v Hasura. Oba klienti také nakonec obdrží druhou revizi. Protože strategie řešení konfliktů PouchDB je deterministická, klienti si pak vyberou stejnou verzi jako "vítěznou revizi".

Jak najdeme tuto vítěznou revizi na serveru? Budeme muset implementovat stejný algoritmus v SQL.

Implementace algoritmu řešení konfliktů CouchDB na Postgres

Krok 1:Nalezení listových uzlů, které nejsou označeny jako smazané

K tomu musíme ignorovat všechny verze, které mají podřízenou revizi, a všechny verze, které jsou označeny jako smazané:

    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

Krok 2:Nalezení řetězu s maximální hloubkou

Za předpokladu, že máme výsledky z výše uvedeného dotazu v tabulce (nebo pohledu nebo s klauzulí) nazvané listy, můžeme najít řetězec s maximální hloubkou přímo vpřed:

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

Krok 3:Nalezení vítězných revizí mezi revizemi se stejnou maximální hloubkou

Opět za předpokladu, že výsledky z výše uvedeného dotazu jsou v tabulce (nebo pohledu nebo klauzuli with) nazvané max_depths, můžeme najít vítěznou revizi takto:

    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

Vytvoření pohledu s vítěznými revizemi

Spojením výše uvedených tří dotazů můžeme vytvořit pohled, který nám ukazuje vítězné revize následovně:

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;

Vzhledem k tomu, že Hasura může sledovat zobrazení a umožňuje je dotazovat prostřednictvím GraphQL, vítězné revize mohou být nyní vystaveny dalším klientům a službám.

Kdykoli zadáte dotaz na pohled, Postgres jednoduše nahradí pohled dotazem v definici pohledu a spustí výsledný dotaz. Pokud se budete dotazovat na pohled často, může to vést ke spoustě promarněných cyklů CPU. Můžeme to optimalizovat použitím spouštěčů Postgres a uložením vítězných revizí do jiné tabulky.

Použití spouštěčů Postgres k výpočtu vítězných revizí

Krok 1:Vytvořte novou tabulku todos_current_revisions

Schéma bude stejné jako u todos stůl. Primárním klíčem však bude id namísto (id, _rev)

Krok 2:Vytvořte spouštěč Postgres

Dotaz na spouštěč můžeme napsat tak, že začneme dotazem na zobrazení. Vzhledem k tomu, že spouštěcí funkce poběží po jednom řádku, můžeme dotaz zjednodušit:

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

A je to! Nyní se můžeme dotazovat na vítězné verze jak na serveru, tak na klientovi.

Vlastní řešení konfliktů

Nyní se podívejme na implementaci vlastního řešení konfliktů pomocí Hasura &RxDB.

Vlastní řešení konfliktů na straně serveru

Řekněme, že chceme sloučit úkoly podle polí. Jak to uděláme? Níže uvedená podstata nám to ukazuje:

To SQL vypadá hodně, ale jediná část, která se zabývá skutečnou strategií sloučení, je toto:

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

Zde deklarujeme vlastní agregační funkci Postgres agg_merge_revisions ke sloučení prvků. Způsob, jakým to funguje, je podobný funkci 'reduce':Postgres inicializuje agregovanou hodnotu na '{}' a poté spusťte merge_revisions funkce s aktuálním agregátem a dalším prvkem, který má být sloučen. Pokud bychom tedy měli sloučit 3 konfliktní verze, výsledek by byl:

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

Pokud chceme implementovat jinou strategii, budeme muset změnit merge_revisions funkce. Pokud například chceme implementovat strategii „poslední zápis vyhrává“:

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;

Vkládací dotaz ve výše uvedené podstatě lze spustit ve spouštěči po vložení pro automatické sloučení konfliktů, kdykoli nastanou.

Poznámka: Výše jsme použili SQL k implementaci vlastního řešení konfliktů. Alternativním přístupem je použití zápisu Action:

  1. Vytvořte vlastní mutaci pro zpracování insertu namísto výchozí automaticky generované mutace insertu.
  2. V obslužné rutině akce vytvořte novou revizi záznamu. Můžeme k tomu použít inzertní mutaci Hasura.
  3. Načtěte všechny revize objektu pomocí dotazu na seznam
  4. Zjistit případné konflikty procházením stromu revizí.
  5. Zapište sloučenou verzi zpět.

Tento přístup vás osloví, pokud dáváte přednost psaní této logiky v jiném jazyce než SQL. Dalším přístupem je vytvoření pohledu SQL pro zobrazení konfliktních revizí a implementaci zbývající logiky v obslužné rutině akce. To zjednoduší krok 4. výše, protože nyní můžeme jednoduše dotazovat pohled na zjištění konfliktů.

Vlastní řešení konfliktů na straně klienta

Existují scénáře, kdy k vyřešení konfliktu potřebujete zásah uživatele. Pokud bychom například budovali něco jako aplikaci Trello a dva uživatelé upravili popis stejného úkolu, možná budete chtít uživateli ukázat obě verze a nechat ho vytvořit sloučenou verzi. V těchto scénářích budeme muset vyřešit konflikt na straně klienta.

Řešení konfliktů na straně klienta je jednodušší na implementaci, protože PouchDB již vystavuje API dotazům na konfliktní revize. Pokud se podíváme na todos Kolekce RxDB z předchozího příspěvku, zde je návod, jak můžeme načíst konfliktní verze:

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

Výše uvedený dotaz by naplnil konfliktní revize v _conflicts pole ve výsledku. Ty pak můžeme předložit uživateli k vyřešení.

Závěr

PouchDB přichází s flexibilní a výkonnou konstrukcí pro správu verzí a řešení konfliktů. Tento příspěvek nám ukázal, jak používat tyto konstrukty s Hasura/Postgres. V tomto příspěvku jsme se zaměřili na to pomocí plpgsql. Připravíme následný příspěvek ukazující, jak to udělat pomocí Akce, abyste mohli na backendu používat jazyk podle svého výběru!

Líbil se vám tento článek? Připojte se k nám na Discord a diskutovat o Hasura &GraphQL!

Přihlaste se k odběru našeho newsletteru, abyste věděli, kdy publikujeme nové články.


  1. Spusťte skript prostředí mongodb pomocí ovladače C#

  2. Deserializace ID objektu Mongo DB pomocí serializátoru JSON

  3. Jaká jsou pravidla pluralizace Mongoose (Nodejs)?

  4. Jak mohu spustit příkaz mongodump programově z node.js?