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.parse atau binary encode/decode di dalam method broadcast().
  • 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.