Loncat Status, Email Ganda, dan Batas Transaksi
Satu tiket loncat dua status, warga dapat email dobel: akarnya dual-write. Perbaikannya peta transisi, 409, riwayat, dan outbox.
Ringkasan
Awalnya tiket komplain bisa loncat status dan ngirim dobel email gara-gara dual-write bug. Ternyata ngirim email langsung di dalam transaksi itu rawan ghost email kalo rollback atau crash. Akhirnya dibenerin pake peta transisi yang nolak pake 409, riwayat append-only satu transaksi, dan outbox biar email kekirim eventual lewat worker.
Saya lagi ngecek tabel pengaduan di database sebuah portal layanan komplain, dan menemukan satu tiket yang statusnya loncat dari "masuk" langsung ke "selesai". Warga yang lapor bahkan dapat dua email notifikasi untuk satu kali klik tombol di dashboard.
Awalnya saya yakin ini cuma masalah race condition di frontend. Saya menduga ada dua request yang terkirim hampir bersamaan, atau mungkin fungsi sendEmail di backend nggak di-await dengan benar sehingga dieksekusi dobel. Saya coba tambahkan lock di level aplikasi, tapi masalahnya tetap muncul dengan pola yang beda.
Setelah menelusuri log transaksi, ternyata akar masalahnya ada di cara saya menangani perubahan status. Saya mengirim email SMTP secara inline di tengah operasi update database. Ini dual-write bug klasik. Kalo transaksi database gagal dan di-rollback, email udah terlanjur terkirim ke warga. Sebaliknya, kalo transaksi sukses tapi proses server crash sesaat setelahnya, email nggak pernah terkirim sama sekali [2].
Tak ada kontrak formal yang mewajibkan 409 untuk kasus ini; yang saya pegang justru alasannya lebih sederhana. Kode status itu bahasa. Salah bahasa, klien sepintar apa pun akan mengambil keputusan yang salah, misalnya mencoba ulang otomatis pada 500, padahal transisinya memang tidak boleh lewat.
Bagian tersulit dari membangun mesin status bukan pada daftar statusnya, melainkan pada batasannya. Ada empat keputusan arsitektur yang akhirnya saya terapkan untuk memperbaiki ini.
Peta Transisi Eksplisit dan HTTP 409
Daripada mengandalkan rantai if-else yang tersebar di beberapa fungsi, saya bikin peta transisi status yang eksplisit. Kalo sebuah permintaan mencoba mengubah status dari "masuk" langsung ke "selesai" tanpa lewat "diproses", sistem harus menolaknya.
Penolakan ini wajib pakai kode HTTP 409 Conflict. Menurut dokumentasi MDN, 409 secara spesifik mengindikasikan bahwa permintaan bertentangan dengan keadaan sumber daya saat ini [1]. Pakai 400 Bad Request atau 500 di sini justru menyesatkan, karena masalahnya bukan pada format data atau kegagalan server, melainkan pada logika bisnis yang dilanggar.
Riwayat yang Append-Only dalam Satu Transaksi
Setiap perubahan status yang valid harus mencatat riwayat di tabel terpisah. Pencatatan baris riwayat ini wajib terjadi di dalam transaksi database yang sama dengan update status utamanya. Manual PostgreSQL ngejelasin bahwa transaksi membundel beberapa langkah jadi satu operasi all-or-nothing; kalo ada kegagalan di tengah jalan, nggak ada efek yang tersisa [3]. Jadi nggak mungkin ada tiket yang statusnya berubah tapi riwayatnya kosong.
Email Lewat Outbox, Bukan Inline
Ini inti perbaikan email ganda tadi. Mengirim pesan di tengah transaksi itu nggak reliabel, dan mengirimnya setelah commit berisiko proses crash sebelum pengiriman terjadi [2].
Solusinya pola Transactional Outbox. Alih-alih manggil layanan SMTP langsung, saya sisipkan baris baru ke tabel email_outbox di transaksi yang sama dengan perubahan status.
BEGIN;
UPDATE tickets SET status = 'diproses' WHERE id = 123;
INSERT INTO ticket_history (ticket_id, old_status, new_status)
VALUES (123, 'masuk', 'diproses');
INSERT INTO email_outbox (ticket_id, event_type, payload, status)
VALUES (123, 'status_changed', '{"to": "[email protected]"}', 'pending');
COMMIT;
Dengan bentuk begini, database yang menjamin catatan riwayat dan antrian email tersimpan secara atomik. Sebuah background worker terpisah yang ambil baris berstatus pending lalu kirimkan. Email jadi sifatnya eventual, bukan sinkron.
Satu catatan jujur soal pola ini: relay bisa kirim email yang sama dua kali, misalnya pas crash tepat setelah mengirim tapi sebelum sempat menandai barisnya selesai. Karena itu penerima harus idempoten, biasanya dengan mencatat ID pesan yang udah diproses [2]. Satu klik dua email yang saya kejar di awal artikel ternyata punya kembaran di sisi worker juga.
Saya pribadi memilih menerima kompleksitas tambahan dari background worker ini. Banyak yang mungkin berpikir nambah tabel outbox dan worker baru itu berlebihan untuk aplikasi sederhana. Tapi setelah lihat sendiri dampak ghost email yang bikin warga bingung, saya yakin ini trade-off yang wajib diambil. Konsistensi data jauh lebih penting daripada kesederhanaan kode yang semu.
Sumber