Jam dua pagi, aku masih menatap log yang sama untuk ketiga kalinya minggu itu. Satu baris aneh: dua user berbeda, satu invoice, angka yang tidak masuk akal. Bug ini punya kebiasaan menyebalkan—ia cuma muncul di jam sepi, hari Minggu, atau tengah malam. Tidak pernah waktu aku sedang menonton dengan saksama.
Proyek sampingan ini kecil saja: alat pencatat langganan untuk tim-tim kecil, kupikir orang butuh sesuatu yang lebih ringan dari spreadsheet tapi tidak seberat software akunting penuh. Sudah jalan tiga bulan, ada beberapa lusin pengguna berbayar, dan aku pegang semuanya sendirian—dari backend sampai balas email support. Jadi ketika satu user japri "kenapa tagihan saya dobel?", itu bukan sekadar tiket yang bisa didelegasikan. Itu jam dua pagiku.
Asumsi pertama yang salah total
Reaksi awalku klise: pasti ada duplikat request dari frontend, tombol submit yang bisa diklik dua kali. Aku tambahkan disable-on-click, aku tambahkan idempotency key sederhana di endpoint pembuatan invoice. Deploy, tunggu, dan—bug itu muncul lagi tiga hari kemudian, di user yang berbeda, dengan pola yang mirip tapi tidak identik.
Yang membuatku curiga: dua invoice itu punya timestamp yang bedanya cuma beberapa milidetik. Bukan human error yang mengklik dua kali—jeda antar klik manusia biasanya di kisaran ratusan milidetik ke detik. Ini terlalu rapat. Ini bau proses, bukan bau orang.
Aku sempat menyalahkan cron job pengingat pembayaran yang jalan tiap jam. Masuk akal di kepala: mungkin dua instance cron tumpang tindih. Aku cek scheduler-nya, cuma satu proses, tidak ada duplikasi jadwal. Jalan buntu lagi, dan aku mulai menyesal tidak menulis test yang lebih agresif dari awal.
Menemukan celah yang sebenarnya
Titik terang datang bukan dari membaca kode, tapi dari membaca infrastruktur. Aku pakai queue worker sederhana untuk memproses pembayaran—satu event masuk, worker menghitung status langganan, lalu menulis invoice kalau perlu. Untuk keandalan, aku set retry otomatis kalau worker gagal atau timeout. Itu keputusan yang kubuat berbulan-bulan lalu tanpa terlalu banyak berpikir, karena "retry itu bagus untuk resiliency", kan?
Masalahnya: retry-nya jalan berdasarkan timeout absolut, bukan berdasarkan apakah proses sebelumnya benar-benar mati. Kalau worker pertama lambat—katakanlah karena database sedang di bawah beban I/O gara-gara backup terjadwal—retry kedua akan start sebelum yang pertama selesai menulis. Dua proses, satu event, dua tulisan ke tabel invoice yang sama-sama menganggap dirinya yang pertama. Classic race condition, dan itu kenapa cuma muncul di jam sepi: backup terjadwal jalan subuh dan akhir pekan, saat trafik rendah tapi I/O database justru sedang sibuk sendiri.
Yang bikin aku merasa bodoh: aku pernah baca soal pola ini di buku desain sistem terdistribusi, bahkan pernah mengutipnya waktu diskusi dengan teman. Tapi antara tahu secara teori dan mengenali gejalanya di log produksi milik sendiri, jaraknya jauh sekali. Pengetahuan yang tidak pernah diuji itu murah.
Perbaikan yang lebih membosankan dari dugaanku
Aku pikir solusinya akan rumit—mungkin perlu distributed lock, mungkin perlu Redis buat koordinasi. Ternyata cukup dua perubahan kecil. Pertama, memberi setiap event sebuah kunci unik berdasarkan kombinasi user dan periode billing, lalu memastikan constraint unique di level database, bukan di level aplikasi. Aplikasi bisa salah asumsi; database yang menolak insert duplikat tidak bisa dibujuk.
Kedua, mengubah logika retry supaya memeriksa dulu apakah event itu sudah "diklaim" oleh worker lain sebelum mulai memproses, dengan window klaim yang realistis terhadap waktu proses terlama yang pernah tercatat. Bukan solusi elegan yang bisa dipamerkan di conference talk. Tapi ia bekerja, dan yang lebih penting, aku sekarang paham kenapa ia bekerja—bukan cuma menambal sampai gejalanya hilang.
Yang paling menampar dari proses ini bukan bug-nya, tapi betapa lama aku mencari di tempat yang salah karena terlalu percaya pada asumsi pertama. Begitu aku berhenti bertanya "kode mana yang salah" dan mulai bertanya "kondisi apa yang membuat dua eksekusi bisa saling menyalip", jawabannya muncul dalam hitungan jam, bukan minggu.
Takeaway
Bug yang intermiten dan sensitif terhadap waktu jarang soal logika yang salah tertulis—biasanya soal asumsi keliru tentang urutan eksekusi. Kalau sebuah masalah cuma muncul di kondisi tertentu (trafik sepi, load tinggi, jam tertentu), itu petunjuk kuat bahwa penyebabnya adalah interaksi antar proses, bukan satu baris kode yang bisa ditemukan dengan membaca ulang fungsi yang sama berkali-kali. Constraint di level database masih jadi jaring pengaman termurah yang aku punya—lebih murah daripada waktu tidur yang hilang gara-gara mengejar bug hantu jam dua pagi.
---
Sumber gambar: Chris Ried di Unsplash Sumber gambar: Ilya Pavlov di Unsplash
The conversation 0
No comments yet. Start the conversation.