Akar Masalah: Hilangnya State dan Out-of-Order Execution

Menangani mutasi data dalam kondisi jaringan tidak stabil pada React Native sering kali berujung pada inkonsistensi data antara klien dan server. Pendekatan naif yang mengandalkan in-memory queue (seperti Redux store atau React state) gagal karena OS mobile (Android dan iOS) memiliki mekanisme agresif untuk mematikan background process demi menghemat memori.

Masalah kritis lainnya adalah out-of-order execution. Bayangkan skenario berikut:

  1. Pengguna mengubah judul catatan dari A ke B (Mutasi 1).
  2. Satu detik kemudian, pengguna mengubah judul dari B ke C (Mutasi 2).
  3. Perangkat beralih dari offline ke online.

Jika mutasi dieksekusi secara paralel menggunakan Promise.all atau listener konektivitas standar, Mutasi 2 dapat tiba lebih cepat di server daripada Mutasi 1 karena latensi jaringan yang fluktuatif. Akibatnya, server memproses Mutasi 1 terakhir, dan data kembali menjadi B (stale state). Solusi untuk masalah ini adalah antrean persisten yang diproses secara sekuensial (FIFO) dengan dukungan database relasional lokal.

Arsitektur SQLite Local Queue

SQLite dipilih dibandingkan key-value storage seperti AsyncStorage atau MMKV karena mendukung transaksi ACID dan query transaksional. Ini mencegah kegagalan mutasi parsial jika aplikasi crash di tengah eksekusi.

Skema Tabel Queue

Tabel queue membutuhkan kolom untuk identifikasi mutasi, payload data, pelacakan percobaan ulang, serta kunci idempoten untuk mencegah duplikasi pemrosesan di sisi backend:

CREATE TABLE IF NOT EXISTS mutation_queue (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  payload TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSING, FAILED
  retry_count INTEGER NOT NULL DEFAULT 0,
  idempotency_key TEXT UNIQUE NOT NULL,
  created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_mutation_status_created 
ON mutation_queue(status, created_at ASC);

Implementasi Lock Mechanism dan FIFO Worker

Worker bertugas memproses antrean satu per satu. Untuk mencegah double processing akibat pemicu ganda (misalnya event konektivitas yang memicu worker saat worker sebelumnya masih berjalan), dibutuhkan lock mechanism di tingkat aplikasi dan database.

// queueWorker.ts
import { open } from 'op-sqlite'; // atau expo-sqlite

const db = open({ name: 'app_queue.db' });
let isWorkerRunning = false;

interface QueueItem {
  id: string;
  type: string;
  payload: string;
  retry_count: number;
  idempotency_key: string;
}

export async function processQueue(): Promise<void> {
  if (isWorkerRunning) return;
  isWorkerRunning = true;

  try {
    while (true) {
      // Ambil satu item terlama dengan status PENDING
      const result = await db.execute(
        `SELECT * FROM mutation_queue 
         WHERE status = 'PENDING' 
         ORDER BY created_at ASC 
         LIMIT 1;`
      );

      if (!result.rows || result.rows.length === 0) {
        break; // Antrean habis
      }

      const item = result.rows[0] as unknown as QueueItem;

      // Kunci status menjadi PROCESSING
      await db.execute(
        `UPDATE mutation_queue SET status = 'PROCESSING' WHERE id = ?;`,
        [item.id]
      );

      try {
        await executeApiCall(item);

        // Sukses: Hapus dari antrean
        await db.execute(
          `DELETE FROM mutation_queue WHERE id = ?;`,
          [item.id]
        );
      } catch (error: any) {
        const isFatal = error.status >= 400 && error.status < 500 && error.status !== 429;

        if (isFatal) {
          // HTTP 4xx: Kesalahan klien/validasi, jangan retry
          await db.execute(
            `UPDATE mutation_queue SET status = 'FAILED' WHERE id = ?;`,
            [item.id]
          );
          triggerRollback(item);
        } else {
          // HTTP 5xx / Network Timeout: Transient error, jadwalkan retry
          const nextRetry = item.retry_count + 1;
          await db.execute(
            `UPDATE mutation_queue 
             SET status = 'PENDING', retry_count = ? 
             WHERE id = ?;`,
            [nextRetry, item.id]
          );

          // Exponential backoff delay
          const backoffDelay = Math.min(1000 * Math.pow(2, nextRetry), 30000);
          await new Promise((resolve) => setTimeout(resolve, backoffDelay));
          break; // Hentikan loop sementara saat network bermasalah
        }
      }
    }
  } finally {
    isWorkerRunning = false;
  }
}

Penanganan Error: Transient vs Fatal

Pembedaan penanganan galat mutasi dibagi menjadi dua kategori:

  • Transient Errors (Network Drop, HTTP 502, 503, 504, 429): Mutasi tetap dipertahankan dalam antrean. Naikkan retry_count dan terapkan exponential backoff dengan jitter untuk mencegah thundering herd problem pada server.
  • Fatal Errors (HTTP 400, 401, 403, 422): Payload tidak valid atau hak akses tidak mencukupi. Percobaan ulang dengan data yang sama tidak akan menyelesaikan masalah. Tandai antrean sebagai FAILED atau hapus dari database, lalu picu mekanisme rollback.

Strategi Rollback pada Optimistic UI

Saat pengguna melakukan aksi offline, UI langsung diperbarui secara optimistik. Jika mutasi tersebut akhirnya berstatus fatal error, kondisi data lokal harus dikembalikan ke snapshot sebelumnya.

  1. Simpan Snapshot State: Sebelum mutasi diaplikasikan ke UI store (Redux, Zustand), simpan salinan state awal bersama referensi idempotency_key.
  2. Trigger Rollback Event: Ketika antrean menetapkan status FAILED, kirimkan event global menggunakan event emitter lokal.
  3. Terapkan Nilai Lama: Store mendengarkan event tersebut dan mengganti data mutasi dengan snapshot awal, serta menampilkan notifikasi peringatan kepada pengguna.
Catatan: Backend wajib mengimplementasikan verifikasi header Idempotency-Key. Jika jaringan terputus tepat setelah backend memproses mutasi namun sebelum klien menerima respons, pengiriman ulang tidak boleh menghasilkan duplikasi entri di database utama.

Trade-off dan Alternatif

Pendekatan SQLite FIFO Queue menjamin konsistensi data absolut dengan urutan yang tepat, namun memiliki trade-off: satu mutasi yang tertahan pada transient error dapat menunda mutasi independen lainnya di belakangnya jika seluruh aksi dimasukkan ke dalam satu antrean global tunggal.

Untuk kasus aplikasi berskala besar, pisahkan queue berdasarkan agregat domain (misalnya: antrean profil dan antrean chat berjalan pada channel terpisah) untuk mencegah head-of-line blocking.