Aplikasi web monolit modern berbasis Inertia.js sering kali memproses mutasi state penting di backend controller: memvalidasi payload, membuka database transaction, menulis data, mendispatch background job, lalu mengembalikan respons Inertia. Pola ini memicu race condition laten: background worker mengambil job dari queue broker (misalnya Redis atau SQS) dan mengeksekusinya sebelum transaksi database commit sepenuhnya ke primary database.
Dampaknya langsung terasa di produksi: worker melempar ModelNotFoundException karena row belum terlihat oleh isolasi transaksi DB, atau sebaliknya, job tetap diproses padahal database transaction di controller mengalami rollback akibat exception mendadak. Artikel ini membahas akar masalah race worker vs commit, batasan mitigasi bawaan framework, serta implementasi Transactional Outbox pattern menggunakan FOR UPDATE SKIP LOCKED untuk atomisitas mutlak.
Anatomi Masalah: Race Condition Worker vs DB Commit
Saat Inertia controller memproses request seperti checkout atau verifikasi akun, kode umumnya terlihat seperti ini:
DB::transaction(function () use ($request) {
$order = Order::create([...]);
// Dispatch job ke queue
ProcessOrderPayment::dispatch($order->id);
// Eksekusi logic lain yang memakan waktu I/O
$this->allocateInventory($order);
});
return to_route('orders.show', $order->id);Eksekusi di atas memiliki celah konkurensi kritis:
- Worker Execution Racing Commit: Job terkirim ke queue broker seketika saat method
dispatch()dipanggil. Jika koneksi broker cepat dan queue worker sedang idle, worker dapat membaca job dalam hitungan milidetik. Pada saat yang sama, thread PHP-FPM controller masih mengeksekusi sisa closure atau sedang menyelesaikan handshake commit database. Worker query ke database, data belum ter-commit, worker crash dengan errorModelNotFoundException. - Ghost Jobs pada Transaction Rollback: Jika method
allocateInventory()melempar exception setelahdispatch(), database transaction akan di-rollback. RecordOrdertidak tersimpan, namun pesan job sudah terlanjur berada di Redis/SQS. Worker tetap mengeksekusi instruksi pembayaran untuk order yang tidak pernah ada. - Replication Lag: Pada arsitektur dengan read replica, worker yang langsung membaca data setelah commit bisa mendapatkan state kosong jika proses replikasi mengalami delay mikro-detik.
Opsi Cepat: afterCommit Flag dan Limitasinya
Laravel menyediakan opsi native untuk menahan dispatch job hingga transaksi database terluar berhasil commit:
// Di dalam Job class
public bool $afterCommit = true;
// Atau saat dispatch eksplisit
ProcessOrderPayment::dispatch($order->id)->afterCommit();Anda juga dapat mengaktifkannya secara global pada konfigurasi config/queue.php dengan opsi 'after_commit' => true pada koneksi queue bersangkutan.
Peringatan Batasan: afterCommit menyelesaikan masalah race condition worker vs commit, tetapi tidak menyelesaikan masalah Dual-Write. Jika database berhasil melakukan commit, tetapi koneksi ke message broker putus (misalnya koneksi Redis time out atau proses PHP-FPM terkena kill OOM sebelum push job tuntas), state database berubah permanen namun background job lenyap selamanya.Untuk proses finansial, reservasi inventori, atau integrasi webhook eksternal di mana kehilangan event tidak dapat ditoleransi, Transactional Outbox pattern menjadi keharusan arsitektur.
Arsitektur Transactional Outbox
Transactional Outbox menjamin atomisitas pengiriman pesan dengan memanfaatkan tabel database yang sama dengan entitas domain. Penulisan state bisnis dan event outbox berada dalam satu blok transaksi database ACID murni. Sebuah relay worker terpisah secara berkala membaca tabel outbox dan mem-push payload ke queue broker sebenarnya.
1. Skema Database Outbox
Buat migrasi untuk tabel outbox. Pastikan skema dirancang efisien untuk indeks polling:
Schema::create('outbox_messages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('event_type')->index();
$table->jsonb('payload');
$table->string('status', 20)->default('pending')->index();
$table->unsignedSmallInteger('retries')->default(0);
$table->text('last_error')->nullable();
$table->timestamp('reserved_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
// Indeks majemuk untuk performa polling
$table->index(['status', 'created_at'], 'outbox_poll_idx');
});2. Implementasi di Controller Inertia
Controller hanya bertugas menulis ke database. Tidak ada network call ke message broker pihak ketiga di dalam thread HTTP controller.
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\OutboxMessage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'total_amount' => 'required|integer',
]);
$order = DB::transaction(function () use ($validated, $request) {
$order = Order::create([
'user_id' => $request->user()->id,
'total_amount' => $validated['total_amount'],
'status' => 'pending',
]);
OutboxMessage::create([
'id' => (string) Str::uuid(),
'event_type' => 'OrderCreated',
'payload' => [
'order_id' => $order->id,
'user_id' => $order->user_id,
'amount' => $order->total_amount,
'created_at' => now()->toISOString(),
],
'status' => 'pending',
]);
return $order;
});
return redirect()->route('orders.show', $order->id)->with([
'message' => 'Order berhasil dibuat dan sedang diproses.',
]);
}
}Mekanisme Polling Relay: Mencegah Worker Contention via SKIP LOCKED
Tantangan utama polling outbox adalah contention saat beberapa instance relay dijalankan paralel. Pendekatan naive menggunakan SELECT ... WHERE status = 'pending' akan menghasilkan lock wait atau race condition antar relay process.
Gunakan klausa PostgreSQL / MySQL 8+ FOR UPDATE SKIP LOCKED. Kueri ini mengunci baris yang ditemukan dan secara transparan melewati (skip) baris yang sudah dikunci oleh thread relay lain.
namespace App\Console\Commands;
use App\Models\OutboxMessage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
class RelayOutboxMessages extends Command
{
protected $signature = 'outbox:relay {--batch=50}';
protected $description = 'Publish pending outbox messages to queue broker';
public function handle(): int
{
$batchSize = (int) $this->option('batch');
// DB Transaction pendek khusus lock & klaim baris
$messages = DB::transaction(function () use ($batchSize) {
$records = OutboxMessage::where('status', 'pending')
->orderBy('created_at', 'asc')
->limit($batchSize)
->lockForUpdate()
->skipLocked()
->get();
if ($records->isNotEmpty()) {
OutboxMessage::whereIn('id', $records->pluck('id'))
->update([
'status' => 'processing',
'reserved_at' => now(),
]);
}
return $records;
});
if ($messages->isEmpty()) {
return self::SUCCESS;
}
foreach ($messages as $message) {
try {
// Push ke broker (Redis/SQS)
Queue::pushRaw(
json_encode([
'event_id' => $message->id,
'event_type' => $message->event_type,
'data' => $message->payload,
]),
queue: 'default'
);
$message->update([
'status' => 'processed',
'processed_at' => now(),
]);
} catch (\Throwable $e) {
$message->increment('retries');
$message->update([
'status' => $message->retries >= 5 ? 'failed' : 'pending',
'last_error' => substr($e->getMessage(), 0, 1000),
'reserved_at' => null,
]);
}
}
return self::SUCCESS;
}
}Idempotensi pada Consumer
Transactional Outbox menjamin at-least-once delivery. Kegagalan jaringan mikro pada fase update status outbox dapat memicu relay mengirim pesan yang sama dua kali. Worker pemroses wajib mengimplementasikan idempotensi via payload tracking:
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;
class ProcessOrderCreated implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public function __construct(
public string $eventId,
public array $data
) {}
public function handle(): void
{
// Atomic lock menggunakan eventId unik outbox
$lock = Cache::lock('job:outbox:' . $this->eventId, 120);
if (! $lock->get()) {
// Job sedang diproses oleh worker lain atau sudah selesai
return;
}
try {
// Cek apakah aksi sudah dieksekusi di domain layer
if (Order::where('id', $this->data['order_id'])->where('status', 'paid')->exists()) {
return;
}
// Eksekusi logic bisnis utama
$this->chargePayment($this->data);
} finally {
$lock->release();
}
}
}Observabilitas dan Pemulihan Kegagalan
Dalam pipeline event outbox, sistem observabilitas wajib memantau metrik berikut:
- Outbox Lag (Stale Pending): Pantau jumlah baris dengan
status = 'pending'yang berumur lebih dari 2 menit. Tingginya angka ini menandakan relay command berhenti berjalan atau mengalami bottleneck throughput. - Dead Messages: Record dengan
status = 'failed'harus memicu alert (Sentry, Slack, atau Datadog) agar engineer dapat menganalisis root cause sebelum melakukan requeue manual via commandoutbox:retry-failed. - Zombie Locks: Baris dengan status
processingyang memilikireserved_atlebih tua dari ambang batas wajar (misalnya > 10 menit) akibat crash pada host relay harus di-reset kembali kependingmelalui scheduler berkala.
// Command pemulih zombie process via Laravel Scheduler
OutboxMessage::where('status', 'processing')
->where('reserved_at', '<=', now()->subMinutes(10))
->update([
'status' => 'pending',
'reserved_at' => null,
]);Panduan Pemilihan: afterCommit vs Transactional Outbox
- Gunakan
afterCommitjika: Sistem bertoleransi terhadap kehilangan job sporadis saat crash infrastruktur terjadi (misalnya dispatch job pengiriman notifikasi visual, audit log non-finansial, atau generate cache warm-up). - Gunakan Transactional Outbox jika: Sistem memproses transaksi bernilai kritis (pembayaran, pengurangan kuota, emit event CDC lintas sistem) di mana inkonsistensi antara state database dan message broker berisiko merusak integritas finansial atau data pengguna.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!