CouchDB
 sql >> Base de Dados >  >> NoSQL >> CouchDB

Sincronização de estilo CouchDB e resolução de conflitos no Postgres com Hasura




Temos falado sobre offline primeiro com Hasura e RxDB (essencialmente Postgres e PouchDB abaixo).

Este post continua a aprofundar o tema. É uma discussão e guia para implementar a resolução de conflitos no estilo CouchDB com Postgres (banco de dados de back-end central) e PouchDB (aplicativo de front-end usuário base de dados).

Aqui está o que vamos falar:
  • O que é resolução de conflitos?
  • Meu aplicativo precisa de resolução de conflitos?
  • Resolução de conflitos com PouchDB explicada
  • Trazendo replicação fácil e gerenciamento de conflitos para pouchdb (frontend) e Postgres (backend) com RxDB e Hasura
    • Configurando o Hasura
    • Configuração do lado do cliente
    • Implementando a resolução de conflitos
    • Usando visualizações
    • Usando acionadores do postgres
  • Estratégias personalizadas de resolução de conflitos com Hasura
    • Resolução de conflitos personalizada no servidor
    • Resolução de conflitos personalizada no cliente
  • Conclusão

O que é resolução de conflitos?


Tomemos um quadro do Trello como exemplo. Digamos que você alterou o responsável em um cartão Trello enquanto estava offline. Enquanto isso, seu colega edita a descrição do mesmo cartão. Quando você voltar a ficar on-line, desejará ver as duas alterações. Agora suponha que vocês dois mudaram a descrição ao mesmo tempo, o que deve acontecer neste caso? Uma opção é simplesmente fazer a última gravação - que é substituir a alteração anterior pela nova. Outra é notificar o usuário e deixá-lo atualizar o cartão com um campo mesclado (como git!).

Esse aspecto de fazer várias alterações simultâneas (que podem ser conflitantes) e mesclá-las em uma alteração é chamado de resolução de conflitos.

Que tipo de aplicativo você pode criar depois de ter bons recursos de replicação e resolução de conflitos?


A infraestrutura de replicação e resolução de conflitos é difícil de ser incorporada no front-end e no back-end de um aplicativo. Mas uma vez configurado, alguns casos de uso importantes se tornam viáveis! Na verdade, para certos tipos de aplicativos, a replicação (e, portanto, a resolução de conflitos) é fundamental para a funcionalidade do aplicativo!
  1. Tempo real:as alterações feitas pelos usuários em diferentes dispositivos são sincronizadas entre si
  2. Colaborativo:diferentes usuários trabalham simultaneamente nos mesmos dados
  3. Offline-first:o mesmo usuário pode trabalhar com seus dados mesmo quando o aplicativo não estiver conectado ao banco de dados central

Exemplos:Trello, clientes de e-mail como Gmail, Superhuman, Google docs, Facebook, Twitter etc.

Hasura torna super fácil adicionar recursos de alto desempenho, seguros e em tempo real ao seu aplicativo baseado em Postgres existente. Não há necessidade de implantar infraestrutura de back-end adicional para dar suporte a esses casos de uso! Nas próximas seções, aprenderemos como você pode usar o PouchDB/RxDB no frontend e emparelhá-lo com o Hasura para criar aplicativos poderosos com ótima experiência do usuário.

Resolução de conflitos com PouchDB explicada

Gerenciamento de versão com PouchDB


O PouchDB - que o RxDB usa por baixo - vem com um poderoso mecanismo de controle de versão e gerenciamento de conflitos. Todo documento no PouchDB tem um campo de versão associado a ele. Os campos de versão estão no formato <depth>-<object-hash> por exemplo 2-c1592ce7b31cc26e91d2f2029c57e621 . Aqui a profundidade indica a profundidade na árvore de revisão. O hash do objeto é uma string gerada aleatoriamente.

Uma prévia das revisões do PouchDB


O PouchDB expõe APIs para buscar o histórico de revisões de um documento. Podemos consultar o histórico de revisões desta forma:

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

Isso retornará um documento contendo um _revisions campo:

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

Aqui ids contém hierarquia de revisões de revisões (incluindo a atual) e start contém o "número do prefixo" para a revisão atual. Toda vez que uma nova revisão é adicionada start é incrementado e um novo hash é adicionado ao início do ids variedade.

Quando um documento é sincronizado com um servidor remoto, _revisions e _rev campos precisam ser incluídos. Dessa forma, todos os clientes eventualmente terão o histórico completo de versões. Isso acontece automaticamente quando o PouchDB é configurado para sincronizar com o CouchDB. A solicitação pull acima também permite isso ao sincronizar via GraphQL.

Observe que nem todos os clientes têm necessariamente todas as revisões, mas todos eles eventualmente terão as versões mais recentes e o histórico dos ids de revisão para essas versões.

Resolução de conflitos


Um conflito será detectado se duas revisões tiverem o mesmo pai ou mais simplesmente se quaisquer duas revisões tiverem a mesma profundidade. Quando um conflito é detectado, o CouchDB e o PouchDB usarão o mesmo algoritmo para escolher automaticamente um vencedor:
  1. Selecione as revisões com o campo de maior profundidade que não estão marcadas como excluídas
  2. Se houver apenas 1 campo, trate-o como o vencedor
  3. Se houver mais de 1, classifique os campos de revisão em ordem decrescente e escolha o primeiro.

Uma observação sobre a exclusão: O PouchDB e o CouchDB nunca excluem revisões ou documentos, em vez disso, uma nova revisão é criada com um sinalizador _deleted definido como true. Portanto, na etapa 1 do algoritmo acima, quaisquer cadeias que terminem com uma revisão marcada como excluída são ignoradas.

Um bom recurso desse algoritmo é que não há necessidade de coordenação entre clientes ou cliente e servidor para resolver um conflito. Também não é necessário nenhum marcador adicional para marcar uma versão como vencedora. Cada cliente e o servidor escolhem independentemente o vencedor. Mas o vencedor será a mesma revisão porque eles usam o mesmo algoritmo determinístico. Mesmo que um dos clientes tenha algumas revisões faltando, eventualmente, quando essas revisões forem sincronizadas, a mesma revisão será escolhida como vencedora.

Implementação de estratégias personalizadas de resolução de conflitos


Mas e se quisermos uma estratégia alternativa de resolução de conflitos? Por exemplo "mesclar por campos" - Se duas revisões conflitantes modificaram chaves diferentes do objeto, queremos mesclar automaticamente criando uma revisão com ambas as chaves. A maneira recomendada de fazer isso no PouchDB é:
  1. Crie esta nova revisão em qualquer uma das cadeias
  2. Adicione uma revisão com _deleted definido como true para cada uma das outras cadeias

A revisão mesclada agora será automaticamente a revisão vencedora de acordo com o algoritmo acima. Podemos fazer a resolução personalizada no servidor ou no cliente. Quando as revisões forem sincronizadas, todos os clientes e o servidor verão a revisão mesclada como a revisão vencedora.

Resolução de conflitos com Hasura e RxDB


Para implementar a estratégia de resolução de conflitos acima, precisaremos que o Hasura também armazene o histórico de revisões e que o RxDB sincronize as revisões durante a replicação usando o GraphQL.

Configurando o Hasura


Continuando com o exemplo do aplicativo Todo da postagem anterior. Teremos que atualizar o esquema da tabela Todos da seguinte forma:

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

Observe os campos adicionais:
  • _rev representa a revisão do registro.
  • _parent_rev representa a revisão pai do registro
  • _depth é a profundidade do registro na árvore de revisão
  • _revisions contém o histórico completo das revisões do registro.

A chave primária da tabela é (id , _rev ).

Estritamente falando, só precisamos do _revisions campo, uma vez que as outras informações podem ser derivadas dele. Mas ter os outros campos prontamente disponíveis facilita a detecção e a resolução de conflitos.

Configuração do lado do cliente


Precisamos definir syncRevisions para true ao configurar a replicação


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

       ...
    }

Também precisamos adicionar um campo de texto last_pulled_rev para o esquema RxDB. Este campo é usado internamente pelo plug-in para evitar o envio de revisões obtidas do servidor de volta para o servidor.

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

Por fim, precisamos alterar os construtores de consultas pull e push para sincronizar as informações relacionadas à revisão

Construtor de consultas pull

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

Agora buscamos os campos _rev &_revisions. O plugin atualizado usará esses campos para criar revisões locais do 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
    };
};

Com o plugin atualizado, o parâmetro de entrada doc agora contém _rev e _revisions Campos. Passamos para Hasura na consulta GraphQL. Adicionamos campos _depth , _parent_rev para doc antes de fazê-lo.

Anteriormente estávamos usando um upsert para inserir ou atualizar um todo registro em Hasura. Agora, como cada versão acaba sendo um novo registro, usamos a mutação de inserção simples e antiga.

Implementação da resolução de conflitos


Se dois clientes diferentes agora fizerem alterações conflitantes, ambas as revisões serão sincronizadas e apresentadas no Hasura. Ambos os clientes também receberão a outra revisão. Como a estratégia de resolução de conflitos do PouchDB é determinística, ambos os clientes escolherão a mesma versão que a "revisão vencedora".

Como podemos encontrar esta revisão vencedora no servidor? Teremos que implementar o mesmo algoritmo em SQL.

Implementando o algoritmo de resolução de conflitos do CouchDB no Postgres


Etapa 1:encontrar nós folha não marcados como excluídos

Para fazer isso, precisamos ignorar todas as versões que tenham uma revisão filha e todas as versões marcadas como excluídas:

    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

Etapa 2:encontrar a corrente com a profundidade máxima

Supondo que tenhamos os resultados da consulta acima em uma tabela (ou visão ou uma cláusula with) chamada folhas, podemos encontrar a cadeia com profundidade máxima:

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

Etapa 3:encontrar revisões vencedoras entre revisões com profundidade máxima igual

Novamente, assumindo que os resultados da consulta acima estão em uma tabela (ou uma visão ou uma cláusula with) chamada max_depths, podemos encontrar a revisão vencedora da seguinte maneira:

    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

Criando uma visualização com revisões vencedoras


Juntando as três consultas acima, podemos criar uma visualização que nos mostra as revisões vencedoras da seguinte forma:

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;


Como o Hasura pode rastrear visualizações e permite consultá-las via GraphQL, as revisões vencedoras agora podem ser expostas a outros clientes e serviços.

Sempre que você consultar a visualização, o Postgres simplesmente substituirá a visualização pela consulta na definição da visualização e executará a consulta resultante. Se você consultar a visualização com frequência, isso pode acabar levando a muitos ciclos de CPU desperdiçados. Podemos otimizar isso usando gatilhos do Postgres e armazenando as revisões vencedoras em uma tabela diferente.

Usando acionadores do Postgres para calcular as revisões vencedoras


Etapa 1:crie uma nova tabela todos_current_revisions

O esquema será o mesmo do todos tabela. A chave primária será, no entanto, o id coluna em vez de (id, _rev)

Etapa 2:criar acionador do Postgres

Podemos escrever a consulta para o gatilho começando com a consulta de visualização. Como a função de gatilho será executada para uma linha de cada vez, podemos simplificar a consulta:

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


É isso! Agora podemos consultar as versões vencedoras tanto no servidor quanto no cliente.

Resolução de conflitos personalizada


Agora vamos ver como implementar a resolução de conflitos personalizada com Hasura e RxDB.

Resolução de conflitos personalizada no lado do servidor


Digamos que queremos mesclar os todos por campos. Como vamos fazer isso? A essência abaixo nos mostra isso:



Esse SQL parece muito, mas a única parte que lida com a estratégia de mesclagem real é esta:

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

Aqui declaramos uma função agregada personalizada do Postgres agg_merge_revisions para mesclar elementos. A maneira como isso funciona é semelhante a uma função 'reduce':o Postgres inicializará o valor agregado para '{}' , em seguida, execute o merge_revisions função com o agregado atual e o próximo elemento a ser mesclado. Então, se tivéssemos 3 versões conflitantes para serem mescladas, o resultado seria:

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

Se quisermos implementar outra estratégia, precisaremos alterar o merge_revisions função. Por exemplo, se quisermos implementar a estratégia 'última gravação ganha':

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;


A consulta de inserção no gist acima pode ser executada em um gatilho pós-inserção para mesclar automaticamente conflitos sempre que ocorrerem.

Observação: Acima, usamos o SQL para implementar a resolução de conflitos personalizada. Uma abordagem alternativa é usar escrever uma ação:
  1. Crie uma mutação personalizada para lidar com a inserção em vez da mutação de inserção padrão gerada automaticamente.
  2. No manipulador de ações, crie a nova revisão do registro. Podemos usar a mutação insert Hasura para isso.
  3. Busque todas as revisões do objeto usando uma consulta de lista
  4. Detecte quaisquer conflitos percorrendo a árvore de revisão.
  5. Escrever a versão mesclada.

Essa abordagem será atraente para você se preferir escrever essa lógica em uma linguagem diferente do SQL. Outra abordagem é criar uma exibição SQL para mostrar as revisões conflitantes e implementar a lógica restante no manipulador de ações. Isso simplificará a etapa 4. acima, pois agora podemos simplesmente consultar a exibição para detectar conflitos.

Resolução de conflitos personalizada no lado do cliente


Há cenários em que você precisa da intervenção do usuário para poder resolver um conflito. Por exemplo, se estivéssemos construindo algo como o aplicativo Trello e dois usuários modificassem a descrição da mesma tarefa, você pode querer mostrar ao usuário ambas as versões e deixá-lo criar uma versão mesclada. Nesses cenários, precisaremos resolver o conflito no lado do cliente.

A resolução de conflitos do lado do cliente é mais simples de implementar porque o PouchDB já expõe as APIs para consultar revisões conflitantes. Se olharmos para os todos Coleção RxDB do post anterior, aqui está como podemos buscar as versões conflitantes:

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

A consulta acima preencheria as revisões conflitantes no _conflicts campo no resultado. Podemos então apresentá-los ao usuário para resolução.

Conclusão


O PouchDB vem com uma construção flexível e poderosa para solução de controle de versão e gerenciamento de conflitos. Este post nos mostrou como usar essas construções com Hasura/Postgres. Neste post, focamos em fazer isso usando o plpgsql. Faremos um post de acompanhamento mostrando como fazer isso com Actions para que você possa usar o idioma de sua escolha no backend!

Gostou deste artigo? Junte-se a nós no Discord para mais discussões sobre Hasura e GraphQL!

Assine nossa newsletter para saber quando publicarmos novos artigos.