Aplikasi React Native dengan fitur offline-first sering memicu crash fatal pada backend saat perangkat kembali online setelah periode operasi offline yang panjang. Ribuan mutasi lokal (insert, update, delete) yang terakumulasi di SQLite atau WatermelonDB dikirimkan secara serentak dalam satu payload HTTP POST berukuran puluhan megabyte. Lonjakan alokasi memori mendadak ini menguras batas RAM container, memicu kernel Linux mematikan proses melalui OOM Killer (Exit Code 137).
Masalah ini berakar pada penggunaan parser JSON berbasis buffer di backend yang memuat seluruh payload ke memori sebelum proses validasi atau persistensi data dimulai. Artikel ini membedah investigasi profil memori dan solusi arsitektural untuk menangani sinkronisasi data bervolume tinggi secara stabil.
Gejala dan Diagnosa: Anatomi Exit Code 137
Pada lingkungan terisolasi seperti Docker atau Kubernetes, sistem operasi memonitor penggunaan memori grup kontrol (cgroups). Saat service menerima payload berukuran 30–50 MB, penggunaan memori runtime dapat melonjak 4 hingga 8 kali lipat dari ukuran file asli akibat struktur objek in-memory. Jika penggunaan memori melampaui batas batas (misalnya 512 MB atau 1 GB), kernel mengirimkan sinyal SIGKILL (sinyal 9).
# Identifikasi status exit pada container Kubernetes/Docker
State: Terminated
Reason: OOMKilled
Exit Code: 137
# Log kernel (dmesg)
[12045.12] Out of memory: Kill process 18241 (node) score 953 or sacrifice child
[12045.13] Killed process 18241 (node) total-vm:1548232kB, anon-rss:518420kB, file-rss:0kBInvestigasi Nginx Access Log
Langkah pertama untuk memvalidasi hipotesis adalah memetakan korelasi antara waktu crash dengan volume request masuk melalui Nginx atau Ingress access log. Periksa variabel $request_length (ukuran total HTTP request termasuk headers dan body):
log_format sync_debug '$remote_addr - [$time_local] "$request" '
'status=$status bytes_sent=$body_bytes_sent '
'payload_size=$request_length duration=$request_time';Log akan memperlihatkan anomali request ke endpoint sinkronisasi sebelum worker mati:
192.168.1.45 - [14/Oct/2023:10:15:20 +0000] "POST /api/v1/sync/mutations HTTP/1.1" status=502 bytes_sent=157 payload_size=48192043 duration=4.120Status 502 Bad Gateway mengonfirmasi bahwa upstream backend mati seketika saat memproses request sebesar ~46 MB tersebut.
Root Cause: Mengapa In-Memory JSON Parser Mematikan Heap
Backend modern (seperti Express dengan express.json({ limit: '50mb' }) atau Fastify/NestJS default) umumnya membaca seluruh HTTP request stream ke dalam buffer internal, mengonversinya menjadi string mentah, lalu menjalankan JSON.parse().
Pada V8 engine (Node.js), proses ini menyebabkan:
- Alokasi Buffer biner mentah berukuran $N$ byte.
- Alokasi representasi string UTF-8 atau UTF-16 berukuran $N$ byte.
- Alokasi Graph Objek JavaScript hasil parse. Objek V8 memiliki overhead signifikan per key-value (pointer, hash map, hidden classes). Payload mentah 40 MB dapat mengembang menjadi 250–350 MB di V8 heap.
- Jika terdapat concurrent requests dengan ukuran serupa, garbage collector (GC) gagal mereklamasi memori tepat waktu, memicu spike tajam yang mengaktifkan OOM Killer.
Verifikasi Profil Memori
Gunakan Node.js heap snapshot atau Go pprof untuk memverifikasi alokasi dominan. Dalam Node.js, rekam snapshot dengan flag --heapsnapshot-signal=SIGUSR2 atau gunakan script profiler terisolasi.
node --inspect index.jsBuka Chrome DevTools (chrome://inspect), jalankan load test lokal dengan payload offline sync 40 MB, dan periksa ringkasan Heap Snapshot. Profil akan menunjukkan persentase alokasi terbesar terpusat pada (array), (string), dan Object yang berasal dari modul middleware parsing body, bukan pada logika bisnis database.
Solusi 1: Partisi Batch pada Mobile Client (React Native)
Solusi paling efektif adalah mencegah client mengirimkan payload monolitik. Aplikasi React Native harus memecah mutasi lokal ke dalam batch-batch berukuran kecil sebelum dikirim ke API sinkronisasi.
// client/syncService.ts
import { database } from './db'; // contoh: WatermelonDB / SQLite
const BATCH_SIZE = 250; // Sesuaikan batas elemen per batch
interface SyncMutation {
id: string;
table: string;
action: 'CREATE' | 'UPDATE' | 'DELETE';
payload: Record<string, unknown>;
timestamp: number;
}
export async function syncOfflineMutations(): Promise<void> {
const pendingMutations = await database.getPendingMutations();
if (pendingMutations.length === 0) return;
for (let i = 0; i < pendingMutations.length; i += BATCH_SIZE) {
const chunk = pendingMutations.slice(i, i + BATCH_SIZE);
try {
const response = await fetch('https://api.domain.com/v1/sync/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({ mutations: chunk }),
});
if (!response.ok) {
throw new Error(`Sync failed with status ${response.status}`);
}
const result = await response.json();
await database.markAsSynced(result.processedIds);
} catch (error) {
console.error(`Gagal memproses batch indeks ${i}:`, error);
// Hentikan perulangan agar urutan eksekusi tetap deterministik
break;
}
}
}Catatan: Pengiriman batch sequential menjaga urutan operasi dependensi (misal: entitas parent dibuat sebelum child) sekaligus menjaga footprint memori HTTP request di bawah 1–2 MB.
Solusi 2: Streaming Parser dan NDJSON di Sisi Backend
Jika backend tetap harus menerima payload berukuran besar tanpa crash, ubah mekanisme dari buffered JSON array ([{...}, {...}]) menjadi NDJSON (Newline Delimited JSON). Setiap baris adalah objek JSON mandiri yang dipisahkan karakter \n.
Format NDJSON:
{"id":"1","table":"orders","action":"CREATE","payload":{"total":50000}}
{"id":"2","table":"items","action":"CREATE","payload":{"name":"item A"}}
{"id":"3","table":"items","action":"CREATE","payload":{"name":"item B"}}Implementasi Stream Receiver di Node.js
Gunakan stream reader untuk memproses baris secara bertahap tanpa menyimpan seluruh dokumen di heap memory:
// backend/streamConsumer.js
const express = require('express');
const readline = require('readline');
const db = require('./database');
const app = express();
// Catatan: JANGAN gunakan express.json() pada route streaming ini
app.post('/api/v1/sync/stream', async (req, res) => {
const rl = readline.createInterface({
input: req,
crlfDelay: Infinity,
});
let batch = [];
const BATCH_FLUSH_SIZE = 100;
let totalProcessed = 0;
try {
for await (const line of rl) {
if (!line.trim()) continue;
const mutation = JSON.parse(line);
batch.push(mutation);
if (batch.length >= BATCH_FLUSH_SIZE) {
await db.executeBatch(batch);
totalProcessed += batch.length;
batch = []; // Kosongkan referensi array untuk Garbage Collector
}
}
// Flush sisa mutasi di batch terakhir
if (batch.length > 0) {
await db.executeBatch(batch);
totalProcessed += batch.length;
batch = [];
}
res.status(200).json({ success: true, processed: totalProcessed });
} catch (err) {
console.error('Error saat membaca stream mutasi:', err);
res.status(500).json({ error: 'Stream processing failed' });
}
});Melalui pipeline stream ini, konsumsi RAM backend tetap datar (flat-line) di kisaran 40–80 MB terlepas dari apakah ukuran payload adalah 10 MB atau 500 MB.
Solusi 3: Chunking Database Transaction
Memasukkan ribuan record secara langsung ke dalam satu transaksi database monolitik menimbulkan masalah baru: penguncian tabel (table locks), saturasi pool koneksi, dan keterbatasan parameter query (misal: batas 65.535 parameter pada PostgreSQL).
Hindari pendekatan Promise.all(records.map(...)) yang meledakkan alokasi thread pool dan memori database client.
// backend/database.js
const { pool } = require('./pgConnection');
async function executeBatch(mutations) {
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const item of mutations) {
if (item.action === 'CREATE') {
await client.query(
'INSERT INTO sync_events (client_id, table_name, data) VALUES ($1, $2, $3)',
[item.id, item.table, JSON.stringify(item.payload)]
);
} else if (item.action === 'UPDATE') {
await client.query(
'UPDATE sync_events SET data = $1 WHERE client_id = $2',
[JSON.stringify(item.payload), item.id]
);
}
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}Matriks Evaluasi Solusi
Pilihan perbaikan bergantung pada kendala infrastruktur dan ketersediaan deployment pada sisi mobile app:
- Client-Side Chunking: Upaya paling stabil dan standar. Menghemat bandwidth dan daya baterai perangkat, serta melindungi backend lama tanpa perombakan parsing. Kelemahan: Bergantung pada adopsi pembaruan aplikasi oleh pengguna.
- Streaming NDJSON Backend: Arsitektur backend yang paling tangguh terhadap serangan DoS payload besar atau sinkronisasi data tak terbatas. Membutuhkan penyesuaian serializer pada client React Native (misal:
react-native-fsmengunggah format stream/file). - Database Chunking: Wajib diimplementasikan pada lapisan persistensi data backend untuk mencegah transaksi macet (deadlock/timeout) saat batch dimasukkan.
Kombinasi pembagian batch di sisi React Native dan pemrosesan chunked transaction di backend memberikan pertahanan komprehensif, menghilangkan insiden OOM Kills Exit 137 secara permanen.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!