Error SQLITE_BUSY: database is locked terjadi ketika dua operasi penulisan mencoba mengakses file database SQLite secara bersamaan. Pada arsitektur React Native, masalah ini umum muncul saat thread antarmuka (UI thread/JS thread utama) dan background worker (seperti Headless JS atau Android WorkManager) mengeksekusi mutasi data ke koneksi SQLite yang sama atau file database yang sama.
Akar Masalah: Batas Konkurensi Single-Writer SQLite
Secara default, engine SQLite bekerja dengan model single-writer, multiple-reader. Pada mode jurnal standar (rollback journal):
- Operasi tulis memerlukan exclusive lock pada seluruh file database.
- Ketika sebuah transaksi memulai fase commit atau write, pembacaan dan penulisan lain akan diblokir.
- Jika koneksi lain mencoba melakukan penulisan saat database sedang dalam status terkunci, SQLite langsung mengembalikan kode kesalahan
SQLITE_BUSY(error code 5).
Pada React Native, pemanggilan JavaScript bersifat asynchronous non-blocking. Ketika UI thread memicu mutasi (misalnya menyimpan formulir offline) bersamaan dengan background worker yang melakukan sinkronisasi ribuan record via Headless JS, kedua operasi mengeluarkan statement BEGIN IMMEDIATE atau COMMIT secara paralel. SQLite native driver gagal mengalokasikan lock, menghasilkan kegagalan transaksi.
Langkah 1: Konfigurasi Engine SQLite (WAL dan Busy Timeout)
Solusi lapis pertama adalah mengoptimalkan cara engine SQLite menangani isolasi lock di tingkat file database. Jalankan perintah PRAGMA berikut segera setelah koneksi database diinisialisasi:
// Eksekusi tepat setelah membuka koneksi SQLite
await db.executeSql('PRAGMA journal_mode = WAL;');
await db.executeSql('PRAGMA busy_timeout = 5000;');
await db.executeSql('PRAGMA synchronous = NORMAL;');Mengapa Konfigurasi Ini Bekerja?
- Write-Ahead Logging (WAL): Memisahkan operasi baca dan tulis. Pembaca tidak memblokir penulis, dan penulis tidak memblokir pembaca. Database menulis perubahan ke file terpisah (
-wal), sehingga operasi pembacaan data dari UI tidak akan pernah terkenaSQLITE_BUSY. - busy_timeout: Menetapkan durasi (dalam milidetik) bagi SQLite untuk melakukan sleep and retry internal sebelum menyerah dan melempar error
SQLITE_BUSY. Nilai 5000ms memberi jeda aman bagi transaksi singkat. - synchronous = NORMAL: Mengurangi overhead
fsyncdisk write pada mode WAL tanpa mengorbankan integritas data saat aplikasi crash.
Catatan Keterbatasan: Mode WAL tetap menerapkan aturan single-writer. Dua transaksi tulis simultan tetap saling memblokir. Jika background worker memegang transaksi selama 6 detik,
busy_timeout = 5000tetap akan melemparSQLITE_BUSYke UI thread.
Langkah 2: Serialisasi Penulisan dengan Asynchronous Mutex
Untuk mengeliminasi benturan write secara deterministik pada layer JavaScript, semua transaksi mutasi harus diserialisasi melalui mekanisme antrean (queue) menggunakan Mutex (Mutual Exclusion). Mutex memastikan bahwa hanya ada satu transaksi tulis yang aktif di runtime pada satu waktu.
Implementasi TypeScript: AsyncMutex
type ReleaseFunction = () => void;
export class AsyncMutex {
private queue: Array<(release: ReleaseFunction) => void> = [];
private locked = false;
public acquire(): Promise<ReleaseFunction> {
return new Promise((resolve) => {
const ticket = (release: ReleaseFunction) => {
resolve(release);
};
if (!this.locked) {
this.locked = true;
resolve(this.createRelease());
} else {
this.queue.push(ticket);
}
});
}
private createRelease(): ReleaseFunction {
return () => {
if (this.queue.length > 0) {
const nextTicket = this.queue.shift();
if (nextTicket) {
nextTicket(this.createRelease());
}
} else {
this.locked = false;
}
};
}
}Implementasi Transaction Wrapper yang Aman dari Race Condition
Gunakan wrapper berikut untuk mengeksekusi operasi database. Pola ini mencegah deadlock dengan memastikan lock selalu dilepas di blok finally, serta menyertakan timeout fallback.
import { AsyncMutex } from './AsyncMutex';
const dbWriteMutex = new AsyncMutex();
interface DatabaseClient {
executeSql: (query: string, params?: any[]) => Promise<any>;
transaction: (callback: (tx: any) => Promise<void>) => Promise<void>;
}
export async function runExclusiveTransaction<T>(
db: DatabaseClient,
action: (tx: any) => Promise<T>,
timeoutMs = 10000
): Promise<T> {
const release = await dbWriteMutex.acquire();
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Transaction execution timeout')), timeoutMs);
});
try {
const executionPromise = new Promise<T>((resolve, reject) => {
db.transaction(async (tx) => {
try {
const result = await action(tx);
resolve(result);
} catch (err) {
reject(err);
}
}).catch(reject);
});
return await Promise.race([executionPromise, timeoutPromise]);
} finally {
// ponytail: memory lock single runtime. Add cross-process native lock if worker runs in separate OS process.
release();
}
}Penjelasan simplifikasi implementasi:
- Menghindari penggunaan external library antrean untuk meminimalisir dependency tree.
- Tidak mengimplementasikan prioritas worker vs UI; semua diproses secara FIFO murni.
- Tambahkan native file-locking (seperti
flock) jika background task berjalan di Android Process terpisah (multi-process application).
Skenario Pengujian Beban Konkurensi
Untuk memvalidasi ketahanan integrasi dari SQLITE_BUSY, simulasikan kondisi ketika UI thread melakukan penulisan berkala sementara background worker menyisipkan data dalam jumlah besar.
async function runConcurrencyStressTest(db: DatabaseClient) {
console.log('Memulai pengujian konkurensi write...');
const tasks: Promise<any>[] = [];
const ITERATIONS = 30;
// 1. Simulasi Background Worker (Menulis batch records)
tasks.push(
runExclusiveTransaction(db, async (tx) => {
for (let i = 0; i < 200; i++) {
await tx.executeSql(
'INSERT INTO sync_logs (source, payload) VALUES (?, ?);',
['worker', `worker_payload_${i}`]
);
}
console.log('Worker write batch selesai');
})
);
// 2. Simulasi Interaksi UI Thread (Mutasi acak paralel)
for (let i = 0; i < ITERATIONS; i++) {
tasks.push(
runExclusiveTransaction(db, async (tx) => {
await tx.executeSql(
'INSERT INTO user_actions (action_name, timestamp) VALUES (?, ?);',
[`click_event_${i}`, Date.now()]
);
})
);
}
try {
await Promise.all(tasks);
console.log('Uji stres berhasil: Seluruh transaksi selesai tanpa SQLITE_BUSY');
} catch (error) {
console.error('Uji stres gagal:', error);
}
}Validasi Hasil dan Evaluasi Trade-off
Pengujian tanpa Mutex pada skenario di atas akan memicu kegagalan berulang Error: SQLITE_BUSY: database is locked saat kedua promise berjalan bersamaan. Setelah Mutex dan konfigurasi WAL diaktifkan:
- Throughput vs Latensi: Operasi UI thread akan mengantre di belakang transaksi worker jika worker lebih dulu mendapatkan lock. Untuk menjaga UI tetap responsif, hindari transaksi worker yang terlalu panjang; pecah batch write menjadi beberapa transaksi kecil berukuran 50-100 baris.
- Memory Leak Prevention: Pastikan release handler di-invoke pada blok
finally. Kegagalan memanggilrelease()akan mengunci seluruh proses penulisan aplikasi (deadlock permanen hingga app restart).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!