Tantangan Validasi Sync Engine Local-First pada CI
Arsitektur local-first mengandalkan Conflict-free Replicated Data Types (CRDT) atau log berbasis event untuk menjaga konsistensi data antar-klien tanpa koordinasi pusat yang ketat. Masalah utama muncul saat validasi: pengujian manual atau pengujian unit standar sering gagal mendeteksi inkonsistensi yang dipicu oleh network partition, latensi tinggi, dan urutan pesan yang teracak (reordered packets).
Menjalankan staging cluster terdistribusi di CI/CD membutuhkan biaya besar, kompleks dikelola, dan rentan menghasilkan flaky tests akibat fluktuasi jaringan riil. Solusinya adalah membangun in-memory network simulator deterministik langsung di dalam test runner standar (seperti node:test bawaan Node.js atau Bun) untuk mengontrol transmisi data antar-node secara presisi.
Prinsip Kerja Network Harness In-Memory
Alih-alih menguji via protokol TCP/WebSocket nyata di CI, harness membungkus transport layer aplikasi ke dalam bus simulasi virtual. Pola ini memungkinkan kita menguji tiga skenario kritis:
- Network Partition: Mengisolasi node A dan B sehingga mutasi tersimpan lokal di offline buffer tanpa saling broadcast.
- Arbitrary Latency & Drop: Menunda atau membuang frame replikasi untuk menyimulasikan koneksi seluler buruk.
- Healing & Reconnection: Memulihkan rute komunikasi dan mengalirkan akumulasi perubahan terbuffer secara deterministik.
Tujuan akhir dari setiap skenario pengujian adalah memvalidasi Strong Eventual Consistency (SEC): jika semua node menerima set pembaruan yang sama, seluruh node harus berada pada state akhir yang identik, terlepas dari urutan pengiriman pesan aslinya.
Implementasi Test Harness Menggunakan Node.js stdlib
Gunakan script mandiri berikut dengan node:test dan node:assert/strict. Implementasi ini mencakup CRDT register sederhana tipe Last-Write-Wins (LWW-Register) dan router simulasi jaringan.
import { test } from 'node:test';
import assert from 'node:assert/strict';
// 1. Primitive CRDT: Last-Write-Wins Register (LWW-Register)
class LWWRegister {
constructor(nodeId, initialValue = null, initialTimestamp = 0) {
this.nodeId = nodeId;
this.value = initialValue;
this.timestamp = initialTimestamp;
}
set(value, timestamp = Date.now()) {
this.value = value;
this.timestamp = timestamp;
return { value: this.value, timestamp: this.timestamp, writer: this.nodeId };
}
merge(update) {
// Resolusi deterministik: timestamp tertinggi menang.
// Jika timestamp sama, gunakan lexicographical sorting pada nodeId sebagai tie-breaker.
if (
update.timestamp > this.timestamp ||
(update.timestamp === this.timestamp && update.writer > this.nodeId)
) {
this.value = update.value;
this.timestamp = update.timestamp;
return true;
}
return false;
}
}
// 2. Simulated Network Hub
class VirtualNetwork {
constructor() {
this.nodes = new Map();
this.partitions = new Set(); // Format string: "nodeA:nodeB"
this.buffers = new Map(); // Offline outbox queue
}
register(nodeId, crdtInstance) {
this.nodes.set(nodeId, crdtInstance);
this.buffers.set(nodeId, []);
}
isolate(nodeA, nodeB) {
this.partitions.add(`${nodeA}:${nodeB}`);
this.partitions.add(`${nodeB}:${nodeA}`);
}
heal(nodeA, nodeB) {
this.partitions.delete(`${nodeA}:${nodeB}`);
this.partitions.delete(`${nodeB}:${nodeA}`);
}
broadcast(senderId, payload) {
for (const [recipientId, node] of this.nodes.entries()) {
if (recipientId === senderId) continue;
const isPartitioned = this.partitions.has(`${senderId}:${recipientId}`);
if (isPartitioned) {
// Simpan ke offline buffer lokal node pengirim
this.buffers.get(senderId).push({ recipientId, payload });
} else {
node.merge(payload);
}
}
}
flushBuffer(senderId) {
const queue = this.buffers.get(senderId);
while (queue.length > 0) {
const { recipientId, payload } = queue.shift();
const isPartitioned = this.partitions.has(`${senderId}:${recipientId}`);
if (!isPartitioned) {
this.nodes.get(recipientId).merge(payload);
} else {
// Kembalikan ke antrean jika masih terisolasi
queue.unshift({ recipientId, payload });
break;
}
}
}
}
Menulis Assertion untuk Simulasi Network Partition
Kode uji berikut memverifikasi perilaku dua node klien saat partisi jaringan terjadi, menerima update konkuren secara offline, reconnect kembali, dan mencapai konvergensi data.
test('Validasi konvergensi CRDT pasca network partition dan concurrent offline writes', () => {
const net = new VirtualNetwork();
const clientA = new LWWRegister('node-A', 'draft-0', 100);
const clientB = new LWWRegister('node-B', 'draft-0', 100);
net.register('node-A', clientA);
net.register('node-B', clientB);
// Skenario 1: Simulasikan pemutusan koneksi (Network Partition)
net.isolate('node-A', 'node-B');
// Node A dan B menulis data secara simultan saat offline
// Node B memiliki wall-clock timestamp lebih tinggi
const updateA = clientA.set('title-by-A', 150);
net.broadcast('node-A', updateA);
const updateB = clientB.set('title-by-B', 200);
net.broadcast('node-B', updateB);
// State harus berbeda selama isolasi jaringan
assert.equal(clientA.value, 'title-by-A');
assert.equal(clientB.value, 'title-by-B');
// Skenario 2: Pulihkan jaringan (Reconnection) & Flush outbox
net.heal('node-A', 'node-B');
net.flushBuffer('node-A');
net.flushBuffer('node-B');
// Skenario 3: Assertion konvergensi deterministik
// clientB memiliki timestamp 200 vs 150 milik clientA -> data clientB harus menang di kedua sisi
assert.equal(clientA.value, 'title-by-B', 'Client A harus konvergen ke state Client B');
assert.equal(clientB.value, 'title-by-B', 'Client B harus mempertahankan state deterministiknya');
assert.equal(clientA.timestamp, clientB.timestamp, 'Timestamp kedua node harus sinkron');
});
Integrasi pada Pipeline CI/CD
Jalankan test suite ini pada workflow GitHub Actions atau GitLab CI. Karena harness tidak memerlukan Docker containers atau background services eksternal, runtime pipeline tetap berada di bawah hitungan detik.
# .github/workflows/sync-test.yml
name: Local-First Sync Verification
on:
push:
branches: [main]
pull_request:
jobs:
verify-crdt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Run Deterministic Partition Tests
run: node --test test/sync-engine.test.js
Trade-off dan Limitasi Pendekatan In-Memory
Meskipun cepat dan deterministik, pendekatan harness virtual memiliki batasan operasional:
- Tidak Memvalidasi Raw Serialization Overhead: Format biner (seperti CBOR, Protocol Buffers, atau SQLite binary blobs) tidak teruji jika harness langsung mempassing native JavaScript object. Solusinya: masukkan tahap
JSON.stringify/JSON.parseatau binary encode/decode di dalam methodbroadcast(). - Clock Drift Nyata: Test di atas menggunakan integer logical timestamp. Jika sistem produksi mengandalkan system clock lokal (Date.now()), gunakan Hybrid Logical Clocks (HLC) dalam engine CRDT untuk mencegah timestamp rollback pada perangkat pengguna.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!