Bug duplikasi email karena retry adalah masalah backend yang sering terlihat sepele, tetapi dampaknya nyata: pengguna menerima email dua kali, tim support kebanjiran tiket, dan kepercayaan terhadap sistem menurun. Dalam banyak kasus, akar masalahnya bukan SMTP provider yang "aneh", melainkan alur request-job-worker yang tidak dirancang idempoten saat terjadi timeout, crash, atau retry otomatis.
Pada studi kasus ini, kita akan membedah proses debugging backend untuk melacak email notifikasi yang terkirim ganda. Fokusnya bukan hanya pada perbaikan akhir, tetapi pada cara berpikir saat investigasi: membaca gejala produksi, memverifikasi hipotesis, menemukan root cause, lalu menutup celah dengan desain yang lebih aman.
Konteks sistem dan alur yang terlihat normal
Bayangkan sebuah backend yang mengirim email notifikasi setelah pengguna menyelesaikan checkout. API menerima request, menyimpan data transaksi, lalu mendorong job ke queue. Worker queue mengambil job tersebut dan memanggil layanan pengiriman email.
Alur request-job-worker
- Client memanggil endpoint checkout.
- Backend menyimpan order ke database.
- Backend membuat job SendOrderEmail ke queue.
- Worker memproses job dan memanggil email provider.
- Jika gagal atau timeout, job di-retry oleh sistem queue.
Secara arsitektur, pola ini wajar. Masalah muncul ketika langkah 4 dan 5 tidak punya mekanisme untuk membedakan job baru dari job yang sama yang sedang diulang.
// API handler (pseudocode)
function checkout(request) {
order = saveOrder(request.payload)
queue.publish("SendOrderEmail", {
orderId: order.id,
email: order.customerEmail
})
return 200
}
// Worker (pseudocode)
function handleSendOrderEmail(job) {
emailProvider.send({
to: job.email,
template: "order-confirmation",
data: { orderId: job.orderId }
})
}
Potongan di atas tampak bersih, tetapi ada celah besar: tidak ada status pengiriman, tidak ada idempotency key, dan tidak ada proteksi duplikasi di database.
Gejala di produksi: email terkirim ganda, tetapi tidak selalu
Kasus seperti ini jarang muncul sebagai kegagalan total. Justru yang membuatnya sulit adalah sifatnya yang sporadis. Sebagian pengguna menerima satu email, sebagian dua, dan pada beban tertentu jumlahnya meningkat.
Gejala yang biasanya terlihat
- Tiket support: pengguna melaporkan email konfirmasi diterima dua kali.
- Metrics provider email menunjukkan lonjakan jumlah send yang tidak proporsional dengan jumlah order.
- Queue worker memiliki retry count yang meningkat, tetapi error rate aplikasi tidak terlalu tinggi.
- Log aplikasi menampilkan beberapa eksekusi job yang identik untuk orderId yang sama.
Pola ini penting: jika email ganda hanya muncul pada saat retry meningkat, maka dugaan kuat mengarah ke worker yang memproses ulang job yang secara logika seharusnya cukup sekali.
Indikator di log dan metrics
Saat debugging backend, jangan langsung membaca log mentah tanpa struktur. Cari korelasi antara:
- orderId atau notificationId
- jobId dan jumlah retry
- timestamp pengiriman email
- response time atau timeout saat memanggil provider
- acknowledgement dari queue worker
Contoh pola log yang mencurigakan:
[10:15:02.100] job started type=SendOrderEmail jobId=9f1 orderId=ORD-1042 attempt=1
[10:15:05.400] email provider request timeout orderId=ORD-1042
[10:15:05.401] worker process restarted unexpectedly
[10:15:08.120] job started type=SendOrderEmail jobId=9f1 orderId=ORD-1042 attempt=2
[10:15:09.050] email sent orderId=ORD-1042 providerMessageId=abc-002
Log seperti ini belum cukup membuktikan duplikasi, tetapi memberi petunjuk kuat: attempt pertama mungkin sebenarnya sudah sempat mengirim email sebelum timeout atau sebelum worker sempat menandai job selesai.
Hipotesis awal yang keliru dan kenapa sering menyesatkan
Dalam insiden nyata, tim biasanya punya beberapa dugaan awal. Sebagian masuk akal, tetapi salah fokus.
Hipotesis yang sering muncul
- Provider email mengirim dua kali. Bisa saja, tetapi lebih jarang dibanding bug di sisi kita.
- Endpoint checkout dipanggil dua kali oleh frontend. Perlu dicek, tetapi jika order hanya satu dan job retry meningkat, ini bukan akar utama.
- User mengklik tombol dua kali. Relevan untuk transaksi, tetapi tidak menjelaskan kenapa duplicate send terjadi pada worker.
- Queue system rusak. Queue justru sering bekerja sesuai desain: jika job tidak di-ack dengan benar, ia akan diulang.
Kenapa hipotesis itu menyesatkan? Karena gejala utama bukan sekadar "email dobel", melainkan pengiriman email tidak dirancang aman terhadap retry. Retry sendiri adalah perilaku normal di sistem terdistribusi.
Investigasi yang sistematis untuk menemukan root cause
Pendekatan terbaik adalah menelusuri satu kejadian konkret dari ujung ke ujung, bukan menebak dari dashboard global.
1. Pilih satu kasus nyata
Ambil satu orderId yang dilaporkan menerima dua email. Cari semua jejak terkait di:
- request log API
- database order dan notification
- queue log
- worker log
- provider log jika tersedia
2. Verifikasi apakah event awal terjadi satu kali atau lebih
Pastikan request checkout memang hanya sekali. Jika order tercatat satu kali, tetapi email terkirim dua kali, fokuskan investigasi ke tahap asynchronous setelah order dibuat.
3. Cocokkan job execution dengan hasil akhir
Lihat apakah ada lebih dari satu attempt untuk job yang sama. Banyak sistem queue akan mengulang job jika:
- worker crash di tengah proses
- handler melempar exception
- timeout melebihi batas visibility/lease
- ack gagal dikirim ke broker
4. Cari titik non-idempoten
Pertanyaan utamanya: jika job yang sama diproses dua kali, apa yang mencegah email terkirim dua kali? Jika jawabannya "tidak ada", maka akar masalah sudah dekat.
5. Reproduksi dalam lingkungan terkontrol
Buat simulasi worker yang memanggil provider, lalu paksa kondisi timeout setelah request keluar tetapi sebelum status sukses tersimpan. Ini skenario klasik yang sulit terlihat tanpa pengujian terarah.
function handleSendOrderEmail(job) {
emailProvider.send(...)
// Misalnya proses mati di sini sebelum update status/ack
if (simulateCrashAfterSend()) {
terminateProcess()
}
markNotificationSent(job.orderId)
}
Jika setelah worker restart email terkirim lagi untuk order yang sama, Anda telah berhasil mereproduksi bug inti.
Root cause utama: retry terjadi pada operasi yang tidak idempoten
Pada studi kasus ini, akar masalahnya adalah:
Worker mengirim email terlebih dahulu, tetapi tidak memiliki mekanisme idempoten untuk menandai bahwa notifikasi untuk entitas tertentu sudah pernah dikirim. Saat terjadi timeout atau crash sebelum status tersimpan atau job di-ack, sistem queue melakukan retry dan email dikirim ulang.
Ini bukan sekadar bug implementasi kecil. Ini adalah konsekuensi desain yang mengasumsikan bahwa sebuah job hanya akan diproses sekali. Di sistem terdistribusi, asumsi itu tidak aman.
Pola bug yang memicu duplikasi
- Send-then-save: kirim email dulu, baru simpan status sukses.
- Retry tanpa deduplication: queue retry aktif, tetapi handler tidak memeriksa apakah aksi sudah pernah dilakukan.
- Status terlalu generik: hanya ada status job, tidak ada status notifikasi per entitas bisnis.
- Tidak ada constraint unik: database mengizinkan dua record pengiriman untuk kombinasi yang sama.
- Observability lemah: tidak ada korelasi antara order, job, dan provider message.
Perbaikan praktis yang benar-benar menutup celah
Solusi yang baik biasanya berlapis. Jangan mengandalkan satu mekanisme saja.
1. Gunakan idempotency key untuk operasi pengiriman
Setiap notifikasi harus punya identitas bisnis yang stabil, misalnya gabungan event type + orderId + recipient. Kunci ini dipakai untuk memastikan bahwa retry dari event yang sama tidak menghasilkan pengiriman baru.
idempotencyKey = `order-confirmation:${orderId}:${recipientEmail}`
Penyimpanannya bisa di tabel notifikasi atau storage deduplikasi lain yang tahan terhadap restart proses.
2. Simpan status pengiriman secara eksplisit
Daripada sekadar mem-publish job dan berharap worker sukses, buat model status yang jelas, misalnya:
- pending
- sending
- sent
- failed
Yang penting, status ini terkait dengan entitas bisnis, bukan hanya lifecycle job queue.
// pseudocode yang lebih aman
function handleSendOrderEmail(job) {
key = `order-confirmation:${job.orderId}:${job.email}`
notification = findOrCreateNotification(key, {
orderId: job.orderId,
email: job.email,
status: "pending"
})
if (notification.status == "sent") {
return
}
lock(notification.id)
notification = reload(notification.id)
if (notification.status == "sent") {
unlock(notification.id)
return
}
updateStatus(notification.id, "sending")
providerResponse = emailProvider.send({
to: job.email,
template: "order-confirmation",
data: { orderId: job.orderId },
idempotencyKey: key
})
updateNotification(notification.id, {
status: "sent",
providerMessageId: providerResponse.messageId,
sentAt: now()
})
unlock(notification.id)
}
Catatan penting: bila provider mendukung idempotency key, gunakan. Jika tidak, Anda tetap perlu deduplikasi di sisi aplikasi sendiri.
3. Tambahkan unique constraint di database
Constraint unik adalah pagar terakhir agar dua proses paralel tidak membuat dua record notifikasi untuk event yang sama.
-- contoh konsep, sesuaikan dengan DBMS yang digunakan
CREATE UNIQUE INDEX uniq_notification_key
ON notification_deliveries (idempotency_key);
Dengan ini, jika dua worker berlomba menulis record yang sama, salah satunya akan gagal di level database. Aplikasi kemudian bisa menangani konflik itu sebagai sinyal bahwa notifikasi sudah ada.
4. Hindari pola send-then-save tanpa proteksi
Masalah paling berbahaya muncul ketika sistem mengirim keluar dulu, lalu baru mencatat hasilnya. Jika memungkinkan, buat alur yang menyimpan intent pengiriman lebih dulu, lalu worker hanya memproses item yang sudah punya identitas unik dan status yang dapat dilanjutkan dengan aman.
5. Tingkatkan observability
Debugging backend untuk bug seperti ini jauh lebih mudah jika sejak awal ada korelasi data yang konsisten. Minimal, sertakan field berikut di log terstruktur:
- orderId
- notificationId
- idempotencyKey
- jobId
- attempt
- providerMessageId
- status transition
Tambahkan juga metrics seperti:
- jumlah job retry per jenis notifikasi
- jumlah duplicate suppression
- rasio sent vs failed vs retried
- latency ke provider email
Metrics duplicate suppression sangat berguna. Jika nilainya naik, artinya mekanisme idempoten bekerja dan sekaligus memberi sinyal ada retry atau concurrency issue yang perlu dipantau.
Trade-off dan batasan solusi
Idempotency key bukan obat untuk semua hal
Idempotency key efektif jika definisi "aksi yang sama" jelas. Untuk email konfirmasi order, ini mudah. Untuk email yang memang boleh dikirim berkali-kali dengan konten berbeda, desain key-nya harus lebih hati-hati.
Unique constraint membantu, tetapi tidak menggantikan state machine
Constraint unik mencegah duplikasi record, tetapi tidak otomatis menyelesaikan status setengah jalan, misalnya saat provider menerima request tetapi aplikasi tidak sempat menyimpan respons.
Status sending bisa tersangkut
Jika worker mati setelah mengubah status menjadi sending, Anda perlu strategi recovery, misalnya timeout status dan job reconciler yang memeriksa notifikasi menggantung.
Strategi testing regresi agar bug tidak kembali
Perbaikan belum selesai sebelum ada tes yang membuktikan skenario retry aman.
1. Unit test untuk idempotent handler
Uji bahwa jika handler dipanggil dua kali dengan payload yang sama, provider hanya dipanggil sekali atau hasil akhirnya tetap satu notifikasi terkirim.
2. Integration test dengan simulasi crash
Ini yang paling penting. Simulasikan kondisi:
- worker mulai memproses job
- provider menerima request
- proses mati sebelum update status atau ack
- job di-retry
- verifikasi tidak ada email kedua yang terkirim
3. Concurrency test
Jalankan dua worker yang mencoba memproses notifikasi yang sama secara bersamaan. Verifikasi unique constraint dan lock bekerja sesuai harapan.
4. Observability test
Pastikan log dan metrics yang dibutuhkan benar-benar muncul. Banyak tim baru sadar logging kurang ketika insiden sudah terjadi.
// contoh skenario uji tingkat tinggi
scenario "retry job tidak mengirim email ganda" {
seed notification pending with idempotencyKey
mock provider send success once
run worker and simulate crash after provider call
rerun same job
assert provider.send called once or duplicate suppressed
assert notification.status == "sent"
}
Checklist pencegahan untuk developer backend
- Anggap semua job queue bisa diproses lebih dari sekali.
- Jangan desain handler yang bergantung pada asumsi exactly once.
- Gunakan idempotency key untuk operasi keluar seperti email, pembayaran, atau webhook.
- Simpan status pengiriman yang eksplisit dan bisa direkonsiliasi.
- Tambahkan unique constraint pada identitas notifikasi.
- Log-kan correlation ID, job attempt, dan provider response ID.
- Pantau retry rate, duplicate suppression, dan stuck sending state.
- Uji skenario timeout, crash, dan concurrent processing.
- Bedakan status job queue dari status bisnis notifikasi.
- Siapkan proses recovery untuk status menggantung.
Pelajaran yang bisa langsung diterapkan
Pelajaran utama dari debugging backend seperti ini sederhana tetapi penting: retry adalah fitur normal, bukan anomali. Begitu sistem Anda memakai queue, worker, network call, atau service eksternal, maka kemungkinan proses yang sama dijalankan ulang harus dianggap sebagai kondisi default.
Karena itu, perbaikannya bukan sekadar "menurunkan retry count" atau "menambah timeout". Solusi yang benar adalah membuat operasi pengiriman email aman terhadap pengulangan. Kombinasi idempotency key, status pengiriman yang eksplisit, unique constraint, dan observability yang baik akan jauh lebih efektif dibanding menebak-nebak dari log saat insiden terjadi.
Jika saat ini backend Anda mengirim email, webhook, atau notifikasi lain lewat queue, anggap artikel ini sebagai audit checklist. Cari handler yang masih memakai pola send-then-save, periksa apakah sudah ada identitas unik per notifikasi, dan buat satu tes regresi yang mensimulasikan retry. Sering kali, satu tes seperti itu cukup untuk mencegah insiden yang sama terulang di produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!