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!
- Realtime:Perubahan yang dibuat oleh pengguna di perangkat yang berbeda disinkronkan satu sama lain
- Kolaboratif:Pengguna yang berbeda secara bersamaan mengerjakan data yang sama
- 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:
- Pilih revisi dengan bidang kedalaman tertinggi yang tidak ditandai sebagai dihapus
- Jika hanya ada 1 bidang seperti itu, perlakukan sebagai pemenang
- 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:
- Buat revisi baru ini di salah satu rantai
- 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:
- Buat mutasi khusus untuk menangani sisipan alih-alih mutasi sisipan yang dibuat secara otomatis.
- Dalam pengendali tindakan, buat revisi baru dari catatan. Kita dapat menggunakan mutasi insert Hasura untuk ini.
- Ambil semua revisi untuk objek menggunakan kueri daftar
- Deteksi konflik apa pun dengan melintasi pohon revisi.
- 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.