CouchDB
 sql >> Teknologi Basis Data >  >> NoSQL >> CouchDB

Sinkronisasi gaya CouchDB dan resolusi konflik di Postgres dengan Hasura

Kami telah berbicara tentang offline-pertama dengan Hasura dan RxDB (pada dasarnya Postgres dan PouchDB di bawahnya).

Posting ini terus menyelam lebih dalam ke topik. Ini adalah diskusi dan panduan untuk mengimplementasikan resolusi konflik gaya CouchDB dengan Postgres (database backend pusat) dan PouchDB (aplikasi frontend pengguna basis data).

Inilah yang akan kita bicarakan:

  • Apa itu resolusi konflik?
  • Apakah aplikasi saya memerlukan resolusi konflik?
  • Penjelasan resolusi konflik dengan PouchDB
  • Membawa replikasi dan manajemen konflik yang mudah ke pouchdb (frontend) dan Postgres (backend) dengan RxDB dan Hasura
    • Menyiapkan Hasura
    • Penyiapan sisi klien
    • Menerapkan resolusi konflik
    • Menggunakan tampilan
    • Menggunakan pemicu postgres
  • Strategi Penyelesaian Konflik Khusus dengan Hasura
    • Penyelesaian konflik khusus di server
    • Penyelesaian konflik khusus pada klien
  • Kesimpulan

Apa itu resolusi konflik?

Mari kita ambil papan Trello sebagai contoh. Katakanlah Anda telah mengubah penerima hak pada kartu Trello saat offline. Sementara itu rekan Anda mengedit deskripsi kartu yang sama. Ketika Anda kembali online, Anda ingin melihat kedua perubahan tersebut. Sekarang anggaplah Anda berdua mengubah deskripsi pada saat yang sama, apa yang harus terjadi dalam kasus ini? Salah satu opsi adalah dengan hanya mengambil penulisan terakhir - yang menimpa perubahan sebelumnya dengan yang baru. Cara lainnya adalah memberi tahu pengguna dan membiarkan mereka memperbarui kartu dengan bidang gabungan (seperti git!).

Aspek mengambil beberapa perubahan simultan (yang mungkin bertentangan), dan menggabungkannya menjadi satu perubahan disebut resolusi konflik.

Aplikasi seperti apa yang dapat Anda buat setelah Anda memiliki kemampuan replikasi dan resolusi konflik yang baik?

Replikasi dan infrastruktur resolusi konflik menyakitkan untuk dibangun ke frontend dan backend aplikasi. Tetapi setelah disiapkan, beberapa kasus penggunaan penting menjadi layak! Faktanya, untuk jenis aplikasi tertentu replikasi (dan karenanya resolusi konflik) sangat penting untuk fungsionalitas aplikasi!

  1. Realtime:Perubahan yang dibuat oleh pengguna di perangkat yang berbeda disinkronkan satu sama lain
  2. Kolaboratif:Pengguna yang berbeda secara bersamaan mengerjakan data yang sama
  3. Offline-first:Pengguna yang sama dapat bekerja dengan data mereka meskipun aplikasi tidak terhubung ke database pusat

Contoh:Trello, klien Email seperti Gmail, Superhuman, Google docs, Facebook, Twitter, dll.

Hasura membuatnya sangat mudah untuk menambahkan kemampuan kinerja tinggi, aman, waktu nyata ke aplikasi berbasis Postgres Anda yang sudah ada. Tidak perlu menerapkan infrastruktur backend tambahan untuk mendukung kasus penggunaan ini! Dalam beberapa bagian berikutnya, kita akan mempelajari bagaimana Anda dapat menggunakan PouchDB/RxDB di frontend dan memasangkannya dengan Hasura untuk membangun aplikasi yang kuat dengan pengalaman pengguna yang luar biasa.

Penyelesaian konflik dengan PouchDB dijelaskan

Manajemen versi dengan PouchDB

PouchDB - yang digunakan RxDB di bawahnya - hadir dengan versi yang kuat dan mekanisme manajemen konflik. Setiap dokumen di PouchDB memiliki bidang versi yang terkait dengannya. Bidang versi dalam bentuk <depth>-<object-hash> misalnya 2-c1592ce7b31cc26e91d2f2029c57e621 . Di sini kedalaman menunjukkan kedalaman di pohon revisi. Hash objek adalah string yang dibuat secara acak.

Sekilas tentang revisi PouchDB

PouchDB mengekspos API untuk mengambil riwayat revisi dokumen. Kami dapat menanyakan riwayat revisi dengan cara ini:

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

Ini akan mengembalikan dokumen yang berisi _revisions bidang:

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

Di sini ids berisi hierarki revisi revisi (termasuk saat ini) dan start berisi "nomor awalan" untuk revisi saat ini. Setiap kali revisi baru ditambahkan start bertambah dan hash baru ditambahkan ke awal ids larik.

Saat dokumen disinkronkan ke server jauh, _revisions dan _rev bidang harus disertakan. Dengan cara ini semua klien akhirnya memiliki riwayat versi lengkap. Ini terjadi secara otomatis ketika PouchDB diatur untuk disinkronkan dengan CouchDB. Permintaan tarik di atas juga memungkinkan ini saat menyinkronkan melalui GraphQL.

Perhatikan bahwa semua klien tidak harus memiliki semua revisi, tetapi semuanya pada akhirnya akan memiliki versi terbaru dan riwayat id revisi untuk versi ini.

Penyelesaian konflik

Konflik akan terdeteksi jika dua revisi memiliki induk yang sama atau lebih sederhana jika dua revisi memiliki kedalaman yang sama. Saat konflik terdeteksi, CouchDB &PouchDB akan menggunakan algoritme yang sama untuk memilih pemenang secara otomatis:

  1. Pilih revisi dengan bidang kedalaman tertinggi yang tidak ditandai sebagai dihapus
  2. Jika hanya ada 1 bidang seperti itu, perlakukan sebagai pemenang
  3. Jika ada lebih dari 1, urutkan bidang revisi dalam urutan menurun dan pilih yang pertama.

Catatan tentang penghapusan: PouchDB &CouchDB tidak pernah menghapus revisi atau dokumen sebagai gantinya revisi baru dibuat dengan tanda _deleted disetel ke true. Jadi pada langkah 1 dari algoritme di atas, setiap rantai yang diakhiri dengan revisi yang ditandai sebagai dihapus akan diabaikan.

Salah satu fitur bagus dari algoritma ini adalah tidak ada koordinasi yang diperlukan antara klien atau klien dan server untuk menyelesaikan konflik. Tidak ada penanda tambahan yang diperlukan untuk menandai versi sebagai pemenang. Setiap klien dan server secara independen memilih pemenang. Namun pemenangnya akan revisi yang sama karena menggunakan algoritma deterministik yang sama. Bahkan jika salah satu klien memiliki beberapa revisi yang hilang, akhirnya ketika revisi tersebut disinkronkan, revisi yang sama akan dipilih sebagai pemenang.

Menerapkan strategi resolusi konflik khusus

Tetapi bagaimana jika kita menginginkan strategi penyelesaian konflik alternatif? Misalnya "gabungkan menurut bidang" - Jika dua revisi yang bertentangan telah memodifikasi kunci objek yang berbeda, kami ingin menggabungkan secara otomatis dengan membuat revisi dengan kedua kunci tersebut. Cara yang disarankan untuk melakukan ini di PouchDB adalah dengan:

  1. Buat revisi baru ini di salah satu rantai
  2. Tambahkan revisi dengan _deleted disetel ke true untuk setiap rantai lainnya

Revisi gabungan sekarang akan secara otomatis menjadi revisi pemenang sesuai dengan algoritma di atas. Kita bisa melakukan custom resolution baik di server maupun di client. Saat revisi disinkronkan, semua klien dan server akan melihat revisi gabungan sebagai revisi pemenang.

Resolusi Konflik dengan Hasura dan RxDB

Untuk menerapkan strategi resolusi konflik di atas, kita memerlukan Hasura untuk juga menyimpan riwayat revisi dan RxDB untuk menyinkronkan revisi saat mereplikasi menggunakan GraphQL.

Menyiapkan Hasura

Melanjutkan contoh aplikasi Todo dari postingan sebelumnya. Kami harus memperbarui skema untuk tabel Todos sebagai berikut:

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

Perhatikan bidang tambahan:

  • _rev mewakili revisi catatan.
  • _parent_rev mewakili revisi induk dari catatan
  • _depth adalah kedalaman catatan di pohon revisi
  • _revisions berisi riwayat lengkap revisi catatan.

Kunci utama untuk tabel adalah (ids , _rev ).

Sebenarnya kita hanya membutuhkan _revisions lapangan karena informasi lain dapat diturunkan darinya. Tetapi dengan menyediakan bidang lain yang tersedia membuat deteksi &penyelesaian konflik menjadi lebih mudah.

Penyiapan sisi klien

Kita perlu mengatur syncRevisions ke true saat menyiapkan replikasi


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

       ...
    }
 

Kita juga perlu menambahkan bidang teks last_pulled_rev untuk skema RxDB. Bidang ini digunakan secara internal oleh plugin untuk menghindari mendorong revisi yang diambil dari server kembali ke server.

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

Terakhir, kita perlu mengubah pembuat kueri tarik &dorong untuk menyinkronkan informasi terkait revisi

Tarik Pembuat Kueri

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

Kami sekarang mengambil bidang _rev &_revisions. Plugin yang ditingkatkan akan menggunakan bidang ini untuk membuat revisi PouchDB lokal.

Pembuat Kueri Dorong


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

Dengan plugin yang ditingkatkan, parameter input doc sekarang berisi _rev dan _revisions bidang. Kami meneruskan ke Hasura dalam kueri GraphQL. Kami menambahkan bidang _depth , _parent_rev ke doc sebelum melakukannya.

Sebelumnya kami menggunakan upsert untuk menyisipkan atau memperbarui todo rekam di Hasura. Sekarang karena setiap versi akhirnya menjadi catatan baru, kami menggunakan mutasi sisipan biasa saja.

Menerapkan resolusi konflik

Jika dua klien yang berbeda sekarang membuat perubahan yang bertentangan maka kedua revisi akan disinkronkan dan ada di Hasura. Kedua klien juga pada akhirnya akan menerima revisi lainnya. Karena strategi resolusi konflik PouchDB bersifat deterministik, kedua klien kemudian akan memilih versi yang sama sebagai "revisi yang menang".

Bagaimana kami dapat menemukan revisi pemenang ini di server? Kita harus mengimplementasikan algoritma yang sama dalam SQL.

Menerapkan algoritma resolusi konflik CouchDB di Postgres

Langkah 1:Menemukan simpul daun yang tidak ditandai sebagai dihapus

Untuk melakukan ini, kita perlu mengabaikan versi apa pun yang memiliki revisi anak dan versi apa pun yang ditandai sebagai dihapus:

    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
 

Langkah 2:Menemukan rantai dengan kedalaman maksimum

Dengan asumsi kami memiliki hasil dari kueri di atas dalam tabel (atau tampilan atau dengan klausa) yang disebut daun, kami dapat menemukan rantai dengan kedalaman maksimum lurus ke depan:

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

Langkah 3:Menemukan revisi yang menang di antara revisi dengan kedalaman maksimum yang sama

Sekali lagi dengan asumsi hasil dari kueri di atas ada dalam tabel (atau tampilan atau dengan klausa) yang disebut max_depths, kita dapat menemukan revisi yang menang sebagai berikut:

    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
 

Membuat tampilan dengan revisi pemenang

Dengan menggabungkan ketiga kueri di atas, kita dapat membuat tampilan yang menunjukkan kepada kita revisi yang menang sebagai berikut:

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;

 

Karena Hasura dapat melacak tampilan dan mengizinkan kuerinya melalui GraphQL, revisi yang menang sekarang dapat ditampilkan ke klien dan layanan lain.

Setiap kali Anda menanyakan tampilan, Postgres hanya akan mengganti tampilan dengan kueri dalam definisi tampilan dan menjalankan kueri yang dihasilkan. Jika Anda sering menanyakan tampilan, ini mungkin berakhir dengan banyak siklus CPU yang terbuang. Kami dapat mengoptimalkan ini dengan menggunakan pemicu Postgres dan menyimpan revisi yang menang di tabel yang berbeda.

Menggunakan pemicu Postgres untuk menghitung revisi yang menang

Langkah 1:Buat tabel baru todos_current_revisions

Skemanya akan sama dengan skema todos meja. Akan tetapi, kunci utama adalah ids kolom bukannya (id, _rev)

Langkah 2:Buat pemicu Postgres

Kita dapat menulis kueri untuk pemicu dengan memulai dengan kueri tampilan. Karena fungsi pemicu akan berjalan untuk satu baris pada satu waktu, kami dapat menyederhanakan kueri:

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

 

Itu dia! Kami sekarang dapat menanyakan versi pemenang baik di server maupun di klien.

Penyelesaian konflik khusus

Sekarang mari kita lihat penerapan resolusi konflik khusus dengan Hasura &RxDB.

Penyelesaian konflik khusus di sisi server

Katakanlah kita ingin menggabungkan todos dengan field. Bagaimana cara kita melakukan ini? Intisari di bawah ini menunjukkan kepada kita ini:

SQL itu terlihat sangat banyak tetapi satu-satunya bagian yang berhubungan dengan strategi penggabungan yang sebenarnya adalah ini:

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

Di sini kami mendeklarasikan fungsi agregat Postgres kustom agg_merge_revisions untuk menggabungkan elemen. Cara kerjanya mirip dengan fungsi 'reduce':Postgres akan menginisialisasi nilai agregat ke '{}' , lalu jalankan merge_revisions berfungsi dengan agregat saat ini dan elemen berikutnya yang akan digabungkan. Jadi jika kita memiliki 3 versi yang bertentangan untuk digabungkan, hasilnya adalah:

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

Jika kita ingin menerapkan strategi lain, kita perlu mengubah merge_revisions fungsi. Misalnya, jika kita ingin menerapkan strategi 'tulisan terakhir menang':

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;

 

Kueri penyisipan dalam intisari di atas dapat dijalankan dalam pemicu penyisipan pos untuk menggabungkan konflik secara otomatis setiap kali terjadi.

Catatan: Di atas kami telah menggunakan SQL untuk mengimplementasikan resolusi konflik khusus. Pendekatan alternatif adalah dengan menggunakan tindakan menulis:

  1. Buat mutasi khusus untuk menangani sisipan alih-alih mutasi sisipan yang dibuat secara otomatis.
  2. Dalam pengendali tindakan, buat revisi baru dari catatan. Kita dapat menggunakan mutasi insert Hasura untuk ini.
  3. Ambil semua revisi untuk objek menggunakan kueri daftar
  4. Deteksi konflik apa pun dengan melintasi pohon revisi.
  5. Tulis kembali versi gabungan.

Pendekatan ini akan menarik bagi Anda jika Anda lebih suka menulis logika ini dalam bahasa selain SQL. Pendekatan lain adalah membuat tampilan SQL untuk menunjukkan revisi yang bertentangan dan menerapkan logika yang tersisa di pengendali tindakan. Ini akan menyederhanakan langkah 4. di atas karena sekarang kita dapat dengan mudah menanyakan tampilan untuk mendeteksi konflik.

Penyelesaian konflik khusus di sisi klien

Ada skenario di mana Anda memerlukan intervensi pengguna untuk dapat menyelesaikan konflik. Misalnya, jika kami sedang membangun sesuatu seperti aplikasi Trello dan dua pengguna memodifikasi deskripsi tugas yang sama, Anda mungkin ingin menunjukkan kepada pengguna kedua versi dan membiarkan mereka membuat versi gabungan. Dalam skenario ini, kita perlu menyelesaikan konflik di sisi klien.

Penyelesaian konflik sisi klien lebih mudah diterapkan karena PouchDB telah mengekspos API ke kueri revisi yang bertentangan. Jika kita melihat todos Koleksi RxDB dari posting sebelumnya, berikut adalah bagaimana kami dapat mengambil versi yang bertentangan:

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

Kueri di atas akan mengisi revisi yang bertentangan di _conflicts lapangan dalam hasil. Kami kemudian dapat menyajikannya kepada pengguna untuk resolusi.

Kesimpulan

PouchDB hadir dengan konstruksi yang fleksibel dan kuat untuk versi dan solusi manajemen konflik. Posting ini menunjukkan kepada kita bagaimana menggunakan konstruksi ini dengan Hasura/Postgres. Dalam posting ini kami telah fokus melakukan ini menggunakan plpgsql. Kami akan melakukan posting lanjutan yang menunjukkan bagaimana melakukan ini dengan Actions sehingga Anda dapat menggunakan bahasa pilihan Anda di backend!

Menikmati artikel ini? Bergabunglah dengan kami di Discord untuk diskusi lebih lanjut tentang Hasura &GraphQL!

Daftar ke buletin kami untuk mengetahui kapan kami menerbitkan artikel baru.


  1. Redis
  2.   
  3. MongoDB
  4.   
  5. Memcached
  6.   
  7. HBase
  8.   
  9. CouchDB
  1. Script Berguna Untuk Couchbase Dba

  2. Cara Menginstal Apache CouchDB di CentOS 8

  3. Cara Menginstal Apache CouchDB 2.3.0 di Linux

  4. Replikasi Couchbase XDCR – Langkah demi Langkah – Praktik Terbaik

  5. Cara Menginstal CouchDB di Debian 10