Error SQLITE_BUSY (error code 5) pada aplikasi React Native terjadi ketika dua thread mencoba menulis ke basis data pada saat bersamaan, atau ketika thread penulisan diblokir oleh operasi pembacaan yang sedang berlangsung. Masalah ini sering muncul dalam arsitektur mobile modern: background sync worker menyuntikkan ratusan record dari server, sementara user mengetuk tombol interaksi di UI thread yang memicu mutasi lokal.

Akar Masalah: Default Rollback Journal dan Lock Contention

Secara default, SQLite menggunakan rollback journal (mode DELETE, TRUNCATE, atau PERSIST). Pada mode ini, SQLite menerapkan file-level lock:

  • Shared Lock: Diminta saat membaca. Banyak thread bisa membaca bersamaan jika tidak ada writer.
  • Reserved Lock: Diminta saat transaksi penulisan dimulai tetapi belum committed.
  • Exclusive Lock: Diminta saat perubahan ditulis ke file database utama. Semua reader diblokir, dan writer lain ditolak.

Ketika background worker menjalankan mutasi besar dalam mode rollback journal, SQLite memegang lock eksklusif. Jika UI thread mengeksekusi operasi penulisan lain tanpa antrean atau mekanisme tunggu, SQLite langsung melempar exception SQLITE_BUSY: database is locked.

Solusi Fondasi: Mengaktifkan WAL Mode dan busy_timeout

Langkah pertama mitigasi adalah beralih ke Write-Ahead Logging (WAL). Pada mode WAL, pembacaan tidak memblokir penulisan, dan penulisan tidak memblokir pembacaan. SQLite menulis mutasi ke file terpisah (<database>.db-wal) alih-alih langsung memodifikasi database utama.

Gunakan library modern berbasis JSI seperti @op-engineering/op-sqlite untuk menghindari overhead serialization bridge React Native lama.

import { open } from '@op-engineering/op-sqlite';

export const db = open({ name: 'app_v1.sqlite' });

// 1. Ubah journal mode ke WAL (hanya perlu dieksekusi sekali per koneksi)
db.execute('PRAGMA journal_mode = WAL;');

// 2. Berikan toleransi retry otomatis sebelum melempar SQLITE_BUSY
db.execute('PRAGMA busy_timeout = 5000;');

// 3. Optimalkan sinkronisasi disk
db.execute('PRAGMA synchronous = NORMAL;');

Pengaturan PRAGMA busy_timeout = 5000; memaksa SQLite di layer C native untuk menunggu hingga 5 detik (secara polling dengan exponential backoff) jika terjadi lock conflict sebelum mengembalikan SQLITE_BUSY ke JavaScript engine.

Serial Write Queue Berbasis Promise di TypeScript

WAL mendukung banyak reader serentak, namun SQLite hanya mengizinkan tepat satu writer dalam satu waktu. Jika busy_timeout habis karena background sync berjalan lebih lama dari durasi timeout, transaksi UI tetap akan gagal.

Solusinya adalah membatasi seluruh mutasi penulisan melalui satu jalur FIFO (First-In, First-Out) di level aplikasi menggunakan serial promise queue, sementara query baca tetap berjalan paralel secara langsung.

type QueueTask<T> = () => Promise<T>;

export class SerialWriteQueue {
  private queue: Promise<unknown> = Promise.resolve();

  enqueue<T>(task: QueueTask<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      // Rangkai task baru ke ekor promise yang sedang berjalan
      this.queue = this.queue
        .catch(() => {
          // ponytail: Mengabaikan error sebelumnya agar antrean berikutnya tetap jalan
        })
        .then(async () => {
          try {
            const result = await task();
            resolve(result);
          } catch (error) {
            reject(error);
          }
        });
    });
  }
}

export const writeQueue = new SerialWriteQueue();

Abstraksi Operasi Read vs Write

Bungkus akses database agar tim pengembang tidak melakukan pemanggilan penulisan secara langsung tanpa melalui antrean:

// Pembacaan langsung (paralel, non-blocking via WAL)
export async function executeRead<T>(sql: string, params: unknown[] = []): Promise<T[]> {
  const result = await db.executeAsync(sql, params);
  return (result.rows?._array as T[]) ?? [];
}

// Penulisan wajib melalui serial queue
export async function executeWrite<T>(task: (database: typeof db) => Promise<T>): Promise<T> {
  return writeQueue.enqueue(async () => {
    return await task(db);
  });
}

Mencegah Stale Lock pada Transaksi Gagal

Jika transaksi gagal di tengah jalan tanpa ROLLBACK eksplisit, koneksi akan mempertahankan lock transaksi di level native, menyebabkan antrean berikutnya gagal karena cannot start a transaction within a transaction.

Gunakan pola try...finally yang ketat untuk menjamin pelepasan status transaksi:

export async function executeTransaction<T>(
  callback: (database: typeof db) => Promise<T>
): Promise<T> {
  return writeQueue.enqueue(async () => {
    // BEGIN IMMEDIATE mencegah promosi lock di tengah transaksi yang memicu deadlock
    await db.executeAsync('BEGIN IMMEDIATE TRANSACTION;');
    try {
      const result = await callback(db);
      await db.executeAsync('COMMIT;');
      return result;
    } catch (error) {
      try {
        await db.executeAsync('ROLLBACK;');
      } catch (rollbackError) {
        // Abaikan rollback error jika database sudah dalam status non-transaksi
      }
      throw error;
    }
  });
}

Penting: Gunakan BEGIN IMMEDIATE alih-alih BEGIN DEFERRED. BEGIN IMMEDIATE segera mengunci hak tulis sejak awal transaksi. Ini mencegah dua thread memasuki status pembacaan lalu saling berebut promosi ke status penulisan secara bersamaan (pemicu utama deadlock SQLite).

Manajemen Ukuran File WAL (Checkpointing)

Dengan WAL mode, data baru disimpan di file .db-wal. SQLite secara default menjalankan passive checkpoint otomatis ketika file mencapai 1000 halaman (pages). Namun, pembacaan yang terus aktif dapat menahan checkpoint, membuat file .db-wal membesar tanpa batas hingga puluhan megabyte.

Jalankan checkpointing berkala, misalnya saat background sync selesai atau saat aplikasi memasuki status background (via AppState React Native):

import { AppState } from 'react-native';

export function setupWalCheckpointListener() {
  AppState.addEventListener('change', async (state) => {
    if (state === 'background') {
      try {
        // PASSIVE: Menulis frame WAL ke database utama tanpa memblokir pembacaan lain
        await db.executeAsync('PRAGMA wal_checkpoint(PASSIVE);');
      } catch (error) {
        // Penanganan error jika database sibuk
      }
    }
  });
}

// Jalankan TRUNCATE hanya setelah proses batch sync besar selesai
export async function checkpointAfterHeavySync() {
  await writeQueue.enqueue(async () => {
    await db.executeAsync('PRAGMA wal_checkpoint(TRUNCATE);');
  });
}

Ringkasan Solusi

  1. Aktifkan WAL Mode: Memisahkan lock baca dan lock tulis.
  2. Set busy_timeout: Beri toleransi tunggu native sebelum melempar error.
  3. Bungkus Penulisan dalam Serial Queue: Memastikan tepat satu mutasi JavaScript yang berjalan pada satu waktu secara FIFO.
  4. Gunakan BEGIN IMMEDIATE: Menghilangkan risiko lock upgrade deadlocks.
  5. Kontrol Checkpoint: Menjaga ukuran storage perangkat pengguna tetap ramping.