Akar Masalah: Latensi Jaringan dan Double Submit pada Inertia.js
Mekanisme proteksi sisi klien seperti menonaktifkan tombol submit (disabled) rentan gagal. Latensi render antarmuka, event bubbling yang tidak terputus, atau user pada koneksi tidak stabil yang menekan tombol berulang kali dapat memicu beberapa panggilan router.post() secara paralel sebelum state reaktif sempat dikunci.
Ketika dua request mutasi identik mencapai backend dalam rentang milidetik, keduanya melewati validasi form, membaca status database yang belum terupdate, dan mengeksekusi aksi kritis seperti memotong saldo atau mendispatch worker job ganda. Klien tidak boleh menjadi satu-satunya pagar integritas data. Backend membutuhkan mekanisme serialisasi eksekusi: Redis atomic distributed lock.
Arsitektur Redis Lock: Komposisi Key dan TTL Terukur
Laravel menyediakan abstraksi distributed lock melalui Cache::lock() yang memanfaatkan perintah atomik Redis SET resource_name token NX PX milliseconds. Kunci mutasi kritis harus unik terhadap konteks pengguna dan data yang dikirim.
- Prefix Aksi: Menandai domain fungsional (misal:
order:create). - User ID: Membatasi locking per pengguna terotentikasi, menghindari false lock lintas user.
- Payload Hash: Hash SHA-256 dari payload canonical memastikan perubahan parameter form tidak terkunci secara keliru.
- Time-to-Live (TTL): Harus mencakup perkiraan durasi transaksi database dan dispatch queue ditambah toleransi jaringan, biasanya antara 5 hingga 10 detik. Jangan gunakan lock tanpa batas waktu (deadlock risk).
Implementasi Backend: Safe Acquire dan Release Lock
Gunakan pola try-finally untuk menjamin pelepasan lock jika terjadi pengecualian runtime. Pastikan dispatch queue menggunakan event afterCommit agar worker tidak membaca state uncommitted database jika transaksi dibatalkan.
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use App\Jobs\ProcessPaymentJob;
use Symfony\Component\HttpFoundation\Response;
class CheckoutController extends Controller
{
public function __invoke(Request $request)
{
$validated = $request->validate([
'cart_id' => ['required', 'string'],
'amount' => ['required', 'numeric', 'min:1'],
]);
$userId = $request->user()->id;
$payloadHash = hash('sha256', json_encode($validated));
$lockKey = "lock:checkout:{$userId}:{$payloadHash}";
// Lock berdurasi 10 detik
$lock = Cache::lock($lockKey, 10);
if (! $lock->get()) {
return back(Response::HTTP_CONFLICT)->withErrors([
'transaction' => 'Permintaan serupa sedang diproses. Mohon tunggu.',
]);
}
try {
DB::transaction(function () use ($validated, $userId) {
// Eksekusi mutasi data lokal
$order = DB::table('orders')->insertGetId([
'user_id' => $userId,
'cart_id' => $validated['cart_id'],
'amount' => $validated['amount'],
'created_at' => now(),
]);
// Dispatch job hanya setelah transaksi database berhasil commit
DB::afterCommit(function () use ($order) {
ProcessPaymentJob::dispatch($order);
});
});
} catch (\Throwable $e) {
// Release segera jika proses gagal agar user bisa mengulang aksi
$lock->release();
throw $e;
}
// ponytail: Biarkan lock bertahan hingga TTL jika mutasi sukses untuk mencegah klik ulang cepat
return redirect()->route('orders.index')->with('success', 'Order berhasil dibuat.');
}
}Menangani HTTP 409 pada Frontend Inertia.js
Saat server mengembalikan kode HTTP 409 Conflict, Inertia.js secara default mempertahankan scroll dan state form hanya jika dikonfigurasi secara eksplisit. Form reset yang tidak disengaja akan merusak user experience.
<script setup>
import { useForm } from '@inertiajs/vue3';
const form = useForm({
cart_id: 'cart-98421',
amount: 150000,
});
const submit = () => {
form.post('/checkout', {
preserveScroll: true,
preserveState: true,
onError: (errors) => {
if (errors.transaction) {
console.warn('Operasi terkunci:', errors.transaction);
}
},
});
};
</script>
<template>
<form @submit.prevent="submit">
<div v-if="form.errors.transaction" class="alert alert-warning">
{{ form.errors.transaction }}
</div>
<button type="submit" :disabled="form.processing">
{{ form.processing ? 'Memproses...' : 'Bayar Sekarang' }}
</button>
</form>
</template>Uji Konkurensi Paralel Menggunakan cURL
Verifikasi isolasi lock dengan mengeksekusi skrip Bash yang mengirim 5 request identik secara simultan melalui background processes (&).
#!/usr/bin/env bash
URL="http://localhost:8000/checkout"
COOKIE="laravel_session=YOUR_SESSION_COOKIE; XSRF-TOKEN=YOUR_TOKEN"
HEADER_CSRF="X-XSRF-TOKEN: YOUR_DECODED_TOKEN"
PAYLOAD='{"cart_id":"cart-test-123","amount":50000}'
echo "Mengirim 5 request konkuren..."
for i in {1..5}; do
curl -s -o /dev/null -w "Request $i: HTTP %{http_code}\n" \
-X POST "$URL" \
-H "Content-Type: application/json" \
-H "Accept: text/html, application/xhtml+xml" \
-H "X-Inertia: true" \
-H "$HEADER_CSRF" \
-b "$COOKIE" \
-d "$PAYLOAD" &
done
wait
echo "Pengujian selesai."Hasil eksekusi yang diharapkan: 1 request menghasilkan HTTP 302 (redirect sukses) atau HTTP 200, dan 4 request lainnya menghasilkan HTTP 409.
Alternatif dan Pertimbangan Desain
Gunakan database unique constraint atau Idempotency-Key header jika transaksi tidak melibatkan trigger job queue eksternal. Redis lock adalah solusi optimal ketika pemrosesan melibatkan antrean asynchronous yang tidak dapat di-rollback otomatis oleh relational database engine saat terjadi duplikasi eksekusi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!