Dalam membangun sistem Payment Orchestration yang terintegrasi langsung dengan sistem Core ERP, tantangan terbesar bukanlah sekadar menerima pembayaran, melainkan menjamin bahwa satu pembayaran yang sah hanya menghasilkan tepat satu faktur (invoice) dan satu jurnal pelunasan di ERP—tidak peduli seberapa buruk kondisi jaringan atau seberapa sering Payment Gateway mengirimkan webhook duplikat.
Masalah menjadi jauh lebih kompleks ketika kita berhadapan dengan dua kenyataan di lapangan:
Jika kita hanya menggunakan in-memory lock sederhana atau pengecekan if (status === 'PENDING'), sistem akan gagal saat dijalankan pada lebih dari satu worker atau saat proses RPC mengalami timeout di tengah jalan.
Artikel ini membedah bagaimana saya merancang arsitektur Dual-Layer Atomic Database Locking beserta Point-of-No-Return Guard dan Active Lock Heartbeat untuk menyelesaikan masalah ini secara tuntas.
Di lingkungan produksi dengan trafik tinggi, terdapat tiga skenario kritis yang menyebabkan mekanisme penguncian standar gagal mencegah duplikasi:
Ketika pengguna baru saja membayar, Payment Gateway menembakkan webhook ke endpoint kita. Pada detik yang sama persis, background cron job juga mengambil transaksi yang sama. Tanpa koordinasi atomik di tingkat database, kedua proses lolos pengecekan awal dan memicu pembuatan dua invoice berbeda di Core ERP.
Untuk mencegah deadlock jika server crash saat memegang lock, setiap mekanisme lock harus memiliki batas waktu kedaluwarsa (stale lock timeout), misalnya 3 menit.
Masalahnya: ketika beban Core ERP sedang tinggi, rangkaian panggilan RPC bisa memakan waktu 200 hingga 240 detik. Jika batas stale lock adalah 180 detik, maka pada detik ke-181 proses lain akan mengira proses pertama sudah mati (crash), lalu mengambil alih lock dan memanggil pembuatan invoice sekali lagi padahal proses pertama masih bekerja!
Bayangkan backend mengirim perintah RPC createInvoice ke Core ERP. Di detik ke-60, koneksi jaringan terputus (socket hang up) sebelum nomor invoice sempat diterima dan disimpan ke database lokal.
Sebenarnya, di sisi Core ERP invoice tersebut sudah berhasil terbuat. Jika proses retry berikutnya langsung memanggil createInvoice lagi karena melihat kolom nomor invoice di database lokal masih kosong (null), maka akan tercipta invoice duplikat di Core ERP.
Untuk mengatasi ketiga skenario di atas sekaligus, saya memisahkan mekanisme penguncian menjadi dua lapisan independen di database PostgreSQL menggunakan operasi atomic conditional update:
Pada lapisan pertama, kita memanfaatkan sifat atomik dari klausa UPDATE ... WHERE di database relasional. Saat dua proses mengeksekusi query secara bersamaan, PostgreSQL menjamin hanya satu proses yang berhasil mengubah baris tersebut (affectedRows === 1).
Selain itu, ada dua mekanisme penting yang saya tambahkan:
WHERE wajib memeriksa locked_by = currentOwnerId. Ini mencegah skenario berbahaya di mana Proses A yang sempat hang terlalu lama tiba-tiba bangun dan menghapus lock yang sudah diambil alih secara sah oleh Proses B.// Prototype Konseptual: Atomic Database Lock dengan Jitter & Ownership Check
async function acquireResourceLock(
id: string,
ownerId: string,
maxRetries = 5,
): Promise<boolean> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const staleCutoff = new Date(Date.now() - 3 * 60 * 1000); // 3 menit
// Atomic conditional update di level database
const result = await db.orders.updateMany({
where: {
id,
OR: [
{ isLocked: false },
{ isLocked: true, updatedAt: { lt: staleCutoff } }, // Ambil alih jika lock macet
],
},
data: { isLocked: true, lockedBy: ownerId, updatedAt: new Date() },
});
if (result.count === 1) return true;
// Exponential Backoff + Random Jitter (Max 2 detik)
const backoff = Math.min(
200 * Math.pow(2, attempt - 1) + Math.random() * 200,
2000,
);
await sleep(backoff);
}
return false;
}
async function releaseResourceLock(id: string, ownerId: string): Promise<void> {
// Hanya lepas lock jika proses ini masih menjadi pemilik sah!
await db.orders.updateMany({
where: { id, lockedBy: ownerId },
data: { isLocked: false, lockedBy: null },
});
}
Untuk menangani panggilan RPC ke Core ERP yang bisa berjalan lebih dari 200 detik, saya merancang tiga pengaman khusus pada tabel tagihan (BillingRecord):
Sebelum mengeksekusi panggilan RPC pembuatan invoice ke Core ERP, kita menuliskan penanda Point-of-No-Return ke dalam kolom metadata dengan format:
ATTEMPTED-{workerId}-r{retryCount}-t{timestamp}
Ketika fungsi releaseLock dipanggil di blok finally (misal karena terjadi network timeout di tengah jalan), status isLocked memang dikembalikan menjadi false, tetapi string metadata ATTEMPTED-... sengaja dipertahankan (tidak dihapus)!
Dengan mempertahankan jejak tersebut lintas proses, worker berikutnya yang membaca record ini dapat mengevaluasi dua aturan:
withHeartbeat)Agar proses RPC yang masih aktif bekerja selama 200+ detik tidak dianggap stale (macet) oleh cron job lain, eksekusi RPC dibungkus dengan heartbeat timer yang menyegarkan kolom updatedAt setiap 60 detik:
// Prototype Konseptual: Active Heartbeat & Persistent Attempt Guard
async function executeWithHeartbeat<T>(
recordId: string,
task: () => Promise<T>,
): Promise<T> {
// Perbarui timestamp setiap 60 detik selama task utama masih berjalan
const timer = setInterval(() => {
db.billings
.update({ where: { id: recordId }, data: { updatedAt: new Date() } })
.catch(() => {});
}, 60_000);
try {
return await task();
} finally {
clearInterval(timer);
}
}
updateMany dengan klausa kondisi state dan batas waktu stale memberikan jaminan mutual exclusion langsung di tingkat PostgreSQL tanpa bergantung pada memori satu proses aplikasi.lockedBy: ownerId) pada klausa WHERE saat melepas lock agar proses yang terlambat selesai tidak merusak lock milik proses baru.ATTEMPTED-r{count}-t{time} setelah lock dilepas adalah kunci untuk menerapkan cooldown dan batas retry yang aman lintas proses.Pada Part 2 dari seri portofolio ini, kita akan membedah bagaimana saya mengoptimalkan query pencarian dan laporan transaksi dari Sequential Scan + Subquery EXISTS menjadi pencarian milidetik menggunakan kombinasi PostgreSQL GIN Trigram Index (pg_trgm), Pre-Lookup Relasi O(1), dan Case-Variant B-Tree Matching.