Masalah Dual-Write pada Arsitektur Offline-First

Aplikasi mobile offline-first umumnya memproses mutasi state secara lokal sebelum mengirimkannya ke server. Pola implementasi naif sering memisahkan penulisan database lokal dengan penjadwalan antrean jaringan (sync queue), misalnya menulis ke SQLite lalu memanggil fungsi enqueue pada memory queue atau storage terpisah (AsyncStorage/MMKV).

Pemisahan ini menciptakan masalah dual-write. Jika aplikasi mengalami crash atau dimatikan oleh sistem operasi (OS-level kill/OOM) tepat setelah mutasi lokal tersimpan tetapi sebelum task sinkronisasi terdaftar, mutasi tersebut menjadi orphaned. Perubahan data tertahan selamanya di perangkat klien tanpa pernah dikirim ke backend, menyebabkan desinkronisasi permanen.

Solusinya adalah Transactional Outbox Pattern. Pola ini memanfaatkan sifat ACID dari SQLite: mutasi data domain bisnis dan pencatatan event sinkronisasi dieksekusi dalam satu transaksi atomik yang sama. Jika transaksi sukses, event dipastikan tercatat. Jika gagal atau aplikasi crash saat eksekusi, seluruh mutasi dibatalkan tanpa menyisakan inkonsistensi.

Perancangan Skema Database dalam Satu Transaksi ACID

Implementasi membutuhkan tabel khusus outbox_events di database SQLite yang sama dengan tabel domain. Hindari penggunaan storage engine terpisah untuk queue.

CREATE TABLE IF NOT EXISTS orders (
  id TEXT PRIMARY KEY NOT NULL,
  item_id TEXT NOT NULL,
  quantity INTEGER NOT NULL,
  status TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS outbox_events (
  id TEXT PRIMARY KEY NOT NULL,
  event_type TEXT NOT NULL,
  payload TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('PENDING', 'IN_FLIGHT', 'COMPLETED', 'FAILED')),
  retry_count INTEGER NOT NULL DEFAULT 0,
  next_retry_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_outbox_queue 
ON outbox_events (status, next_retry_at);

Ketika pengguna membuat pesanan, mutasi tabel orders dan penambahan entri pada outbox_events dijalankan di dalam blok transaksi tunggal:

async function createOrder(db: SQLiteDatabase, orderId: string, itemId: string, qty: number) {
  const now = Date.now();
  const eventId = crypto.randomUUID();
  const payload = JSON.stringify({ orderId, itemId, qty });

  await db.transaction(async (tx) => {
    // 1. Mutasi domain lokal
    await tx.executeAsync(
      `INSERT INTO orders (id, item_id, quantity, status, created_at) VALUES (?, ?, ?, 'PENDING', ?)`,
      [orderId, itemId, qty, now]
    );

    // 2. Registrasi event outbox
    await tx.executeAsync(
      `INSERT INTO outbox_events (id, event_type, payload, status, retry_count, next_retry_at, created_at) 
       VALUES (?, 'ORDER_CREATED', ?, 'PENDING', 0, ?, ?)`,
      [eventId, payload, now, now]
    );
  });
}

Lifecycle Status Event Outbox

Event berpindah status secara deterministik melalui empat status utama:

  • PENDING: Event baru dibuat, siap diambil oleh background worker untuk sinkronisasi.
  • IN_FLIGHT: Event sedang diproses oleh jaringan. Penanda ini mencegah eksekusi paralel ganda (race conditions) jika worker terpanggil berulang kali.
  • COMPLETED: Backend mengonfirmasi pemrosesan dengan status respons 2xx. Record siap dipruning.
  • FAILED: Event mencapai batas maksimal retry atau menerima status 4xx permanen (unrecoverable client error seperti HTTP 400 atau 422) yang tidak valid untuk diulang.

Implementasi Worker Sinkronisasi dan Retry Backoff

Worker menguras antrean dengan mengambil event yang berstatus PENDING atau event IN_FLIGHT yang kedaluwarsa (zombie events akibat crash saat pengiriman data berlangsung). Gunakan mekanisme exponential backoff untuk menghindari flooding ke server ketika konektivitas buruk.

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 2000;
const LOCK_TIMEOUT_MS = 60000; // 1 menit sebelum event IN_FLIGHT dianggap stale

async function drainOutbox(db: SQLiteDatabase, apiClient: ApiClient) {
  const now = Date.now();
  const staleThreshold = now - LOCK_TIMEOUT_MS;

  // Ambil batch event: PENDING atau IN_FLIGHT yang mati (zombie lock)
  const events = await db.getAllAsync<OutboxRow>(
    `SELECT * FROM outbox_events 
     WHERE (status = 'PENDING' AND next_retry_at <= ?)
        OR (status = 'IN_FLIGHT' AND next_retry_at <= ?)
     ORDER BY created_at ASC LIMIT 10`,
    [now, staleThreshold]
  );

  for (const event of events) {
    // Kunci event ke IN_FLIGHT
    await db.executeAsync(
      `UPDATE outbox_events SET status = 'IN_FLIGHT', next_retry_at = ? WHERE id = ?`,
      [now + LOCK_TIMEOUT_MS, event.id]
    );

    try {
      await apiClient.post('/sync/orders', JSON.parse(event.payload), {
        headers: { 'Idempotency-Key': event.id }
      });

      // Update sukses
      await db.executeAsync(
        `UPDATE outbox_events SET status = 'COMPLETED' WHERE id = ?`,
        [event.id]
      );
    } catch (err: any) {
      const isRecoverable = !err.status || (err.status >= 500 || err.status === 429);
      const nextRetryCount = event.retry_count + 1;

      if (isRecoverable && nextRetryCount < MAX_RETRIES) {
        // Exponential backoff: base * 2^(retry)
        const delay = BASE_DELAY_MS * Math.pow(2, nextRetryCount);
        await db.executeAsync(
          `UPDATE outbox_events 
           SET status = 'PENDING', retry_count = ?, next_retry_at = ? 
           WHERE id = ?`,
          [nextRetryCount, Date.now() + delay, event.id]
        );
      } else {
        // Kegagalan permanen
        await db.executeAsync(
          `UPDATE outbox_events SET status = 'FAILED' WHERE id = ?`,
          [event.id]
        );
      }
    }
  }
}

Integrasi Idempotency-Key pada Endpoint API

Mobile network tidak andal. Skenario umum: paket request sampai ke server dan database backend sukses menyimpan data, tetapi koneksi drop sebelum respons HTTP 200 diterima perangkat mobile.

Ketika worker outbox mengirim ulang event tersebut, backend akan memproses payload duplikat jika tidak diverifikasi. Untuk mengatasinya, gunakan kolom id dari outbox_events sebagai nilai header Idempotency-Key HTTP request.

Penting: Backend wajib menyimpan Idempotency-Key di layer redis atau transactional table miliknya untuk mengenali duplikasi dan langsung mengembalikan cached response sukses tanpa menjalankan ulang side-effect bisnis.

Strategi Pruning untuk Mengatasi Storage Bloat

Tabel outbox yang tidak dibersihkan akan meningkatkan ukuran file database SQLite lokal, memperlambat query indexing, dan memakan kapasitas storage user. Jalankan mekanisme pruning terjadwal:

  • Purge Event COMPLETED: Hapus event bertatus COMPLETED yang berusia lebih dari 24-72 jam. Jendela waktu ini berguna untuk kebutuhan debugging log lokal sebelum dihapus permanen.
  • Retain Event FAILED: Jangan hapus event FAILED secara otomatis. Biarkan tersimpan untuk audit, monitoring, atau mekanisme intervensi manual (misal UI retry/tinjauan error bagi user).
  • Jalankan SQLite VACUUM berkala: Menghapus baris pada SQLite hanya menandai page sebagai free list. Jalankan command pemeliharaan PRAGMA auto_vacuum = INCREMENTAL atau run vacuum pasif saat utilisasi aplikasi rendah untuk mereclaim storage.
async function pruneCompletedEvents(db: SQLiteDatabase, retentionDays = 3) {
  const cutoffTime = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
  await db.executeAsync(
    `DELETE FROM outbox_events WHERE status = 'COMPLETED' AND created_at < ?`,
    [cutoffTime]
  );
}

Dengan menggabungkan transaksi ACID SQLite, status lifecycle yang ketat, header idempotency, dan pembersihan rutin, aplikasi React Native terlindungi dari kehilangan data mutasi lokal tanpa risiko duplikasi data di backend.