Hemos estado hablando de offline-first con Hasura y RxDB (esencialmente Postgres y PouchDB por debajo).
Esta publicación continúa profundizando en el tema. Es una discusión y una guía para implementar la resolución de conflictos al estilo de CouchDB con Postgres (base de datos backend central) y PouchDB (usuario de la aplicación frontend). base de datos).
Esto es de lo que vamos a hablar:
- ¿Qué es la resolución de conflictos?
- ¿Mi aplicación necesita resolución de conflictos?
- Explicación de la resolución de conflictos con PouchDB
- Fácil replicación y gestión de conflictos en pouchdb (frontend) y Postgres (backend) con RxDB y Hasura
- Configuración de Hasura
- Configuración del lado del cliente
- Implementación de la resolución de conflictos
- Uso de vistas
- Uso de disparadores de postgres
- Estrategias personalizadas de resolución de conflictos con Hasura
- Resolución de conflictos personalizada en el servidor
- Resolución de conflictos personalizada en el cliente
- Conclusión
¿Qué es la resolución de conflictos?
Tomemos un tablero de Trello como ejemplo. Supongamos que ha cambiado el asignado en una tarjeta de Trello sin conexión. Mientras tanto, su colega edita la descripción de la misma tarjeta. Cuando vuelvas a conectarte, querrás ver ambos cambios. Ahora supongamos que ambos cambiaron la descripción al mismo tiempo, ¿qué debería suceder en este caso? Una opción es simplemente tomar la última escritura, es decir, anular el cambio anterior con el nuevo. Otra es notificar al usuario y dejar que actualice la tarjeta con un campo combinado (¡como git!).
Este aspecto de tomar múltiples cambios simultáneos (que pueden ser conflictivos) y fusionarlos en un solo cambio se denomina resolución de conflictos.
¿Qué tipo de aplicaciones puede crear una vez que tenga buenas capacidades de replicación y resolución de conflictos?
La infraestructura de replicación y resolución de conflictos es complicada de integrar en el front-end y el back-end de una aplicación. ¡Pero una vez que está configurado, algunos casos de uso importantes se vuelven viables! De hecho, para ciertos tipos de aplicaciones, la replicación (y, por lo tanto, la resolución de conflictos) es fundamental para la funcionalidad de la aplicación.
- En tiempo real:los cambios realizados por los usuarios en diferentes dispositivos se sincronizan entre sí
- Colaborativo:diferentes usuarios trabajan simultáneamente en los mismos datos
- Desconectado primero:el mismo usuario puede trabajar con sus datos incluso cuando la aplicación no está conectada a la base de datos central
Ejemplos:Trello, clientes de correo electrónico como Gmail, Superhuman, Google docs, Facebook, Twitter, etc.
Hasura hace que sea muy fácil agregar capacidades de alto rendimiento, seguras y en tiempo real a su aplicación basada en Postgres existente. ¡No es necesario implementar una infraestructura de back-end adicional para admitir estos casos de uso! En las próximas secciones, aprenderemos cómo puede usar PouchDB/RxDB en la interfaz y combinarlo con Hasura para crear aplicaciones potentes con una gran experiencia de usuario.
Explicación de la resolución de conflictos con PouchDB
Gestión de versiones con PouchDB
PouchDB, que RxDB usa debajo, viene con un poderoso mecanismo de control de versiones y manejo de conflictos. Cada documento en PouchDB tiene un campo de versión asociado. Los campos de versión tienen el formato <depth>-<object-hash>
por ejemplo 2-c1592ce7b31cc26e91d2f2029c57e621
. Aquí profundidad indica la profundidad en el árbol de revisión. El hash de objeto es una cadena generada aleatoriamente.
Un adelanto de las revisiones de PouchDB
PouchDB expone las API para obtener el historial de revisiones de un documento. Podemos consultar el historial de revisión de esta manera:
todos.pouch.get(todo.id, {
revs: true
})
Esto devolverá un documento que contiene un _revisions
campo:
{
"id": "559da26d-ad0f-42bc-a172-1821641bf2bb",
"_rev": "4-95162faab173d1e748952179e0db1a53",
"_revisions": {
"ids": [
"95162faab173d1e748952179e0db1a53",
"94162faab173d1e748952179e0db1a53",
"9055e63d99db056a95b61936f0185c8c",
"de71900ec14567088bed5914b2439896"
],
"start": 4
}
}
Aquí ids
contiene jerarquía de revisiones de revisiones (incluida la actual) y start
contiene el "número de prefijo" para la revisión actual. Cada vez que se agrega una nueva revisión start
se incrementa y se agrega un nuevo hash al inicio de los ids
matriz.
Cuando un documento se sincroniza con un servidor remoto, _revisions
y _rev
es necesario incluir campos. De esta forma, todos los clientes finalmente tendrán el historial de versiones completo. Esto sucede automáticamente cuando PouchDB está configurado para sincronizarse con CouchDB. La solicitud de extracción anterior también habilita esto cuando se sincroniza a través de GraphQL.
Tenga en cuenta que no todos los clientes tienen necesariamente todas las revisiones, pero todos ellos eventualmente tendrán las últimas versiones y el historial de los ID de revisión para estas versiones.
Resolución de conflictos
Se detectará un conflicto si dos revisiones tienen el mismo padre o más simplemente si dos revisiones tienen la misma profundidad. Cuando se detecta un conflicto, CouchDB y PouchDB utilizarán el mismo algoritmo para elegir automáticamente un ganador:
- Seleccione las revisiones con el campo de mayor profundidad que no estén marcadas como eliminadas
- Si solo hay 1 de estos campos, trátelo como el ganador
- Si hay más de 1, ordene los campos de revisión en orden descendente y elija el primero.
Una nota sobre la eliminación: PouchDB y CouchDB nunca eliminan revisiones o documentos, sino que se crea una nueva revisión con un indicador _deleted establecido en verdadero. Entonces, en el paso 1 del algoritmo anterior, se ignoran todas las cadenas que terminan con una revisión marcada como eliminada.
Una buena característica de este algoritmo es que no se requiere coordinación entre los clientes o el cliente y el servidor para resolver un conflicto. Tampoco se requiere marcador adicional para marcar una versión como ganadora. Cada cliente y el servidor eligen de forma independiente al ganador. Pero el ganador será la misma revisión porque usan el mismo algoritmo determinista. Incluso si a uno de los clientes le faltan algunas revisiones, finalmente, cuando esas revisiones se sincronizan, se elige la misma revisión como ganadora.
Implementar estrategias personalizadas de resolución de conflictos
Pero, ¿y si queremos una estrategia alternativa de resolución de conflictos? Por ejemplo, "combinar por campos":si dos revisiones en conflicto han modificado diferentes claves del objeto, queremos fusionar automáticamente creando una revisión con ambas claves. La forma recomendada de hacer esto en PouchDB es:
- Cree esta nueva revisión en cualquiera de las cadenas
- Agregue una revisión con _deleted establecido en verdadero para cada una de las otras cadenas
La revisión fusionada ahora será automáticamente la revisión ganadora de acuerdo con el algoritmo anterior. Podemos hacer una resolución personalizada en el servidor o en el cliente. Cuando las revisiones se sincronicen, todos los clientes y el servidor verán la revisión fusionada como la revisión ganadora.
Resolución de Conflictos con Hasura y RxDB
Para implementar la estrategia de resolución de conflictos anterior, necesitaremos que Hasura también almacene el historial de revisión y que RxDB sincronice las revisiones mientras se replica usando GraphQL.
Configuración de Hasura
Continuando con el ejemplo de la aplicación Todo de la publicación anterior. Tendremos que actualizar el esquema para la tabla Todos de la siguiente manera:
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,
)
Tenga en cuenta los campos adicionales:
_rev
representa la revisión del registro._parent_rev
representa la revisión principal del registro_depth
es la profundidad del registro en el árbol de revisión_revisions
contiene el historial completo de revisiones del registro.
La clave principal de la tabla es (id
, _rev
).
Estrictamente hablando, solo necesitamos las _revisions
ya que la otra información puede derivarse de él. Pero tener los otros campos fácilmente disponibles facilita la detección y resolución de conflictos.
Configuración del lado del cliente
Necesitamos configurar syncRevisions
a verdadero al configurar la replicación
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,
});
...
}
También necesitamos agregar un campo de texto last_pulled_rev
al esquema RxDB. El complemento utiliza este campo internamente para evitar enviar las revisiones recuperadas del servidor al servidor.
const todoSchema = {
...
'properties': {
...
'last_pulled_rev': {
'type': 'string'
}
},
...
};
Finalmente, necesitamos cambiar los generadores de consultas de extracción y inserción para sincronizar la información relacionada con la revisión
Generador de consultas de extracción
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: {}
};
};
};
Ahora buscamos los campos _rev y _revisions. El complemento actualizado utilizará estos campos para crear revisiones locales de PouchDB.
Generador de consultas push
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
};
};
Con el complemento actualizado, el parámetro de entrada doc
ahora contiene _rev
y _revisions
los campos. Pasamos a Hasura en la consulta de GraphQL. Agregamos campos _depth
, _parent_rev
a doc
antes de hacerlo.
Anteriormente estábamos usando un upsert para insertar o actualizar un todo
grabar en Hasura. Ahora, dado que cada versión termina siendo un nuevo registro, en su lugar usamos la simple mutación de inserción antigua.
Implementar la resolución de conflictos
Si dos clientes diferentes ahora hacen cambios en conflicto, ambas revisiones se sincronizarán y estarán presentes en Hasura. Ambos clientes también recibirán eventualmente la otra revisión. Debido a que la estrategia de resolución de conflictos de PouchDB es determinista, ambos clientes elegirán la misma versión como "revisión ganadora".
¿Cómo podemos encontrar esta revisión ganadora en el servidor? Tendremos que implementar el mismo algoritmo en SQL.
Implementando el algoritmo de resolución de conflictos de CouchDB en Postgres
Paso 1:encontrar nodos de hoja que no estén marcados como eliminados
Para hacer esto, debemos ignorar cualquier versión que tenga una revisión secundaria y cualquier versión que esté marcada como eliminada:
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
Paso 2:encontrar la cadena con la profundidad máxima
Suponiendo que tenemos los resultados de la consulta anterior en una tabla (o vista o una cláusula with) llamada hojas, podemos encontrar que la cadena con la profundidad máxima es sencilla:
SELECT
id,
MAX(_depth) AS max_depth
FROM
leaves
GROUP BY
id
Paso 3:encontrar revisiones ganadoras entre revisiones con la misma profundidad máxima
Nuevamente, suponiendo que los resultados de la consulta anterior están en una tabla (o una vista o una cláusula with) llamada max_depths, podemos encontrar la revisión ganadora de la siguiente manera:
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
Creando una vista con revisiones ganadoras
Al juntar las tres consultas anteriores, podemos crear una vista que nos muestre las revisiones ganadoras de la siguiente manera:
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;
Dado que Hasura puede realizar un seguimiento de las vistas y permite consultarlas a través de GraphQL, las revisiones ganadoras ahora se pueden exponer a otros clientes y servicios.
Siempre que consulte la vista, Postgres simplemente reemplazará la vista con la consulta en la definición de la vista y ejecutará la consulta resultante. Si consulta la vista con frecuencia, esto podría terminar generando muchos ciclos de CPU desperdiciados. Podemos optimizar esto usando disparadores de Postgres y almacenando las revisiones ganadoras en una tabla diferente.
Usar disparadores de Postgres para calcular las revisiones ganadoras
Paso 1:Cree una nueva tabla todos_revisiones_actuales
El esquema será el mismo que el de todos
mesa. Sin embargo, la clave principal será el id
columna en lugar de (id, _rev)
Paso 2:crear un activador de Postgres
Podemos escribir la consulta para el disparador comenzando con la consulta de vista. Dado que la función de activación se ejecutará para una fila a la vez, podemos simplificar la 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 ()
¡Eso es todo! Ahora podemos consultar las versiones ganadoras tanto en el servidor como en el cliente.
Resolución de conflictos personalizada
Ahora echemos un vistazo a la implementación de una resolución de conflictos personalizada con Hasura y RxDB.
Resolución de conflictos personalizada en el lado del servidor
Digamos que queremos fusionar todos por campos. ¿Cómo hacemos para hacer esto? La esencia a continuación nos muestra esto:
Ese SQL se parece mucho, pero la única parte que se ocupa de la estrategia de fusión real es 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
);
Aquí declaramos una función agregada de Postgres personalizada agg_merge_revisions
para fusionar elementos. La forma en que esto funciona es similar a una función de 'reducción':Postgres inicializará el valor agregado a '{}'
, luego ejecute merge_revisions
función con el agregado actual y el siguiente elemento a fusionar. Entonces, si tuviéramos 3 versiones en conflicto para fusionar, el resultado sería:
merge_revisions(merge_revisions(merge_revisions('{}', v1), v2), v3)
Si queremos implementar otra estrategia necesitaremos cambiar las merge_revisions
función. Por ejemplo, si queremos implementar la estrategia 'la última escritura gana':
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;
La consulta de inserción en la esencia anterior se puede ejecutar en un disparador posterior a la inserción para fusionar automáticamente los conflictos siempre que ocurran.
- Cree una mutación personalizada para gestionar la inserción en lugar de la mutación de inserción predeterminada generada automáticamente.
- En el controlador de acciones, cree la nueva revisión del registro. Podemos usar la mutación de inserción de Hasura para esto.
- Obtener todas las revisiones del objeto mediante una consulta de lista
- Detecte cualquier conflicto recorriendo el árbol de revisión.
- Reescribe la versión fusionada.
Este enfoque le resultará atractivo si prefiere escribir esta lógica en un lenguaje que no sea SQL. Otro enfoque es crear una vista SQL para mostrar las revisiones en conflicto e implementar la lógica restante en el controlador de acciones. Esto simplificará el paso 4 anterior, ya que ahora podemos simplemente consultar la vista para detectar conflictos.
Resolución de conflictos personalizada en el lado del cliente
Hay escenarios en los que necesita la intervención del usuario para poder resolver un conflicto. Por ejemplo, si estuviéramos creando algo como la aplicación Trello y dos usuarios modificaran la descripción de la misma tarea, es posible que desee mostrar al usuario ambas versiones y dejar que cree una versión fusionada. En estos escenarios, necesitaremos resolver el conflicto del lado del cliente.
La resolución de conflictos del lado del cliente es más sencilla de implementar porque PouchDB ya expone las API para consultar las revisiones en conflicto. Si miramos las todos
Colección RxDB de la publicación anterior, así es como podemos obtener las versiones en conflicto:
todos.pouch.get(todo.id, {
conflicts: true
})
La consulta anterior llenaría las revisiones conflictivas en _conflicts
campo en el resultado. Luego podemos presentarlos al usuario para su resolución.
Conclusión
PouchDB viene con una construcción flexible y poderosa para la solución de control de versiones y gestión de conflictos. Esta publicación nos mostró cómo usar estas construcciones con Hasura/Postgres. En esta publicación nos hemos centrado en hacer esto usando plpgsql. ¡Haremos una publicación de seguimiento que muestra cómo hacer esto con Acciones para que pueda usar el idioma de su elección en el backend!
¿Te gustó este artículo? ¡Únase a nosotros en Discord para más debates sobre Hasura y GraphQL!
Suscríbete a nuestra newsletter para saber cuándo publicamos nuevos artículos.