CouchDB
 sql >> база данни >  >> NoSQL >> CouchDB

Синхронизиране в стил CouchDB и разрешаване на конфликти в Postgres с Hasura

Говорихме първо за офлайн с Hasura и RxDB (по същество Postgres и PouchDB отдолу).

Тази публикация продължава да се потапя по-дълбоко в темата. Това е дискусия и ръководство за внедряване на разрешаване на конфликти в стил CouchDB с Postgres (централна бекенд база данни) и PouchDB (потребител на предното приложение) база данни).

Ето за какво ще говорим:

  • Какво е разрешаване на конфликти?
  • Моето приложение има ли нужда от разрешаване на конфликти?
  • Разрешаването на конфликти с PouchDB е обяснено
  • Предоставяне на лесно репликация и управление на конфликти в pouchdb (frontend) и Postgres (backend) с RxDB и Hasura
    • Настройване на Hasura
    • Настройка от страна на клиента
    • Прилагане на разрешаване на конфликти
    • Използване на изгледи
    • Използване на задействания на postgres
  • Персонализирани стратегии за разрешаване на конфликти с Hasura
    • Персонализирано разрешаване на конфликти на сървъра
    • Персонализирано разрешаване на конфликти на клиента
  • Заключение

Какво е разрешаване на конфликти?

Нека вземем за пример дъска Trello. Да приемем, че сте променили правоприемника на карта Trello, докато сте офлайн. Междувременно вашият колега редактира описанието на същата карта. Когато се върнете онлайн, бихте искали да видите и двете промени. Сега да предположим, че и двамата промените описанието едновременно, какво трябва да се случи в този случай? Една от опциите е просто да вземете последното записване - това е да замените по-ранната промяна с новата. Друго е да уведомите потребителя и да го оставите да актуализира картата с обединено поле (като git!).

Този аспект на вземане на множество едновременни промени (които може да са противоречиви) и обединяването им в една промяна се нарича разрешаване на конфликти.

Какъв вид приложения можете да създадете, след като имате добри възможности за репликация и разрешаване на конфликти?

Инфраструктурата за репликация и разрешаване на конфликти е болезнена за вграждане в предния и задния край на приложение. Но след като е настроен, някои важни случаи на употреба стават жизнеспособни! Всъщност за определени видове приложения репликацията (и следователно разрешаването на конфликти) е от решаващо значение за функционалността на приложението!

  1. В реално време:Промените, направени от потребителите на различни устройства, се синхронизират една с друга
  2. Сътрудничество:Различни потребители едновременно работят върху едни и същи данни
  3. Офлайн-първо:Същият потребител може да работи със своите данни, дори когато приложението не е свързано с централната база данни

Примери: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. Изберете ревизии с най-голямо поле за дълбочина, които не са маркирани като изтрити
  2. Ако има само 1 такова поле, третирайте го като победител
  3. Ако има повече от 1, сортирайте полетата за ревизия в низходящ ред и изберете първото.

Бележка относно изтриването: PouchDB &CouchDB никога не изтриват ревизии или документи, вместо това се създава нова ревизия с флаг _deleted, зададен на true. Така че в стъпка 1 от горния алгоритъм всички вериги, които завършват с ревизия, отбелязана като изтрита, се игнорират.

Една приятна характеристика на този алгоритъм е, че не се изисква координация между клиентите или клиента и сървъра за разрешаване на конфликт. Не е необходим допълнителен маркер, за да маркирате версия като печеливша. Всеки клиент и сървър независимо избират победителя. Но победителят ще бъде същата ревизия, тъй като те използват същия детерминистичен алгоритъм. Дори ако на един от клиентите липсват някои ревизии, в крайна сметка, когато тези ревизии се синхронизират, същата ревизия се избира като победител.

Внедряване на персонализирани стратегии за разрешаване на конфликти

Но какво ще стане, ако искаме алтернативна стратегия за разрешаване на конфликти? Например "сливане по полета" - Ако две конфликтни ревизии са променили различни ключове на обекта, ние искаме да се слеем автоматично, като създадем ревизия с двата ключа. Препоръчителният начин да направите това в PouchDB е да:

  1. Създайте тази нова редакция във всяка от веригите
  2. Добавете ревизия с _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 за прилагане на персонализирано разрешаване на конфликти. Алтернативен подход е да се използва запис на действие:

  1. Създайте персонализирана мутация за обработка на вмъкването вместо автоматично генерираната по подразбиране мутация на вмъкване.
  2. В манипулатора на действие създайте новата ревизия на записа. Можем да използваме мутацията на вмъкване на Hasura за това.
  3. Извличане на всички ревизии за обекта с помощта на списъчна заявка
  4. Откривайте всякакви конфликти, като преминете през дървото на ревизиите.
  5. Запишете обратно обединената версия.

Този подход ще ви хареса, ако предпочитате да напишете тази логика на език, различен от SQL. Друг подход е да се създаде SQL изглед за показване на конфликтните ревизии и прилагане на останалата логика в манипулатора на действие. Това ще опрости стъпка 4. по-горе, тъй като вече можем просто да потърсим изгледа за откриване на конфликти.

Персонализирано разрешаване на конфликти от страна на клиента

Има сценарии, при които се нуждаете от намеса на потребителя, за да можете да разрешите конфликт. Например, ако създавахме нещо като приложението Trello и двама потребители промениха описанието на една и съща задача, може да искате да покажете на потребителя и двете версии и да им позволите да създаде обединена версия. В тези сценарии ще трябва да разрешим конфликта от страна на клиента.

Разрешаването на конфликти от страна на клиента е по-лесно за изпълнение, тъй като PouchDB вече излага API на заявки за конфликтни ревизии. Ако погледнем todos RxDB колекция от предишната публикация, ето как можем да извлечем конфликтните версии:

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

Горната заявка ще попълни конфликтните ревизии в _conflicts поле в резултата. След това можем да ги представим на потребителя за разрешаване.

Заключение

PouchDB идва с гъвкава и мощна конструкция за управление на версии и решение за управление на конфликти. Тази публикация ни показа как да използваме тези конструкции с Hasura/Postgres. В тази публикация се фокусирахме върху това да направим това с помощта на plpgsql. Ще направим последваща публикация, показваща как да направите това с Actions, така че да можете да използвате езика по ваш избор в бекенда!

Хареса ли ви тази статия? Присъединете се към нас в Discord за повече дискусии относно Hasura &GraphQL!

Регистрирайте се за нашия бюлетин, за да знаете кога публикуваме нови статии.


  1. Redis
  2.   
  3. MongoDB
  4.   
  5. Memcached
  6.   
  7. HBase
  8.   
  9. CouchDB
  1. Couchbase XDCR репликация – стъпка по стъпка – най-добри практики

  2. Някой пробвал ли е CouchDB и различни офлайн реализации (PouchDB)?

  3. Как да инсталирате Apache CouchDB на CentOS 8

  4. Синхронизиране в стил CouchDB и разрешаване на конфликти в Postgres с Hasura

  5. Как да инсталирате Apache CouchDB 2.3.0 в Linux