Masalah Dual-Write pada Next.js Server Action
Eksekusi mutasi database dan publikasi event ke message broker (seperti RabbitMQ, Kafka, atau Redis) secara sekuensial di Next.js Server Action memicu anomali dual-write inconsistency. Operasi terdistribusi ini tidak berada di bawah payung transaksi atomik yang sama.
Dua skenario kegagalan fatal yang kerap terjadi di lingkungan produksi:
- Publikasi di luar transaksi DB: Mutasi entitas bisnis berhasil disimpan di database, namun pemanggilan message broker terputus karena network drop, timeout, atau broker crash. Database berada dalam kondisi termutasi, namun layanan hilir (misal: sistem notifikasi, payment processing) tidak pernah menerima event tersebut.
- Publikasi di dalam blok transaksi DB: Pesan dikirim ke broker sebelum transaksi database di-commit. Jika transaksi database mengalami rollback (misalnya terjadi pelanggaran constraint atau koneksi pool timeout saat commit), broker telah terlanjur mendistribusikan event untuk data yang sebenarnya tidak pernah ada di database.
Menggunakan mekanisme Two-Phase Commit (2PC) pada arsitektur modern berbasis web/serverless tidak efisien karena latensi tinggi dan risiko blocking lock. Solusi deterministik untuk masalah ini adalah Transactional Outbox Pattern.
Prinsip Kerja Transactional Outbox Pattern
Transactional Outbox Pattern menyatukan mutasi data domain dan pencatatan event ke dalam satu transaksi lokal database yang bersifat ACID. Alih-alih menghubungi broker langsung, Server Action menyimpan payload event ke tabel pembantu bernama outbox.
Alur kerja pola ini terdiri dari dua fase:
- Transactional Write: Server Action menulis entitas bisnis sekaligus row baru di tabel outbox dalam satu transaksi database. Jika database gagal, keduanya di-rollback. Jika berhasil, data dan event dipastikan tersimpan permanen secara konsisten.
- Asynchronous Relay: Background worker terpisah membaca record yang belum diproses dari tabel outbox, mempublikasikannya ke message broker, dan memperbarui status record menjadi berhasil.
Skema Database Outbox
Berikut adalah skema tabel outbox menggunakan Prisma ORM dan PostgreSQL:
// schema.prisma
model Order {
id String @id @default(uuid())
userId String
amount Decimal
status String
createdAt DateTime @default(now())
}
model Outbox {
id String @id @default(uuid())
aggregateType String // e.g., 'ORDER'
aggregateId String // ID dari entitas terkait
eventType String // e.g., 'ORDER_CREATED'
payload Json // Data event lengkap
status String @default("PENDING") // PENDING, PROCESSING, COMPLETED, FAILED
retryCount Int @default(0)
errorMessage String?
createdAt DateTime @default(now())
processedAt DateTime?
@@index([status, createdAt])
}Implementasi Server Action dengan Atomic Transaction
Kode berikut mendemonstrasikan mutasi order baru dan pencatatan event outbox dalam satu transaksi interaktif Prisma:
// app/actions/create-order.ts
'use server';
import { prisma } from '@/lib/prisma';
interface CreateOrderInput {
userId: string;
amount: number;
}
export async function createOrderAction(input: CreateOrderInput) {
if (!input.userId || input.amount <= 0) {
throw new Error('Validasi input gagal: Parameter order tidak valid.');
}
return await prisma.$transaction(async (tx) => {
// 1. Simpan entitas domain
const order = await tx.order.create({
data: {
userId: input.userId,
amount: input.amount,
status: 'PENDING_PAYMENT',
},
});
// 2. Simpan event ke tabel outbox dalam transaksi yang sama
await tx.outbox.create({
data: {
aggregateType: 'ORDER',
aggregateId: order.id,
eventType: 'ORDER_CREATED',
payload: {
orderId: order.id,
userId: order.userId,
amount: order.amount.toString(),
status: order.status,
},
status: 'PENDING',
},
});
return { success: true, orderId: order.id };
});
}Background Worker Processor
Worker dijalankan pada proses Node.js terpisah (atau container terpisah) untuk membaca antrean outbox secara berkala. Untuk mencegah race condition antar instance worker, gunakan strategi locking seperti FOR UPDATE SKIP LOCKED bila menggunakan raw SQL, atau mekanisme update atomic berstatus PROCESSING.
// worker/outbox-processor.ts
import { prisma } from '../lib/prisma';
import { publishToBroker } from '../lib/broker';
const MAX_RETRIES = 5;
const BATCH_SIZE = 50;
export async function processOutboxBatch() {
// Ambil batch pesan yang siap diproses
const pendingEvents = await prisma.outbox.findMany({
where: {
status: 'PENDING',
retryCount: { lt: MAX_RETRIES },
},
take: BATCH_SIZE,
orderBy: { createdAt: 'asc' },
});
for (const event of pendingEvents) {
try {
// Tandai event sedang diproses untuk mencegah replikasi eksekusi instan
await prisma.outbox.update({
where: { id: event.id },
data: { status: 'PROCESSING' },
});
// Idempotency key diteruskan melalui id event outbox ke header message
await publishToBroker({
topic: event.eventType,
idempotencyKey: event.id,
payload: event.payload,
});
// Sukses: perbarui status menjadi COMPLETED
await prisma.outbox.update({
where: { id: event.id },
data: {
status: 'COMPLETED',
processedAt: new Date(),
},
});
} catch (error: any) {
const nextRetryCount = event.retryCount + 1;
const isDead = nextRetryCount >= MAX_RETRIES;
await prisma.outbox.update({
where: { id: event.id },
data: {
status: isDead ? 'FAILED' : 'PENDING',
retryCount: nextRetryCount,
errorMessage: error?.message ?? 'Unknown error',
},
});
}
}
}Jaminan At-Least-Once dan Poison Messages
1. At-Least-Once Delivery & Idempotensi Consumer
Pola Outbox menjamin pengiriman pesan at-least-once (setidaknya satu kali), bukan exactly-once. Jika worker berhasil mengirim pesan ke broker tetapi database crash sesaat sebelum status outbox diubah menjadi COMPLETED, pesan yang sama dapat dikirim ulang pada iterasi berikutnya.
Consumer downstream wajib idempoten. Gunakan id record outbox sebagai idempotencyKey di layer consumer. Simpan kunci ini dalam Redis cache atau tabel riwayat pemrosesan di sisi subscriber sebelum mengeksekusi logika bisnis downstream.
2. Penanganan Poison Message
Poison message adalah payload rusak atau tidak kompatibel yang memicu kegagalan sistem downstream setiap kali diproses, sehingga worker terjebak dalam loop error tak berujung.
- Retry Count Limit: Tetapkan batas maksimal percobaan ulang (misal:
MAX_RETRIES = 5). - Status FAILED / Dead Letter Queue (DLQ): Bila limit tercapai, ubah status menjadi
FAILEDdan alihkan event ke tabel khusus atau DLQ broker untuk diinvestigasi secara manual tanpa memblokir batch event lain. - Exponential Backoff: Berikan penundaan adaptif pada event yang gagal dengan kolom
nextRetryAtagar tidak membebani broker dan database yang sedang mengalami degradasi performa.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!