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!
- Tempo real:as alterações feitas pelos usuários em diferentes dispositivos são sincronizadas entre si
- Colaborativo:diferentes usuários trabalham simultaneamente nos mesmos dados
- 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:
- Selecione as revisões com o campo de maior profundidade que não estão marcadas como excluídas
- Se houver apenas 1 campo, trate-o como o vencedor
- 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 é:
- Crie esta nova revisão em qualquer uma das cadeias
- 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:
- Crie uma mutação personalizada para lidar com a inserção em vez da mutação de inserção padrão gerada automaticamente.
- No manipulador de ações, crie a nova revisão do registro. Podemos usar a mutação insert Hasura para isso.
- Busque todas as revisões do objeto usando uma consulta de lista
- Detecte quaisquer conflitos percorrendo a árvore de revisão.
- 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.