Win minggu ini: berhasil melacak bug produksi yang awalnya terlihat sepele: beberapa user menerima notifikasi dua kali. Setelah ditelusuri, masalahnya bukan sekadar worker yang “jalan dua kali”, tetapi race condition pada job retry yang bertemu dengan update status job yang tidak atomik.

Kalau Anda sedang debug race condition pada job retry dan notifikasi ganda, inti masalah yang perlu dicari biasanya ada di tiga titik: siapa yang mengirim notifikasi, kapan job dianggap gagal atau selesai, dan apakah ada guard idempotency di level aplikasi maupun database. Tanpa tiga hal itu, retry yang sah bisa berubah menjadi duplikasi side effect.

Sudut pandang artikel ini sengaja dibuat seperti weekly retro: bukan cerita “sistem kami sempurna”, tetapi cerita tentang satu kemenangan kecil yang penting di backend engineering—menemukan akar bug produksi yang sulit direproduksi.

Gejala awal di produksi

Insiden mulai terlihat dari tiket support: sebagian kecil user mengaku menerima notifikasi yang sama dua kali dalam selang beberapa detik. Tidak semua request terdampak, dan metrik global juga tampak normal. Queue throughput stabil, error rate tidak melonjak drastis, dan tidak ada deploy besar sesaat sebelum kejadian.

Gejala yang tercatat:

  • User menerima notifikasi duplikat dengan payload identik.
  • Duplikasi hanya muncul pada sebagian job, bukan semua.
  • Log aplikasi menunjukkan ada job yang sempat timeout atau dianggap gagal, lalu berhasil pada retry.
  • Provider notifikasi tidak melaporkan request duplikat secara eksplisit karena request memang datang dua kali dari sistem internal.

Ini penting: ketika side effect terjadi di luar proses utama, misalnya mengirim email, push notification, webhook, atau SMS, bug duplikasi sering tampak seperti masalah provider. Padahal akar masalahnya justru di cara backend mengelola status job.

Timeline insiden: bagaimana bug ini terlihat dari luar

Urutan kejadian yang tampak di dashboard

  1. Aplikasi membuat job SendNotification untuk event tertentu.
  2. Worker A mengambil job tersebut dan mulai memproses.
  3. Pengiriman ke provider notifikasi lambat, atau respons balik terlambat.
  4. Sistem queue menganggap job gagal/timeout lalu menjadwalkan retry.
  5. Worker B mengambil retry job yang sama.
  6. Worker A sebenarnya tetap berhasil mengirim notifikasi, tetapi update status sukses datang terlambat.
  7. Worker B juga mengeksekusi pengiriman karena tidak melihat state final yang konsisten.
  8. User menerima dua notifikasi.

Kenapa data awal menyesatkan

Pada awal investigasi, semua orang cenderung menyalahkan salah satu dari hal berikut:

  • Provider notifikasi duplikat — ternyata tidak, karena request internal memang terkirim dua kali.
  • Bug UI atau user klik dua kali — tidak relevan, karena event sumber hanya satu.
  • Queue broker mengirim pesan ganda — mungkin terjadi pada beberapa sistem, tetapi di kasus ini bukan akar utamanya.

Data yang paling menyesatkan biasanya adalah log yang menunjukkan satu job “failed” lalu “retried successfully”. Dari permukaan, itu terdengar normal. Yang tidak langsung terlihat adalah bahwa eksekusi pertama ternyata sudah sempat menghasilkan side effect sebelum statusnya ditandai dengan benar.

Anatomi race condition pada job retry

Masalah inti terjadi ketika sistem memperlakukan operasi berikut sebagai langkah terpisah:

  1. Mengambil job.
  2. Mengirim notifikasi ke provider eksternal.
  3. Menyimpan status bahwa notifikasi sudah terkirim.

Jika langkah 2 berhasil tetapi langkah 3 terlambat, gagal, atau kalah balapan dengan retry, maka worker lain bisa menganggap notifikasi belum terkirim dan mengeksekusinya lagi.

Contoh alur yang bermasalah

Worker A: ambil job #9381
Worker A: cek notifications.status = 'pending'
Worker A: kirim ke provider  -- sukses, tapi respons lambat/log flush terlambat

Queue system: lease job habis / worker dianggap timeout
Queue system: jadwalkan retry

Worker B: ambil job #9381 (retry)
Worker B: cek notifications.status = 'pending'  -- karena Worker A belum update status
Worker B: kirim ke provider -- sukses lagi

Worker A: update notifications.status = 'sent'
Worker B: update notifications.status = 'sent'

Di sini bug bukan sekadar “ada retry”, karena retry sendiri adalah mekanisme yang valid. Bug muncul karena side effect eksternal tidak diproteksi oleh idempotency dan state transisi tidak dijaga secara atomik.

Pseudo-log yang membantu melihat pola

2026-07-21T10:00:01.120Z worker=A job_id=9381 attempt=1 status=start notification_id=7712
2026-07-21T10:00:01.125Z worker=A job_id=9381 attempt=1 action=provider_request request_id=req-abc
2026-07-21T10:00:06.200Z queue job_id=9381 attempt=1 status=lease_expired
2026-07-21T10:00:06.240Z worker=B job_id=9381 attempt=2 status=start notification_id=7712
2026-07-21T10:00:06.245Z worker=B job_id=9381 attempt=2 action=provider_request request_id=req-def
2026-07-21T10:00:06.510Z worker=A job_id=9381 attempt=1 action=provider_response result=success provider_msg_id=p-1001
2026-07-21T10:00:06.530Z worker=A job_id=9381 attempt=1 db_update status=sent
2026-07-21T10:00:06.820Z worker=B job_id=9381 attempt=2 action=provider_response result=success provider_msg_id=p-1002
2026-07-21T10:00:06.840Z worker=B job_id=9381 attempt=2 db_update status=sent

Dari log seperti ini, terlihat jelas bahwa ada dua request provider berbeda untuk satu notification_id yang sama. Tanpa korelasi job_id, attempt, dan notification_id, pola seperti ini mudah terlewat.

Root cause: kombinasi worker, retry, dan update status job

Dalam banyak implementasi backend, akar masalah ada pada satu atau lebih kondisi berikut:

  • Status diperbarui setelah side effect eksternal tanpa proteksi atomik.
  • Retry berjalan sebelum eksekusi sebelumnya benar-benar selesai.
  • Tidak ada idempotency key untuk operasi kirim notifikasi.
  • Tidak ada unique constraint yang memastikan satu event hanya menghasilkan satu catatan pengiriman final.
  • Timeout/lease queue terlalu agresif dibanding waktu respons provider.
  • Observability kurang baik, sehingga attempt pertama dan retry tampak seperti dua kejadian terpisah.

Poin pentingnya: status “sent” bukan sumber kebenaran yang cukup jika ditulis terlambat dan tidak dilindungi. Sistem butuh mekanisme yang mencegah dua worker merasa sama-sama berhak mengeksekusi side effect.

Langkah perbaikan yang benar-benar bisa diterapkan

1. Terapkan idempotency key pada operasi pengiriman

Ini lapisan pertahanan paling penting. Untuk setiap notifikasi, buat kunci idempotensi yang stabil, misalnya gabungan dari event_id, user_id, dan channel. Kunci ini harus dipakai secara konsisten baik di sistem internal maupun, jika didukung, saat memanggil provider eksternal.

idempotency_key = sha256("event:48291:user:1007:channel:push")

Kenapa ini bekerja? Karena walaupun worker mengeksekusi ulang, sistem tetap bisa mengenali bahwa side effect ini sebenarnya sudah pernah diminta. Idempotency membantu ketika retry adalah bagian normal dari sistem, bukan kondisi abnormal.

Trade-off:

  • Perlu desain key yang benar-benar merepresentasikan satu aksi unik.
  • Jika key terlalu luas, notifikasi yang seharusnya berbeda bisa tertahan.
  • Jika key terlalu sempit, duplikasi tetap lolos.

2. Tambahkan unique constraint di database

Jangan hanya mengandalkan pengecekan if not exists di level aplikasi. Dalam situasi balapan, dua worker bisa sama-sama lolos dari pengecekan itu. Database perlu menjadi pagar terakhir.

-- Contoh pseudo-SQL
CREATE UNIQUE INDEX uniq_notification_delivery
ON notification_deliveries (idempotency_key);

Atau jika model datanya berbasis event:

CREATE UNIQUE INDEX uniq_event_user_channel
ON notification_deliveries (event_id, user_id, channel);

Dengan constraint ini, ketika dua worker mencoba membuat record pengiriman yang sama, salah satunya akan gagal secara deterministik. Aplikasi kemudian bisa menangani konflik itu sebagai indikasi bahwa notifikasi sudah pernah diproses.

Kesalahan umum: membuat unique constraint di kolom yang tidak benar-benar unik secara bisnis, misalnya hanya berdasarkan job_id. Padahal retry bisa menghasilkan job attempt berbeda untuk aksi bisnis yang sama.

3. Gunakan locking atau transaksi untuk claim eksekusi

Sebelum mengirim notifikasi, worker perlu claim hak eksekusi secara atomik. Pendekatannya bisa berupa:

  • Row-level lock pada record notifikasi.
  • Atomic update dari status pending ke processing.
  • Distributed lock bila state tersebar dan benar-benar perlu sinkronisasi lintas proses.

Contoh pola atomic update:

-- Worker mencoba mengklaim notifikasi
UPDATE notifications
SET status = 'processing', claimed_by = :worker_id, claimed_at = NOW()
WHERE id = :notification_id
  AND status = 'pending';

-- lanjut kirim hanya jika affected_rows = 1

Kenapa ini lebih aman? Karena hanya satu worker yang berhasil mengubah status dari pending ke processing. Worker lain yang datang belakangan akan mendapatkan affected_rows = 0 dan tahu bahwa job sudah diambil oleh proses lain.

Catatan penting: transaksi database tidak bisa membuat request ke provider eksternal menjadi atomik. Jadi locking membantu mencegah eksekusi ganda, tetapi tetap perlu dipadukan dengan idempotency.

4. Pisahkan status teknis job dari status bisnis pengiriman

Salah satu sumber kebingungan adalah mencampur job queue state dengan business delivery state. Job boleh saja gagal dan retry, tetapi itu tidak selalu berarti notifikasi belum terkirim. Simpan keduanya secara terpisah.

  • Status job: queued, running, failed, retried, dead-letter.
  • Status delivery: pending, processing, sent, confirmed, canceled.

Dengan pemisahan ini, engineer tidak akan tergoda menyimpulkan “job failed berarti aman untuk kirim ulang”. Bisa jadi job gagal menulis status, padahal pengiriman ke provider sudah sukses.

5. Perbaiki observability: log, tracing, dan metrik korelatif

Bug race condition jarang bisa dijelaskan dari satu log baris. Yang dibutuhkan adalah jejak yang menghubungkan event bisnis, job queue, dan request keluar.

Minimal, setiap log terkait harus memiliki:

  • notification_id atau event_id
  • job_id
  • attempt
  • worker_id
  • idempotency_key
  • provider_request_id jika ada

Metrik yang berguna:

  • Jumlah retry per jenis job.
  • Jumlah conflict pada unique constraint.
  • Selisih waktu antara provider_request dan db_update sent.
  • Jumlah notifikasi dengan lebih dari satu request keluar untuk id yang sama.

Kalau Anda hanya punya log “job started” dan “job failed”, investigasi race condition akan terasa seperti menebak-nebak.

6. Sesuaikan timeout, visibility window, atau lease job

Kadang akar masalah dipicu oleh konfigurasi queue yang tidak selaras dengan realita latency provider. Jika lease terlalu pendek, worker pertama belum selesai tetapi job sudah dianggap layak di-retry.

Yang perlu dievaluasi:

  • Apakah timeout worker realistis untuk operasi jaringan eksternal?
  • Apakah visibility timeout/lease lebih pendek dari durasi normal provider?
  • Apakah job melakukan terlalu banyak kerja dalam satu eksekusi?

Namun, jangan menjadikan peningkatan timeout sebagai satu-satunya solusi. Itu hanya mengurangi probabilitas race, bukan menghilangkan akar masalah.

Contoh desain alur yang lebih aman

Berikut pola yang lebih tahan terhadap retry:

  1. Terima event bisnis.
  2. Buat atau cari record delivery berdasarkan idempotency_key.
  3. Coba claim record dengan atomic update atau lock.
  4. Jika gagal claim, hentikan proses karena worker lain sudah menangani.
  5. Kirim ke provider dengan idempotency_key yang sama jika didukung.
  6. Simpan hasil pengiriman dan provider_message_id.
  7. Jika terjadi retry, worker baru membaca state terakhir dan tidak mengirim ulang jika sudah terklaim atau terkirim.
function processNotification(notificationId) {
  record = findDelivery(notificationId)

  if (record.status == 'sent') {
    return 'noop_already_sent'
  }

  claimed = atomicClaim(record.id, from='pending', to='processing')
  if (!claimed) {
    return 'noop_claimed_by_other_worker'
  }

  try {
    response = sendToProvider({
      payload: record.payload,
      idempotencyKey: record.idempotency_key
    })

    markSent(record.id, response.provider_message_id)
    return 'sent'
  } catch (err) {
    markPendingOrFailed(record.id, err)
    throw err
  }
}

Pola di atas masih generik, tetapi prinsipnya jelas: claim dulu, kirim kemudian, dan pastikan retry membaca state bisnis yang benar.

Checklist diagnosis saat notifikasi terkirim ganda

Saat menerima laporan duplikasi, berikut urutan diagnosis yang praktis:

  1. Pastikan duplikasi benar-benar terjadi di sistem Anda
    Bandingkan payload, timestamp, recipient, dan request keluar ke provider.
  2. Cari semua attempt untuk entity bisnis yang sama
    Jangan berhenti di job_id; cari berdasarkan event_id, notification_id, atau idempotency_key.
  3. Lihat apakah ada retry yang overlap dengan eksekusi sebelumnya
    Fokus pada lease expired, timeout, atau worker restart.
  4. Periksa urutan “provider success” vs “db status update”
    Jika provider sukses lebih dulu dan status terlambat, Anda punya celah race.
  5. Cek ada atau tidaknya unique constraint
    Jika tidak ada, dua worker bisa menulis state yang seolah valid.
  6. Audit penggunaan lock atau claim state
    Apakah worker benar-benar harus memenangkan hak eksekusi sebelum mengirim?
  7. Evaluasi timeout queue dan latency provider
    Konfigurasi yang terlalu ketat sering memicu retry palsu.
  8. Periksa apakah provider mendukung idempotency key
    Kalau ya, gunakan. Kalau tidak, tanggung jawab deduplikasi harus lebih kuat di sisi internal.

Regression test yang sebaiknya ditambahkan

Setelah bug ditemukan, kemenangan berikutnya adalah memastikan ia tidak kembali dalam bentuk baru. Untuk kasus ini, regression test perlu mensimulasikan retry dan overlap antaworker, bukan hanya jalur sukses biasa.

Skenario uji minimum

  • Job attempt pertama berhasil mengirim, tetapi gagal menulis status.
  • Retry berjalan sebelum status sukses tersimpan.
  • Dua worker mencoba memproses notifikasi yang sama secara paralel.
  • Insert record delivery yang sama terjadi hampir bersamaan.
  • Provider timeout tetapi sebenarnya request sudah diterima.

Contoh pseudo-test

test("retry tidak mengirim notifikasi ganda saat attempt pertama terlambat update status", () => {
  seedNotification({ id: 7712, status: 'pending', idempotency_key: 'k-7712' })

  mockProvider
    .onFirstCall().returnsSuccessAfterDelay()
    .onSecondCall().returnsSuccess()

  runWorkerAUntilProviderSuccessButBeforeDbCommit()
  triggerRetryAndRunWorkerB()
  resumeWorkerACommit()

  assertProviderCalledAtMostOnceForIdempotencyKey('k-7712')
  assertFinalStatus('7712', 'sent')
})

Walaupun implementasi nyatanya akan berbeda tergantung stack, tujuan test ini sama: memaksa kondisi balapan yang dulu hanya muncul di produksi menjadi reproduksibel di lingkungan uji.

Pelajaran praktis untuk developer backend

  • Retry bukan bug. Yang menjadi bug adalah side effect yang tidak idempotent.
  • Status aplikasi yang terlambat ditulis bisa lebih berbahaya daripada error yang eksplisit.
  • “Cek dulu lalu insert” tidak cukup aman untuk concurrency. Pakai constraint database atau atomic operation.
  • Queue state dan business state harus dipisah. Job gagal tidak selalu berarti aksi bisnis gagal.
  • Observability harus dirancang, bukan ditambahkan belakangan. Tanpa korelasi ID yang konsisten, bug balapan sangat mahal untuk diburu.
  • Timeout yang salah bisa menciptakan retry palsu. Tetapi memperbesar timeout saja bukan solusi struktural.

Penutup

Bug notifikasi ganda setelah job retry sering terlihat seperti masalah kecil, padahal ia membuka isu desain yang lebih dalam: bagaimana sistem memproses side effect di bawah concurrency dan failure. Debugging kasus ini memberi satu pelajaran yang sangat berguna untuk backend engineering: jangan pernah menganggap “berhasil kirim” dan “berhasil mencatat bahwa sudah terkirim” sebagai hal yang sama.

Kalau minggu ini Anda berhasil melacak bug produksi seperti ini, itu memang layak disebut win. Bukan karena bug-nya menarik, tetapi karena memahami race condition antara worker, retry, dan update status job biasanya membuat sistem jauh lebih andal setelahnya.