Говорихме първо за офлайн с Hasura и RxDB (по същество Postgres и PouchDB отдолу).
Тази публикация продължава да се потапя по-дълбоко в темата. Това е дискусия и ръководство за внедряване на разрешаване на конфликти в стил CouchDB с Postgres (централна бекенд база данни) и PouchDB (потребител на предното приложение) база данни).
Ето за какво ще говорим:
- Какво е разрешаване на конфликти?
- Моето приложение има ли нужда от разрешаване на конфликти?
- Разрешаването на конфликти с PouchDB е обяснено
- Предоставяне на лесно репликация и управление на конфликти в pouchdb (frontend) и Postgres (backend) с RxDB и Hasura
- Настройване на Hasura
- Настройка от страна на клиента
- Прилагане на разрешаване на конфликти
- Използване на изгледи
- Използване на задействания на postgres
- Персонализирани стратегии за разрешаване на конфликти с Hasura
- Персонализирано разрешаване на конфликти на сървъра
- Персонализирано разрешаване на конфликти на клиента
- Заключение
Какво е разрешаване на конфликти?
Нека вземем за пример дъска Trello. Да приемем, че сте променили правоприемника на карта Trello, докато сте офлайн. Междувременно вашият колега редактира описанието на същата карта. Когато се върнете онлайн, бихте искали да видите и двете промени. Сега да предположим, че и двамата промените описанието едновременно, какво трябва да се случи в този случай? Една от опциите е просто да вземете последното записване - това е да замените по-ранната промяна с новата. Друго е да уведомите потребителя и да го оставите да актуализира картата с обединено поле (като git!).
Този аспект на вземане на множество едновременни промени (които може да са противоречиви) и обединяването им в една промяна се нарича разрешаване на конфликти.
Какъв вид приложения можете да създадете, след като имате добри възможности за репликация и разрешаване на конфликти?
Инфраструктурата за репликация и разрешаване на конфликти е болезнена за вграждане в предния и задния край на приложение. Но след като е настроен, някои важни случаи на употреба стават жизнеспособни! Всъщност за определени видове приложения репликацията (и следователно разрешаването на конфликти) е от решаващо значение за функционалността на приложението!
- В реално време:Промените, направени от потребителите на различни устройства, се синхронизират една с друга
- Сътрудничество:Различни потребители едновременно работят върху едни и същи данни
- Офлайн-първо:Същият потребител може да работи със своите данни, дори когато приложението не е свързано с централната база данни
Примери:Trello, имейл клиенти като Gmail, Superhuman, Google документи, Facebook, Twitter и др.
Hasura прави супер лесно добавянето на високопроизводителни, сигурни възможности в реално време към вашето съществуващо приложение, базирано на Postgres. Не е необходимо да се разгръща допълнителна бекенд инфраструктура за поддръжка на тези случаи на употреба! В следващите няколко раздела ще научим как можете да използвате PouchDB/RxDB на интерфейса и да го сдвоите с Hasura, за да създавате мощни приложения с страхотно потребителско изживяване.
Разрешаването на конфликти с PouchDB е обяснено
Управление на версиите с PouchDB
PouchDB - който RxDB използва отдолу - идва с мощен механизъм за управление на версии и конфликти. Всеки документ в PouchDB има поле за версия, свързано с него. Полетата за версия са от формата <дълбочина>-<обект-хеш>код> например
2-c1592ce7b31cc26e91d2f2029c57e621
. Тук дълбочината показва дълбочината в дървото на ревизията. Хешът на обекта е произволно генериран низ.
Кратък поглед към ревизиите на PouchDB
PouchDB разкрива API за извличане на историята на ревизиите на документ. Можем да потърсим историята на ревизиите по следния начин:
todos.pouch.get(todo.id, {
revs: true
})
Това ще върне документ, съдържащ _revisions
поле:
{
"id": "559da26d-ad0f-42bc-a172-1821641bf2bb",
"_rev": "4-95162faab173d1e748952179e0db1a53",
"_revisions": {
"ids": [
"95162faab173d1e748952179e0db1a53",
"94162faab173d1e748952179e0db1a53",
"9055e63d99db056a95b61936f0185c8c",
"de71900ec14567088bed5914b2439896"
],
"start": 4
}
}
Тук ids
съдържа йерархия от ревизии на ревизии (включително текущите) и start
съдържа "номера на префикса" за текущата ревизия. Всеки път, когато се добавя нова ревизия start
се увеличава и в началото на ids
се добавя нов хеш масив.
Когато документ се синхронизира с отдалечен сървър, _revisions
и _rev
полета трябва да бъдат включени. По този начин всички клиенти в крайна сметка имат пълната история на версиите. Това се случва автоматично, когато PouchDB е настроен да се синхронизира с CouchDB. Горната заявка за изтегляне позволява това и при синхронизиране чрез GraphQL.
Обърнете внимание, че не всички клиенти непременно имат всички ревизии, но всички те в крайна сметка ще имат най-новите версии и историята на идентификаторите на ревизии за тези версии.
Разрешаване на конфликти
Конфликт ще бъде открит, ако две ревизии имат един и същ родител или по-просто, ако две ревизии имат еднаква дълбочина. Когато бъде открит конфликт, CouchDB и PouchDB ще използват същия алгоритъм за автоматично избиране на победител:
- Изберете ревизии с най-голямо поле за дълбочина, които не са маркирани като изтрити
- Ако има само 1 такова поле, третирайте го като победител
- Ако има повече от 1, сортирайте полетата за ревизия в низходящ ред и изберете първото.
Бележка относно изтриването: PouchDB &CouchDB никога не изтриват ревизии или документи, вместо това се създава нова ревизия с флаг _deleted, зададен на true. Така че в стъпка 1 от горния алгоритъм всички вериги, които завършват с ревизия, отбелязана като изтрита, се игнорират.
Една приятна характеристика на този алгоритъм е, че не се изисква координация между клиентите или клиента и сървъра за разрешаване на конфликт. Не е необходим допълнителен маркер, за да маркирате версия като печеливша. Всеки клиент и сървър независимо избират победителя. Но победителят ще бъде същата ревизия, тъй като те използват същия детерминистичен алгоритъм. Дори ако на един от клиентите липсват някои ревизии, в крайна сметка, когато тези ревизии се синхронизират, същата ревизия се избира като победител.
Внедряване на персонализирани стратегии за разрешаване на конфликти
Но какво ще стане, ако искаме алтернативна стратегия за разрешаване на конфликти? Например "сливане по полета" - Ако две конфликтни ревизии са променили различни ключове на обекта, ние искаме да се слеем автоматично, като създадем ревизия с двата ключа. Препоръчителният начин да направите това в PouchDB е да:
- Създайте тази нова редакция във всяка от веригите
- Добавете ревизия с _deleted, зададено на true към всяка от другите вериги
Обединената ревизия вече автоматично ще бъде печелившата ревизия съгласно горния алгоритъм. Можем да направим персонализирана резолюция или на сървъра, или на клиента. Когато ревизиите се синхронизират, всички клиенти и сървърът ще види обединената ревизия като печеливша ревизия.
Разрешаване на конфликти с Hasura и RxDB
За да приложим горната стратегия за разрешаване на конфликти, ще ни трябва Hasura също да съхранява хронологията на ревизиите и RxDB да синхронизира ревизии, докато се репликира с помощта на GraphQL.
Настройване на Hasura
Продължавайки с примера за приложението Todo от предишната публикация. Ще трябва да актуализираме схемата за таблицата Todos, както следва:
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,
)
Обърнете внимание на допълнителните полета:
_rev
представлява ревизията на записа._parent_rev
представлява родителската ревизия на записа_дълбочина
е дълбочината на записа в дървото на ревизията_revisions
съдържа пълната история на ревизиите на записа.
Първичният ключ за таблицата е (id
, _rev
).
Строго погледнато имаме нужда само от _revisions
поле, тъй като другата информация може да бъде извлечена от него. Но наличието на лесно достъпни други полета прави откриването и разрешаването на конфликти по-лесно.
Настройка от страна на клиента
Трябва да зададем syncRevisions
на true, докато настройвате репликация
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,
});
...
}
Трябва също да добавим текстово поле last_pulled_rev
към RxDB схема. Това поле се използва вътрешно от приставката, за да се избегне изтласкване на ревизии, извлечени от сървъра обратно към сървъра.
const todoSchema = {
...
'properties': {
...
'last_pulled_rev': {
'type': 'string'
}
},
...
};
И накрая, трябва да променим конструкторите на заявки за изтегляне и натискане, за да синхронизираме информация, свързана с ревизията
Конструктор на изтеглени заявки
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: {}
};
};
};
Сега извличаме полетата _rev &_revisions. Надстроеният плъгин ще използва тези полета за създаване на локални ревизии на 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
};
};
С надстроения плъгин, входният параметър doc
сега съдържа _rev
и _revisions
полета. Преминаваме към Hasura в заявката GraphQL. Добавяме полета _depth
, _parent_rev
към doc
преди да го направите.
По-рано използвахме upsert, за да вмъкнем или актуализираме todo
запис на Хасура. Сега, тъй като всяка версия в крайна сметка се превръща в нов запис, вместо това използваме обикновената стара мутация на вмъкване.
Внедряване на разрешаване на конфликти
Ако два различни клиента сега направят противоречиви промени, тогава и двете ревизии ще бъдат синхронизирани и ще присъстват в Hasura. И двамата клиенти в крайна сметка ще получат и другата ревизия. Тъй като стратегията на PouchDB за разрешаване на конфликти е детерминистична, и клиентите ще изберат същата версия като "печелившата ревизия".
Как можем да намерим тази печеливша ревизия на сървъра? Ще трябва да приложим същия алгоритъм в SQL.
Внедряване на алгоритъма за разрешаване на конфликти на CouchDB в Postgres
Стъпка 1:Намиране на листни възли, които не са маркирани като изтрити
За да направим това, трябва да игнорираме всички версии, които имат дъщерна ревизия и всички версии, които са маркирани като изтрити:
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
Стъпка 2:Намиране на веригата с максимална дълбочина
Ако приемем, че имаме резултатите от горната заявка в таблица (или изглед или клауза with), наречена листа, можем да намерим веригата с максимална дълбочина е права напред:
SELECT
id,
MAX(_depth) AS max_depth
FROM
leaves
GROUP BY
id
Стъпка 3:Намиране на печеливши ревизии сред ревизии с еднаква максимална дълбочина
Отново, ако приемем, че резултатите от горната заявка са в таблица (или изглед или клауза with), наречена max_depths, можем да намерим печелившата ревизия, както следва:
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
Създаване на изглед с печеливши ревизии
Събирайки горните три заявки, можем да създадем изглед, който ни показва печелившите ревизии, както следва:
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;
Тъй като Hasura може да проследява изгледи и позволява запитването им чрез GraphQL, печелившите ревизии вече могат да бъдат изложени на други клиенти и услуги.
Всеки път, когато правите заявка към изгледа, Postgres просто ще замени изгледа със заявката в дефиницията на изгледа и ще изпълни получената заявка. Ако запитвате изгледа често, това може да доведе до много загубени цикли на процесора. Можем да оптимизираме това, като използваме Postgres тригери и съхраняваме печелившите ревизии в друга таблица.
Използване на тригери на Postgres за изчисляване на печелившите ревизии
Стъпка 1:Създайте нова таблица todos_current_revisions
Схемата ще бъде същата като тази на todos
маса. Първичният ключ обаче ще бъде id
колона вместо (id, _rev)
Стъпка 2:Създайте задействане на Postgres
Можем да напишем заявката за тригера, като започнем със заявката за изглед. Тъй като функцията за задействане ще се изпълнява за един ред в даден момент, можем да опростим заявката:
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 ()
Това е! Вече можем да правим запитвания за печелившите версии както на сървъра, така и на клиента.
Персонализирано разрешаване на конфликти
Сега нека разгледаме прилагането на персонализирано разрешаване на конфликти с Hasura &RxDB.
Персонализирано разрешаване на конфликти от страна на сървъра
Да кажем, че искаме да обединим задачите по полета. Как да направим това? Същината по-долу ни показва това:
Този SQL изглежда много, но единствената част, която се занимава с действителната стратегия за сливане, е следната:
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
);
Тук декларираме персонализирана агрегатна функция на Postgres agg_merge_revisions
за сливане на елементи. Начинът, по който това работи, е подобен на функцията 'reduce':Postgres ще инициализира обобщената стойност до '{}'
, след което стартирайте merge_revisions
функция с текущия агрегат и следващия елемент за сливане. Така че, ако имаме 3 конфликтни версии, които да бъдат обединени, резултатът ще бъде:
merge_revisions(merge_revisions(merge_revisions('{}', v1), v2), v3)
Ако искаме да приложим друга стратегия, ще трябва да променим merge_revisions
функция. Например, ако искаме да приложим стратегията „последното записване печели“:
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;
Заявката за вмъкване в горната същност може да се изпълни в тригер след вмъкване за автоматично обединяване на конфликти, когато възникнат.
Забележка: По-горе използвахме SQL за прилагане на персонализирано разрешаване на конфликти. Алтернативен подход е да се използва запис на действие:
- Създайте персонализирана мутация за обработка на вмъкването вместо автоматично генерираната по подразбиране мутация на вмъкване.
- В манипулатора на действие създайте новата ревизия на записа. Можем да използваме мутацията на вмъкване на Hasura за това.
- Извличане на всички ревизии за обекта с помощта на списъчна заявка
- Откривайте всякакви конфликти, като преминете през дървото на ревизиите.
- Запишете обратно обединената версия.
Този подход ще ви хареса, ако предпочитате да напишете тази логика на език, различен от SQL. Друг подход е да се създаде SQL изглед за показване на конфликтните ревизии и прилагане на останалата логика в манипулатора на действие. Това ще опрости стъпка 4. по-горе, тъй като вече можем просто да потърсим изгледа за откриване на конфликти.
Персонализирано разрешаване на конфликти от страна на клиента
Има сценарии, при които се нуждаете от намеса на потребителя, за да можете да разрешите конфликт. Например, ако създавахме нещо като приложението Trello и двама потребители промениха описанието на една и съща задача, може да искате да покажете на потребителя и двете версии и да им позволите да създаде обединена версия. В тези сценарии ще трябва да разрешим конфликта от страна на клиента.
Разрешаването на конфликти от страна на клиента е по-лесно за изпълнение, тъй като PouchDB вече излага API на заявки за конфликтни ревизии. Ако погледнем todos
RxDB колекция от предишната публикация, ето как можем да извлечем конфликтните версии:
todos.pouch.get(todo.id, {
conflicts: true
})
Горната заявка ще попълни конфликтните ревизии в _conflicts
поле в резултата. След това можем да ги представим на потребителя за разрешаване.
Заключение
PouchDB идва с гъвкава и мощна конструкция за управление на версии и решение за управление на конфликти. Тази публикация ни показа как да използваме тези конструкции с Hasura/Postgres. В тази публикация се фокусирахме върху това да направим това с помощта на plpgsql. Ще направим последваща публикация, показваща как да направите това с Actions, така че да можете да използвате езика по ваш избор в бекенда!
Хареса ли ви тази статия? Присъединете се към нас в Discord за повече дискусии относно Hasura &GraphQL!
Регистрирайте се за нашия бюлетин, за да знаете кога публикуваме нови статии.