Job diproses dua kali setelah deploy zero-downtime biasanya bukan bug acak. Dalam banyak kasus, penyebabnya adalah overlap worker lama dan worker baru, mekanisme ack atau visibility timeout yang tidak selaras dengan durasi proses, atau proteksi lock yang terlihat aman tetapi sebenarnya tidak atomik.

Kalau gejala yang muncul adalah email terkirim dua kali, notifikasi dobel, stok terpotong dua kali, atau log worker dari dua instance saling tumpang tindih pada waktu deploy, maka arah investigasinya harus langsung ke alur konsumsi queue dan lifecycle worker, bukan hanya ke kode bisnis. Artikel ini membahas studi kasus debugging race condition worker secara praktis: dari gejala awal, hipotesis yang sempat salah, root cause yang sebenarnya, hingga checklist perbaikan agar bug yang sama tidak terulang.

Catatan kecil: developer tools sekarang makin cepat dipakai harian, mirip semangat efisiensi yang sering dibicarakan saat rilis alat kerja seperti Emacs 31. Poinnya bukan editornya, melainkan pengingat bahwa alur observasi dan debugging yang cepat sangat menentukan saat insiden seperti ini terjadi.

Gejala awal: semua terlihat normal, sampai efek samping mulai dobel

Kasusnya biasanya dimulai dari laporan yang tampak tidak saling berhubungan:

  • Email konfirmasi pesanan terkirim dua kali.
  • Push notification muncul ganda.
  • Stok inventory berkurang dua kali untuk satu order.
  • Log menunjukkan job yang sama diproses oleh dua worker berbeda dalam rentang beberapa detik.
  • Lonjakan retry queue sesaat setelah deploy, lalu kembali normal.

Karena deploy dilakukan dengan strategi zero-downtime, tim sering berasumsi bahwa aplikasi tetap aman karena tidak ada jeda layanan. Di sinilah jebakannya: zero-downtime untuk web request tidak otomatis berarti zero-duplication untuk background worker.

Pada worker queue, deploy yang aman harus memikirkan dua hal:

  1. Transisi worker lama ke worker baru tanpa ada job yang diambil bersamaan.
  2. Ketahanan job terhadap pemrosesan ulang bila queue mengirim ulang pesan karena timeout, crash, atau ack terlambat.

Hipotesis yang sempat salah

Sebelum menemukan akar masalah, ada beberapa dugaan yang sering muncul dan cukup masuk akal, tetapi ternyata salah atau hanya sebagian benar.

1. Bug ada di endpoint API atau user klik dua kali

Ini dugaan umum ketika efek sampingnya berupa order ganda atau notifikasi ganda. Namun jika payload request unik, order ID sama, dan event producer hanya mencatat satu publish ke queue, maka sumber masalah kemungkinan bukan dari sisi API.

2. Retry otomatis dari aplikasi terlalu agresif

Retry memang bisa memicu efek dobel, tetapi perlu dibedakan antara:

  • retry karena exception, dan
  • redelivery dari queue karena job dianggap tidak selesai.

Kalau log aplikasi tidak menunjukkan exception, tetapi job yang sama muncul lagi dengan jeda sesuai timeout queue, maka akar masalahnya lebih dekat ke mekanisme delivery queue daripada kode retry bisnis.

3. Database transaction belum konsisten

Transaksi yang buruk memang bisa menyebabkan stok salah, tetapi jika terlihat dua proses worker berbeda sama-sama menjalankan handler untuk job yang sama, maka transaction hanya memperparah dampak, bukan sumber utamanya.

4. Caching atau lock sudah cukup melindungi

Sering ada key seperti processing:order:123 di Redis lalu tim merasa aman. Masalahnya, bila implementasi lock tidak atomik, TTL terlalu pendek, atau pelepasan lock tidak memverifikasi pemiliknya, dua worker tetap bisa lolos bersamaan.

Root cause yang paling sering: overlap worker, ack tidak tepat, atau lock tidak atomik

Dalam studi kasus seperti ini, ada tiga akar masalah yang paling sering muncul. Kadang satu sistem terkena lebih dari satu sekaligus.

1. Dua worker aktif bersamaan saat deploy zero-downtime

Pada deploy zero-downtime, instance baru dinaikkan sebelum instance lama benar-benar berhenti. Untuk web server ini normal. Untuk worker queue, ini berbahaya jika:

  • worker lama masih menarik job baru sebelum shutdown,
  • worker baru langsung mulai polling queue,
  • tidak ada mekanisme drain atau graceful shutdown,
  • satu job bisa dipublish atau terlihat ulang saat statusnya belum final.

Akibatnya, selama beberapa detik atau menit, dua generasi worker hidup bersamaan dan keduanya merasa berhak memproses job yang tersedia.

2. Visibility timeout lebih pendek dari durasi proses

Pada sistem queue yang memakai konsep lease atau visibility timeout, pesan yang sudah diambil worker akan disembunyikan sementara. Jika worker belum menyelesaikan dan mengakui job sebelum timeout habis, queue bisa menganggap pesan itu tidak selesai lalu memberikannya lagi ke worker lain.

Contoh skenario:

  • Job butuh 90 detik karena memanggil layanan eksternal.
  • Visibility timeout hanya 30 detik.
  • Worker A mulai memproses.
  • Pada detik ke-31, queue menampilkan pesan yang sama lagi.
  • Worker B mengambil job yang sama.
  • Efek samping pun terjadi dua kali.

Ini sering tidak terlihat di staging karena beban ringan membuat job selesai cepat, tetapi saat produksi pasca deploy latensi eksternal sedikit naik dan timeout jadi tidak cukup.

3. Lock terlihat ada, tetapi tidak atomik

Contoh antipola yang sering ditemukan:

if (!cache.has(lockKey)) {
  cache.set(lockKey, true, ttl=30)
  processJob()
  cache.delete(lockKey)
}

Masalahnya adalah operasi has dan set terpisah. Dua worker bisa membaca hasil yang sama sebelum salah satu sempat menulis lock. Ini adalah race condition klasik.

Solusi yang benar harus memakai operasi atomik, misalnya set-if-not-exists dengan TTL dalam satu langkah, atau mekanisme lock terdistribusi yang memang dirancang untuk itu.

Langkah investigasi yang benar-benar membantu

Debugging race condition worker tidak cukup dengan membaca stack trace. Anda perlu menggabungkan log terstruktur, correlation ID, metrik queue, dan timeline deploy.

1. Tambahkan correlation ID dari producer sampai consumer

Setiap job sebaiknya membawa identifier yang stabil, misalnya:

  • job_id dari sistem queue,
  • event_id dari publisher,
  • order_id atau entity ID bisnis,
  • deployment_id atau versi aplikasi yang memproses.

Tujuannya agar Anda bisa menjawab pertanyaan penting:

  • Apakah job dipublish sekali atau dua kali?
  • Apakah job yang sama diproses oleh dua worker berbeda?
  • Apakah duplikasi selalu terjadi saat pergantian deployment?
  • Worker versi mana yang melakukan efek samping pertama dan kedua?

Contoh log terstruktur:

{
  "event": "job_started",
  "job_id": "q-7812",
  "event_id": "evt-20240618-991",
  "order_id": "ord-123",
  "worker_id": "worker-a-17",
  "deployment_id": "release-2024-06-18.2",
  "attempt": 1,
  "received_at": "2024-06-18T10:00:01Z"
}

{
  "event": "job_started",
  "job_id": "q-7812",
  "event_id": "evt-20240618-991",
  "order_id": "ord-123",
  "worker_id": "worker-b-03",
  "deployment_id": "release-2024-06-18.3",
  "attempt": 2,
  "received_at": "2024-06-18T10:00:34Z"
}

Dari dua log ini saja, kita sudah punya sinyal kuat bahwa pesan yang sama muncul lagi setelah sekitar 33 detik. Ini mengarah ke timeout, redelivery, atau ack yang tidak selesai.

2. Cocokkan timeline deploy dengan timeline job

Jangan hanya mencari di log aplikasi. Ambil juga data dari pipeline deploy dan orkestrator:

  • jam instance baru mulai menerima trafik,
  • jam worker baru mulai polling queue,
  • jam worker lama menerima sinyal shutdown,
  • berapa lama worker lama tetap hidup sebelum dipaksa mati.

Sering kali pola yang terlihat adalah: duplikasi hanya muncul pada jendela 1-3 menit setelah deploy. Ini indikasi kuat bahwa masalah ada pada transisi worker, bukan pada kode bisnis murni.

3. Lihat metrik queue, bukan hanya log aplikasi

Metrik yang relevan biasanya meliputi:

  • jumlah pesan masuk,
  • jumlah pesan diterima consumer,
  • jumlah ack sukses,
  • jumlah timeout atau redelivery,
  • usia pesan tertua,
  • durasi proses per job.

Jika jumlah publish stabil tetapi jumlah receive melonjak pasca deploy, berarti ada job yang dibaca ulang. Jika durasi proses p95 mendekati atau melewati visibility timeout, itu red flag yang sangat jelas.

4. Audit kapan efek samping dilakukan

Banyak sistem mengirim email, memotong stok, dan menulis status akhir dalam urutan yang tidak aman. Misalnya:

  1. ambil job,
  2. kirim email,
  3. potong stok,
  4. baru update status bahwa job selesai.

Jika worker mati atau timeout di tengah, queue akan mengirim ulang job, dan efek samping bisa terulang karena tidak ada penanda idempoten yang kuat.

Tanyakan secara spesifik:

  • Apakah email dikirim sebelum status dipersist?
  • Apakah stok dipotong dengan operasi yang aman terhadap duplikasi?
  • Apakah sistem eksternal menerima idempotency key?

5. Reproduksi lokal dengan dua worker dan timeout pendek

Race condition sering sulit dibuktikan jika hanya menebak dari produksi. Reproduksi lokal jauh lebih efektif.

Skema reproduksi yang sederhana:

  1. Jalankan dua worker secara paralel.
  2. Buat satu job yang sengaja tidur lebih lama dari visibility timeout.
  3. Tambahkan log saat job dimulai, sebelum efek samping, dan saat ack.
  4. Lakukan simulasi deploy: hidupkan worker baru saat worker lama belum benar-benar berhenti.

Pseudocode handler untuk memancing kondisi:

function handle(job) {
  log("job_started", {
    jobId: job.id,
    eventId: job.eventId,
    workerId: env.WORKER_ID,
  })

  acquireLockOrFail("job:" + job.eventId)

  sleep(45) // simulasi proses lambat atau dependency eksternal

  sendEmail(job.orderId)
  deductStock(job.orderId)

  ack(job)
  releaseLock("job:" + job.eventId)
}

Kalau visibility timeout Anda 30 detik, worker kedua berpeluang menerima job yang sama sebelum worker pertama selesai. Bila lock tidak atomik atau TTL lock habis terlalu cepat, reproduksi ini biasanya langsung memperlihatkan sumber masalah.

Mengapa bug ini lolos dari pengujian biasa

Ada beberapa alasan mengapa bug job ganda setelah deploy zero-downtime sering lolos:

  • Test lokal hanya memakai satu worker.
  • Staging tidak meniru lifecycle deploy produksi.
  • Durasi proses di lingkungan uji jauh lebih cepat dari produksi.
  • Tidak ada simulasi redelivery atau force shutdown.
  • Log tidak membawa correlation ID sehingga duplikasi terlihat seperti dua kejadian terpisah.

Bug jenis ini berada di perbatasan aplikasi, queue, dan operasi deploy. Karena itu ia sering tidak tertangkap oleh unit test biasa. Yang dibutuhkan adalah kombinasi integration test, observability, dan simulasi kegagalan.

Perbaikan yang benar: jangan hanya menambah sleep atau memperpanjang timeout

Setelah akar masalah ditemukan, solusi yang baik biasanya berlapis. Menambah timeout saja kadang mengurangi frekuensi, tetapi tidak menghilangkan risiko fundamental.

1. Terapkan idempotency key pada efek samping

Ini pertahanan paling penting. Jika satu job terproses dua kali, sistem tetap menghasilkan efek yang sama satu kali saja.

Contoh pendekatan:

  • Gunakan event_id atau order_id + action sebagai idempotency key.
  • Simpan rekam jejak pemrosesan di database dengan unique constraint.
  • Sebelum kirim email atau potong stok, cek dan tulis status secara atomik.

Contoh pola di database:

BEGIN;

INSERT INTO processed_events (idempotency_key, processed_at)
VALUES (:key, NOW())
ON CONFLICT (idempotency_key) DO NOTHING;

-- lanjutkan hanya jika insert berhasil

COMMIT;

Dengan pola ini, meskipun queue mengirim ulang job, efek samping tidak dieksekusi dua kali.

2. Gunakan distributed lock yang benar-benar atomik

Lock berguna untuk mengurangi kontensi, tetapi jangan dijadikan satu-satunya pertahanan. Pastikan:

  • akuisisi lock atomik,
  • ada TTL untuk mencegah deadlock,
  • pelepasan lock memverifikasi pemilik lock,
  • TTL cukup lebih panjang dari durasi proses atau bisa diperpanjang selama proses berjalan.

Lock cocok untuk mencegah dua worker mengerjakan resource yang sama secara paralel, tetapi idempotency tetap wajib karena lock bisa gagal, kedaluwarsa, atau terlewati saat crash.

3. Perbaiki graceful shutdown worker

Saat deploy zero-downtime, worker lama seharusnya:

  1. berhenti menarik job baru,
  2. menyelesaikan job yang sedang berjalan,
  3. melakukan ack dengan benar,
  4. baru kemudian berhenti.

Hindari pola di mana orkestrator mengirim sinyal stop lalu beberapa detik kemudian mematikan proses secara paksa sebelum job selesai. Jika itu tidak bisa dihindari, pastikan job aman terhadap redelivery.

Yang perlu dicek pada konfigurasi operasi:

  • waktu tunggu shutdown cukup panjang,
  • readiness/liveness tidak membuat worker lama tetap menerima kerja baru terlalu lama,
  • worker punya mode drain atau stop-polling sebelum terminate.

4. Selaraskan visibility timeout, durasi job, dan retry policy

Jangan memilih timeout secara asal. Dasarnya harus durasi proses nyata di produksi.

  • Jika job normalnya 10 detik tetapi kadang 90 detik karena layanan eksternal, timeout 30 detik berisiko tinggi.
  • Jika timeout diperpanjang terlalu besar, recovery dari worker yang benar-benar mati jadi lebih lambat.

Trade-off-nya jelas: timeout pendek meningkatkan redelivery palsu, timeout panjang memperlambat failover. Solusi yang sehat adalah:

  • ukur durasi job aktual,
  • pisahkan job lambat dan job cepat bila perlu,
  • atur retry berdasarkan jenis error,
  • gunakan heartbeat atau lease extension jika didukung.

5. Tambahkan alerting untuk sinyal duplikasi

Jangan menunggu laporan dari pengguna. Alert yang berguna antara lain:

  • kenaikan redelivery atau receive ulang untuk event yang sama,
  • selisih besar antara jumlah publish dan ack sukses,
  • job yang diproses oleh lebih dari satu worker ID,
  • idempotency conflict yang tiba-tiba meningkat setelah deploy.

Alert seperti ini membantu tim mendeteksi regresi sebelum efek samping bisnis meluas.

Contoh alur perbaikan yang lebih aman

Berikut pola yang lebih tahan terhadap race condition:

  1. Worker menerima job dan mencatat event_id, worker_id, deployment_id.
  2. Worker mencoba mencatat idempotency key secara atomik di database.
  3. Jika key sudah ada, worker menganggap job sudah pernah diproses dan keluar dengan aman.
  4. Jika perlu, worker mengambil distributed lock per resource untuk mencegah kontensi paralel.
  5. Efek samping dilakukan setelah perlindungan idempoten aktif.
  6. Worker menyimpan status hasil dengan jelas.
  7. Baru setelah itu job di-ack.

Dengan alur ini, bahkan bila deploy memunculkan overlap worker atau queue melakukan redelivery, dampak duplikasi jauh lebih kecil.

Checklist debugging dan pencegahan

Jika Anda sedang menangani kasus debugging race condition worker dengan job ganda usai deploy zero-downtime, gunakan checklist berikut:

  • Idempotency key: setiap efek samping penting punya key unik dan validasi atomik.
  • Distributed lock: gunakan lock atomik, bukan cek lalu set terpisah.
  • Graceful shutdown: worker lama berhenti polling, menyelesaikan job aktif, lalu terminate.
  • Retry policy: bedakan retry karena error bisnis, error sementara, dan redelivery karena timeout.
  • Visibility timeout: sesuaikan dengan durasi proses aktual, bukan asumsi.
  • Correlation ID: pastikan producer, consumer, dan efek samping memakai ID yang sama di log.
  • Metrik queue: pantau receive, ack, redelivery, durasi proses, dan usia pesan.
  • Reproduksi lokal: uji dengan dua worker, timeout pendek, dan simulasi deploy.
  • Alerting: pasang alarm untuk duplicate processing dan lonjakan conflict idempoten.

Penutup

Bug job ganda setelah deploy zero-downtime hampir selalu menunjukkan satu pelajaran yang sama: background processing harus diasumsikan bisa berjalan lebih dari sekali. Begitu asumsi itu diterima, desain sistem akan berubah ke arah yang lebih aman: efek samping dibuat idempoten, lock dibuat atomik, worker dimatikan dengan benar, dan observability dipasang sejak awal.

Jika hari ini tim Anda masih mengandalkan “semoga worker lama keburu selesai” atau “queue seharusnya tidak mengirim dua kali”, maka insiden berikutnya tinggal menunggu waktu. Perbaikan terbaik bukan menebak-nebak penyebab, melainkan membangun alur debugging yang cepat, terukur, dan mudah diulang.