Lonjakan galat HTTP 500 sering terjadi saat ribuan perangkat bergerak (React Native) beralih dari mode luring ke daring secara bersamaan. Saat sinkronisasi batch payload dijalankan, worker backend mulai bertumbukan pada baris database yang sama. Database melempar kode eror 40P01 (deadlock detected), dan transaksi dibatalkan paksa.

Gejala: Lonjakan HTTP 500 Pasca-Koneksi Massal

Aplikasi React Native dengan arsitektur offline-first umumnya menampung mutasi lokal di SQLite atau WatermelonDB. Ketika koneksi internet pulih, antrean mutasi dikirim sekaligus ke API sinkronisasi dalam format array batch:

POST /api/v1/sync
Content-Type: application/json

{
  "mutations": [
    { "id": "d3b07384-d113-4965-b3e1-7e811fc8b628", "stock_delta": -1 },
    { "id": "a108c352-3d71-4820-bcae-2139e8a719d2", "stock_delta": -2 }
  ]
}

Jika ratusan pekerja backend (Puma, Gunicorn, atau Node.js worker) memproses muatan ini dalam transaksi paralel tanpa urutan penguncian yang pasti, database akan mengalami tabrakan row-level lock.

Anatomi Root Cause: Circular Wait pada PostgreSQL (40P01)

Deadlock terjadi ketika dua atau lebih transaksi saling menunggu exclusive lock yang dipegang oleh transaksi lain. Ini memenuhi kondisi circular wait:

  • Worker A (Klien 1): Mengunci Row X, lalu mencoba mengunci Row Y.
  • Worker B (Klien 2): Mengunci Row Y, lalu mencoba mengunci Row X.

PostgreSQL menjalankan proses deadlock_timeout (standar: 1 detik). Begitu siklus terdeteksi, PostgreSQL memutus salah satu transaksi dengan eror berikut pada log server:

LOG:  process 28194 detected deadlock while waiting for ShareLock on transaction 892311 after 1000.082 ms
DETAIL:  Process 28194 waits for ExclusiveLock on tuple (0, 14) of relation "inventory_items"; blocked by process 28195.
Process 28195 waits for ExclusiveLock on tuple (0, 12) of relation "inventory_items"; blocked by process 28194.
HINT:  See server log for query details.
STATEMENT:  UPDATE inventory_items SET stock = stock + $1 WHERE id = $2
ERROR:  40P01: deadlock detected

Skenario Repro Konkurensi

Skenario dapat direproduksi menggunakan dua sesi psql pada tabel inventaris sederhana:

-- Setup Tabel
CREATE TABLE inventory_items (
    id UUID PRIMARY KEY,
    stock INT NOT NULL DEFAULT 0
);

INSERT INTO inventory_items (id, stock) VALUES 
('00000000-0000-0000-0000-000000000001', 10),
('00000000-0000-0000-0000-000000000002', 20);

Jalankan langkah berikut berurutan secara manual di dua terminal terpisah:

  1. Sesi 1: BEGIN; UPDATE inventory_items SET stock = stock - 1 WHERE id = '00000000-0000-0000-0000-000000000001'; (Kunci Row 1 didapat)
  2. Sesi 2: BEGIN; UPDATE inventory_items SET stock = stock - 1 WHERE id = '00000000-0000-0000-0000-000000000002'; (Kunci Row 2 didapat)
  3. Sesi 1: UPDATE inventory_items SET stock = stock - 1 WHERE id = '00000000-0000-0000-0000-000000000002'; (Sesi 1 terblokir, menunggu Sesi 2)
  4. Sesi 2: UPDATE inventory_items SET stock = stock - 1 WHERE id = '00000000-0000-0000-0000-000000000001'; (Deadlock! PostgreSQL membatalkan Sesi 2 dengan kode 40P01)

Solusi 1: Canonical Sorting Payload Mutasi

Cara paling murah mencegah circular wait adalah menjamin seluruh transaksi meminta kunci dalam urutan global yang identik (misal: ascending berdasarkan Primary Key). Jangan percaya urutan array dari klien; lakukan penataan ulang di tingkat aplikasi maupun payload klien.

// client/syncService.ts atau backend/handler.ts
type MutationItem = {
  id: string;
  stock_delta: number;
};

export function sortMutationsCanonically(mutations: MutationItem[]): MutationItem[] {
  return [...mutations].sort((a, b) => a.id.localeCompare(b.id));
}

Bila Klien 1 dan Klien 2 sama-sama memproses Row 1 sebelum Row 2, Worker B akan antre di belakang Worker A pada Row 1, alih-alih mengunci Row 2 lebih dahulu. Siklus kunci mustahil terbentuk.

Solusi 2: Eksekusi Batch Update Terurut dalam SQL

Melakukan UPDATE via looping kode aplikasi rawan menghasilkan urutan yang salah jika menggunakan Promise.all tanpa kendali konkurensi. Gunakan pendekatan set-based query dengan ORDER BY yang eksplisit.

-- Eksekusi batch update dengan determinisme urutan lock
WITH input_data AS (
  SELECT * FROM jsonb_to_recordset($1::jsonb) 
  AS x(id UUID, stock_delta INT)
  ORDER BY id ASC -- Memastikan alokasi lock berurutan
)
UPDATE inventory_items i
SET stock = i.stock + d.stock_delta
FROM input_data d
WHERE i.id = d.id;

Catatan Kritis: Pada PostgreSQL, sintaks UPDATE ... FROM tidak menjamin urutan akuisisi lock baris fisik secara mutlak jika perencana kueri memilih paralel scan. Untuk batch besar, gunakan SELECT ... FOR UPDATE ORDER BY id dalam Common Table Expression (CTE) sebelum mutasi.

WITH locked_rows AS (
  SELECT id 
  FROM inventory_items
  WHERE id IN (SELECT id FROM jsonb_to_recordset($1::jsonb) AS x(id UUID))
  ORDER BY id ASC
  FOR UPDATE
)
UPDATE inventory_items i
SET stock = i.stock + payload.stock_delta
FROM jsonb_to_recordset($1::jsonb) AS payload(id UUID, stock_delta INT)
WHERE i.id = payload.id AND i.id IN (SELECT id FROM locked_rows);

Solusi 3: Client Resilience dengan Exponential Backoff & Full Jitter

Sekalipun skema locking sudah diperbaiki, deadlock transien tetap bisa terjadi akibat operasi latar belakang lain (misal: foreign key checks atau indexing). React Native client harus memiliki mekanisme retry cerdas yang tidak memicu thundering herd problem.

// syncClient.ts
async function syncWithBackoff(
  payload: { mutations: MutationItem[] }, 
  attempt = 0, 
  maxRetries = 4
): Promise<Response> {
  const baseDelayMs = 200;
  const maxDelayMs = 3000;

  try {
    // Pastikan mutasi selalu terurut sebelum dikirim
    const sortedPayload = {
      mutations: sortMutationsCanonically(payload.mutations)
    };

    const res = await fetch('https://api.domain.internal/v1/sync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(sortedPayload),
    });

    if (res.status === 500 && attempt < maxRetries) {
      throw new Error('Transient server error');
    }

    return res;
  } catch (error) {
    if (attempt >= maxRetries) throw error;

    // Algoritma Full Jitter: Sleep = rand(0, min(maxDelay, base * 2 ^ attempt))
    const exponentialDelay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
    const jitteredDelay = Math.random() * exponentialDelay;

    await new Promise((resolve) => setTimeout(resolve, jitteredDelay));
    return syncWithBackoff(payload, attempt + 1, maxRetries);
  }
}

Konfigurasi Database Tambahan

Atur batas waktu tunggu pada tingkat sesi atau global PostgreSQL untuk menghentikan transaksi macet sebelum membebani connection pool:

-- Turunkan waktu identifikasi deadlock jika aplikasi sangat konkuren
SET deadlock_timeout = '250ms';

-- Pastikan transaksi tidak menggantung selamanya
SET statement_timeout = '5000ms';
SET lock_timeout = '3000ms';

Ringkasan

Deadlock mutasi batch bukan bug acak, melainkan cacat desain urutan konkurensi. Solusinya:

  1. Normalisasi urutan mutasi (canonical sort) sedini mungkin sebelum baris dikunci.
  2. Gunakan SELECT FOR UPDATE ORDER BY di tingkat database jika menangani mutasi banyak baris secara simultan.
  3. Terapkan exponential backoff dengan full jitter pada aplikasi React Native agar sinkronisasi pasca-luring tidak membebani server.