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:
- Pengguna mengubah judul catatan dari A ke B (Mutasi 1).
- Satu detik kemudian, pengguna mengubah judul dari B ke C (Mutasi 2).
- 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_countdan 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
FAILEDatau 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.
- Simpan Snapshot State: Sebelum mutasi diaplikasikan ke UI store (Redux, Zustand), simpan salinan state awal bersama referensi
idempotency_key. - Trigger Rollback Event: Ketika antrean menetapkan status
FAILED, kirimkan event global menggunakan event emitter lokal. - 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!