sql >> Base de Datos >  >> NoSQL >> CouchDB

Sincronización estilo CouchDB y resolución de conflictos en Postgres con Hasura

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.

  1. En tiempo real:los cambios realizados por los usuarios en diferentes dispositivos se sincronizan entre sí
  2. Colaborativo:diferentes usuarios trabajan simultáneamente en los mismos datos
  3. 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:

  1. Seleccione las revisiones con el campo de mayor profundidad que no estén marcadas como eliminadas
  2. Si solo hay 1 de estos campos, trátelo como el ganador
  3. 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:

  1. Cree esta nueva revisión en cualquiera de las cadenas
  2. 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.

Nota: Arriba, hemos usado SQL para implementar una resolución de conflictos personalizada. Un enfoque alternativo es usar escribir una Acción:

  1. Cree una mutación personalizada para gestionar la inserción en lugar de la mutación de inserción predeterminada generada automáticamente.
  2. En el controlador de acciones, cree la nueva revisión del registro. Podemos usar la mutación de inserción de Hasura para esto.
  3. Obtener todas las revisiones del objeto mediante una consulta de lista
  4. Detecte cualquier conflicto recorriendo el árbol de revisión.
  5. 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.