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í!
- V reálném čase:Změny provedené uživateli na různých zařízeních se vzájemně synchronizují
- Spolupráce:Různí uživatelé současně pracují na stejných datech
- 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 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:
- Vyberte revize s nejvyšší hloubkou pole, které nejsou označeny jako smazané
- Pokud existuje pouze 1 takové pole, považujte je za vítězné
- 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:
- Vytvořte tuto novou revizi na kterémkoli z řetězců
- 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:
- Vytvořte vlastní mutaci pro zpracování insertu namísto výchozí automaticky generované mutace insertu.
- V obslužné rutině akce vytvořte novou revizi záznamu. Můžeme k tomu použít inzertní mutaci Hasura.
- Načtěte všechny revize objektu pomocí dotazu na seznam
- Zjistit případné konflikty procházením stromu revizí.
- 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.