CouchDB
 sql >> Database >  >> NoSQL >> CouchDB

Sincronizzazione in stile CouchDB e risoluzione dei conflitti su Postgres con Hasura

Abbiamo parlato di offline-first con Hasura e RxDB (essenzialmente Postgres e PouchDB sotto).

Questo post continua ad approfondire l'argomento. È una discussione e una guida per implementare la risoluzione dei conflitti in stile CouchDB con Postgres (database backend centrale) e PouchDB (app frontend utente banca dati).

Ecco di cosa parleremo:

  • Cos'è la risoluzione dei conflitti?
  • La mia app ha bisogno di una risoluzione dei conflitti?
  • Spiegazione della risoluzione dei conflitti con PouchDB
  • Portare una facile replica e gestione dei conflitti a pouchdb (frontend) e Postgres (backend) con RxDB e Hasura
    • Configurazione di Hasura
    • Configurazione lato client
    • Attuazione della risoluzione dei conflitti
    • Utilizzo delle visualizzazioni
    • Utilizzo dei trigger postgres
  • Strategie personalizzate di risoluzione dei conflitti con Hasura
    • Risoluzione dei conflitti personalizzata sul server
    • Risoluzione dei conflitti personalizzata sul client
  • Conclusione

Che cos'è la risoluzione dei conflitti?

Prendiamo come esempio una scheda Trello. Supponiamo che tu abbia cambiato l'assegnatario su una carta Trello mentre sei offline. Nel frattempo il tuo collega modifica la descrizione della stessa carta. Quando torni online, vorresti vedere entrambe le modifiche. Ora supponiamo che entrambi abbiate cambiato la descrizione contemporaneamente, cosa dovrebbe succedere in questo caso? Un'opzione è semplicemente prendere l'ultima scrittura, ovvero sostituire la modifica precedente con quella nuova. Un altro è avvisare l'utente e lasciare che aggiorni la carta con un campo unito (come git!).

Questo aspetto di prendere più modifiche simultanee (che potrebbero essere in conflitto) e unirle in un'unica modifica è chiamato risoluzione dei conflitti.

Che tipo di app puoi creare una volta che hai buone capacità di replica e risoluzione dei conflitti?

L'infrastruttura di replica e risoluzione dei conflitti è difficile da integrare nel front-end e nel back-end di un'applicazione. Ma una volta impostato, alcuni casi d'uso importanti diventano praticabili! In effetti, per alcuni tipi di applicazioni la replica (e quindi la risoluzione dei conflitti) è fondamentale per la funzionalità dell'app!

  1. In tempo reale:le modifiche apportate dagli utenti su dispositivi diversi vengono sincronizzate tra loro
  2. Collaborativo:utenti diversi lavorano contemporaneamente sugli stessi dati
  3. Prima offline:lo stesso utente può lavorare con i propri dati anche quando l'app non è connessa al database centrale

Esempi:Trello, client di posta elettronica come Gmail, Superhuman, Google docs, Facebook, Twitter ecc.

Hasura rende semplicissimo aggiungere funzionalità ad alte prestazioni, sicure e in tempo reale alla tua applicazione basata su Postgres esistente. Non è necessario implementare un'infrastruttura di back-end aggiuntiva per supportare questi casi d'uso! Nelle prossime sezioni impareremo come utilizzare PouchDB/RxDB sul frontend e accoppiarlo con Hasura per creare app potenti con un'esperienza utente eccezionale.

Spiegazione della risoluzione dei conflitti con PouchDB

Gestione delle versioni con PouchDB

PouchDB - che RxDB usa sotto - viene fornito con un potente meccanismo di controllo delle versioni e gestione dei conflitti. Ogni documento in PouchDB ha un campo versione ad esso associato. I campi della versione sono nel formato <depth>-<object-hash> ad esempio 2-c1592ce7b31cc26e91d2f2029c57e621 . Qui la profondità indica la profondità nell'albero delle revisioni. L'hash dell'oggetto è una stringa generata casualmente.

Un'anteprima delle revisioni di PouchDB

PouchDB espone le API per recuperare la cronologia delle revisioni di un documento. Possiamo interrogare la cronologia delle revisioni in questo modo:

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

Questo restituirà un documento contenente un _revisions campo:

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

Qui ids contiene la gerarchia delle revisioni delle revisioni (inclusa quella corrente) e start contiene il "numero di prefisso" per la revisione corrente. Ogni volta che viene aggiunta una nuova revisione start viene incrementato e viene aggiunto un nuovo hash all'inizio degli ids matrice.

Quando un documento viene sincronizzato con un server remoto, _revisions e _rev i campi devono essere inclusi. In questo modo tutti i client alla fine hanno la cronologia completa delle versioni. Ciò accade automaticamente quando PouchDB è impostato per la sincronizzazione con CouchDB. La richiesta pull sopra lo abilita anche durante la sincronizzazione tramite GraphQL.

Tieni presente che tutti i client non hanno necessariamente tutte le revisioni, ma alla fine tutti avranno le ultime versioni e la cronologia degli ID di revisione per queste versioni.

Risoluzione dei conflitti

Verrà rilevato un conflitto se due revisioni hanno lo stesso genitore o più semplicemente se due revisioni qualsiasi hanno la stessa profondità. Quando viene rilevato un conflitto, CouchDB e PouchDB utilizzeranno lo stesso algoritmo per selezionare automaticamente un vincitore:

  1. Seleziona le revisioni con il campo di profondità più alto che non sono contrassegnate come eliminate
  2. Se c'è solo 1 di questi campi, consideralo come il vincitore
  3. Se ce ne sono più di 1, ordina i campi di revisione in ordine decrescente e scegli il primo.

Una nota sull'eliminazione: PouchDB e CouchDB non eliminano mai revisioni o documenti, invece viene creata una nuova revisione con un flag _deleted impostato su true. Quindi, nel passaggio 1 dell'algoritmo sopra, tutte le catene che terminano con una revisione contrassegnata come eliminata vengono ignorate.

Una caratteristica interessante di questo algoritmo è che non è richiesto alcun coordinamento tra i client o il client e il server per risolvere un conflitto. Non è richiesto alcun marcatore aggiuntivo per contrassegnare una versione come vincente. Ogni client e il server scelgono indipendentemente il vincitore. Ma il vincitore sarà la stessa revisione perché usano lo stesso algoritmo deterministico. Anche se a uno dei client mancano alcune revisioni, alla fine, quando tali revisioni vengono sincronizzate, la stessa revisione viene scelta come vincitrice.

Implementazione di strategie personalizzate di risoluzione dei conflitti

Ma cosa succede se vogliamo una strategia alternativa di risoluzione dei conflitti? Ad esempio "unisci per campi" - Se due revisioni in conflitto hanno modificato chiavi diverse dell'oggetto, vogliamo unire automaticamente creando una revisione con entrambe le chiavi. Il modo consigliato per farlo in PouchDB è:

  1. Crea questa nuova revisione su qualsiasi catena
  2. Aggiungi una revisione con _deleted impostato su true a ciascuna delle altre catene

La revisione unita ora sarà automaticamente la revisione vincente secondo l'algoritmo di cui sopra. Possiamo eseguire risoluzioni personalizzate sia sul server che sul client. Quando le revisioni verranno sincronizzate, tutti i client e il server vedranno la revisione unita come la revisione vincente.

Risoluzione dei conflitti con Hasura e RxDB

Per implementare la strategia di risoluzione dei conflitti di cui sopra, avremo bisogno che Hasura memorizzi anche la cronologia delle revisioni e che RxDB sincronizzi le revisioni durante la replica utilizzando GraphQL.

Configurazione di Hasura

Continuando con l'esempio dell'app Todo del post precedente. Dovremo aggiornare lo schema per la tabella Todos come segue:

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

Nota i campi aggiuntivi:

  • _rev rappresenta la revisione del record.
  • _parent_rev rappresenta la revisione principale del record
  • _depth è la profondità del record nell'albero delle revisioni
  • _revisions contiene la cronologia completa delle revisioni del record.

La chiave primaria per la tabella è (id , _rev ).

A rigor di termini abbiamo solo bisogno delle _revisions campo poiché le altre informazioni possono essere derivate da esso. Ma avere gli altri campi prontamente disponibili facilita il rilevamento e la risoluzione dei conflitti.

Configurazione lato client

Dobbiamo impostare syncRevisions su true durante l'impostazione della replica


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

       ...
    }

Dobbiamo anche aggiungere un campo di testo last_pulled_rev allo schema RxDB. Questo campo viene utilizzato internamente dal plug-in per evitare di inviare le revisioni recuperate dal server al server.

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

Infine, dobbiamo modificare i generatori di query pull &push per sincronizzare le informazioni relative alla revisione

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

Ora recuperiamo i campi _rev e _revisions. Il plug-in aggiornato utilizzerà questi campi per creare revisioni PouchDB locali.

Costruttore di query 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 il plugin aggiornato, il parametro di input doc ora contiene _rev e _revisions campi. Passiamo ad Hasura nella query GraphQL. Aggiungiamo i campi _depth , _parent_rev a doc prima di farlo.

In precedenza stavamo usando un upsert per inserire o aggiornare una todo registrare su Hasura. Ora, poiché ogni versione finisce per essere un nuovo record, utilizziamo invece la semplice vecchia mutazione dell'inserto.

Implementazione della risoluzione dei conflitti

Se ora due client diversi apportano modifiche in conflitto, entrambe le revisioni verranno sincronizzate e presenti in Hasura. Entrambi i client riceveranno eventualmente anche l'altra revisione. Poiché la strategia di risoluzione dei conflitti di PouchDB è deterministica, entrambi i client sceglieranno la stessa versione della "revisione vincente".

Come possiamo trovare questa revisione vincente sul server? Dovremo implementare lo stesso algoritmo in SQL.

Implementazione dell'algoritmo di risoluzione dei conflitti di CouchDB su Postgres

Passaggio 1:ricerca di nodi foglia non contrassegnati come eliminati

Per fare ciò dobbiamo ignorare tutte le versioni che hanno una revisione figlio e tutte le versioni contrassegnate come eliminate:

    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

Passaggio 2:trovare la catena con la profondità massima

Supponendo di avere i risultati della query precedente in una tabella (o vista o una clausola with) chiamata foglie, possiamo trovare che la catena con la massima profondità è semplice:

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

Passaggio 3:trovare le revisioni vincenti tra le revisioni con uguale profondità massima

Sempre supponendo che i risultati della query precedente siano in una tabella (o una vista o una clausola with) chiamata max_depths possiamo trovare la revisione vincente come segue:

    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

Creazione di una vista con revisioni vincenti

Mettendo insieme le tre query precedenti possiamo creare una vista che ci mostra le revisioni vincenti come segue:

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;

Poiché Hasura può tenere traccia delle visualizzazioni e consente di interrogarle tramite GraphQL, le revisioni vincenti possono ora essere esposte ad altri client e servizi.

Ogni volta che esegui una query sulla vista, Postgres sostituirà semplicemente la vista con la query nella definizione della vista ed eseguirà la query risultante. Se interroghi frequentemente la vista, ciò potrebbe portare a molti cicli di CPU sprecati. Possiamo ottimizzarlo utilizzando i trigger di Postgres e archiviando le revisioni vincenti in una tabella diversa.

Utilizzo dei trigger di Postgres per calcolare le revisioni vincenti

Passaggio 1:crea una nuova tabella todos_current_revisions

Lo schema sarà lo stesso di todos tavolo. La chiave primaria sarà, tuttavia, l'id colonna invece di (id, _rev)

Passaggio 2:crea l'attivatore Postgres

Possiamo scrivere la query per il trigger iniziando con la query di visualizzazione. Poiché la funzione trigger verrà eseguita per una riga alla volta, possiamo semplificare la query:

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

Questo è tutto! Ora possiamo interrogare le versioni vincenti sia sul server che sul client.

Risoluzione dei conflitti personalizzata

Ora esaminiamo l'implementazione della risoluzione dei conflitti personalizzata con Hasura e RxDB.

Risoluzione dei conflitti personalizzata lato server

Diciamo che vogliamo unire le cose da fare per campi. Come facciamo a farlo? Il succo qui sotto ci mostra questo:

Quell'SQL sembra molto, ma l'unica parte che si occupa dell'effettiva strategia di unione è questa:

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

Qui dichiariamo una funzione aggregata Postgres personalizzata agg_merge_revisions per unire elementi. Il modo in cui funziona è simile a una funzione 'riduci':Postgres inizializzerà il valore aggregato su '{}' , quindi esegui merge_revisions funzione con l'aggregato corrente e l'elemento successivo da unire. Quindi se avessimo 3 versioni in conflitto da unire il risultato sarebbe:

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

Se vogliamo implementare un'altra strategia dovremo cambiare il merge_revisions funzione. Ad esempio, se vogliamo implementare la strategia "vince l'ultima scrittura":

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 query di inserimento nel gist sopra può essere eseguita in un trigger post inserimento per unire automaticamente i conflitti ogni volta che si verificano.

Nota: Sopra abbiamo usato SQL per implementare la risoluzione dei conflitti personalizzata. Un approccio alternativo consiste nell'utilizzare un'azione di scrittura:

  1. Crea una mutazione personalizzata per gestire l'inserto invece della mutazione dell'inserto generata automaticamente di default.
  2. Nel gestore delle azioni crea la nuova revisione del record. Possiamo usare la mutazione dell'inserto Hasura per questo.
  3. Recupera tutte le revisioni per l'oggetto utilizzando una query elenco
  4. Rileva eventuali conflitti attraversando l'albero delle revisioni.
  5. Riscrivi la versione unita.

Questo approccio ti piacerà se preferisci scrivere questa logica in un linguaggio diverso da SQL. Un altro approccio consiste nel creare una vista SQL per mostrare le revisioni in conflitto e implementare la logica rimanente nel gestore dell'azione. Questo semplificherà il passaggio 4. sopra dato che ora possiamo semplicemente interrogare la vista per rilevare i conflitti.

Risoluzione dei conflitti personalizzata lato client

Esistono scenari in cui è necessario l'intervento dell'utente per poter risolvere un conflitto. Ad esempio, se stavamo creando qualcosa come l'app Trello e due utenti hanno modificato la descrizione della stessa attività, potresti voler mostrare all'utente entrambe le versioni e consentire loro di creare una versione unita. In questi scenari dovremo risolvere il conflitto lato client.

La risoluzione dei conflitti lato client è più semplice da implementare perché PouchDB espone già le API per interrogare revisioni in conflitto. Se guardiamo le todos Raccolta RxDB dal post precedente, ecco come possiamo recuperare le versioni in conflitto:

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

La query precedente popolerebbe le revisioni in conflitto in _conflicts campo nel risultato. Possiamo quindi presentarli all'utente per la risoluzione.

Conclusione

PouchDB viene fornito con un costrutto flessibile e potente per il controllo delle versioni e la soluzione di gestione dei conflitti. Questo post ci ha mostrato come usare questi costrutti con Hasura/Postgres. In questo post ci siamo concentrati su come farlo usando plpgsql. Faremo un post di follow-up che mostrerà come farlo con Actions in modo che tu possa usare la lingua di tua scelta sul back-end!

Ti è piaciuto questo articolo? Unisciti a noi su Discord per ulteriori discussioni su Hasura e GraphQL!

Iscriviti alla nostra newsletter per sapere quando pubblichiamo nuovi articoli.