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!
- Realtid:Ändringar som görs av användarna på olika enheter synkroniseras med varandra
- Samarbete:Olika användare arbetar samtidigt på samma data
- 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:
- Välj versioner med det största djupfältet som inte är markerade som borttagna
- Om det bara finns ett sådant fält, behandla det som vinnaren
- 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:
- Skapa den här nya versionen på någon av kedjorna
- 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:
- Skapa en anpassad mutation för att hantera infogningen istället för den automatiskt genererade standardmutationen för infogningen.
- Skapa den nya revisionen av posten i åtgärdshanteraren. Vi kan använda Hasura-insertmutationen för detta.
- Hämta alla revisioner för objektet med hjälp av en listfråga
- Detektera eventuella konflikter genom att gå igenom revisionsträdet.
- 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.