Mencegah job ganda pada sistem queue/worker terdistribusi tidak cukup dengan menambah retry atau memasang lock seadanya. Masalah utamanya adalah delivery pada queue umumnya bersifat at-least-once: pesan bisa dikirim lebih dari sekali, worker bisa crash setelah menulis ke database tetapi sebelum melakukan ack, dan lock bisa kedaluwarsa saat proses masih berjalan.

Karena itu, pendekatan yang aman biasanya menggabungkan beberapa lapisan: idempotency pada operasi bisnis, distributed lock untuk membatasi eksekusi paralel, dedupe table atau unique job untuk menahan enqueue ganda, serta transactional outbox dan optimistic concurrency untuk menjaga konsistensi saat state berubah. Kuncinya bukan mencari satu mekanisme ajaib, tetapi memilih kombinasi yang sesuai dengan failure mode yang benar-benar terjadi di produksi.

Ada pelajaran penting dari analogi ReactOS yang menjalankan game lama di hardware nyata: sistem bisa terlihat baik di lingkungan uji, tetapi perilakunya berubah ketika menghadapi kondisi operasional nyata. Demikian juga queue terdistribusi: simulasi lokal sering tidak memperlihatkan retry race, clock skew, lock expiry, atau duplicate delivery yang baru muncul saat latensi, crash, dan timeout terjadi bersamaan.

Kenapa job ganda tetap terjadi meski sudah ada queue?

Banyak engineer mengira queue otomatis menjamin satu job dikerjakan sekali. Pada praktiknya, kebanyakan queue hanya menjamin job akan diproses, bukan hanya sekali diproses. Sumber duplikasi yang paling umum:

  • Retry race: producer atau worker mengirim ulang karena mengira operasi sebelumnya gagal, padahal sebenarnya sudah diterima.
  • Visibility timeout: job dianggap tidak selesai dalam waktu tertentu, lalu dikirim lagi ke worker lain meski worker pertama masih memproses.
  • Worker crash: proses mati setelah efek samping terjadi, tetapi sebelum ack disimpan.
  • Duplicate delivery: sistem messaging memang mendesain ulang pesan dalam kondisi tertentu.
  • Cache stampede: banyak worker serentak menghitung ulang data yang sama karena cache miss bersamaan.
  • Lock expiry: lock habis sebelum kerja selesai, sehingga worker lain masuk dan menjalankan job yang sama.
  • Clock skew: keputusan berbasis waktu antar node menjadi tidak sinkron.
  • Split-brain ringan: ada periode singkat ketika dua node merasa punya otoritas akibat gangguan jaringan atau failover yang belum konvergen.

Implikasinya jelas: desain harus mengasumsikan duplikasi itu normal, bukan anomali langka.

Pola utama untuk mencegah job ganda

1. Idempotency key: pilihan utama untuk efek samping bisnis

Gunakan idempotency key ketika operasi harus aman jika dieksekusi lebih dari sekali, misalnya pembuatan invoice, charge pembayaran, pengiriman email tertentu, atau sinkronisasi ke layanan eksternal. Ide dasarnya: tiap permintaan bisnis punya kunci unik yang disimpan bersama hasil atau status eksekusi. Jika permintaan dengan kunci yang sama datang lagi, sistem mengembalikan hasil yang sama atau menolak eksekusi ulang.

Pola ini paling kuat karena tidak bergantung pada lock yang selalu hidup. Bahkan jika worker crash dan job dikirim ulang, operasi tetap aman selama pemeriksaan idempotency dilakukan terhadap sumber kebenaran yang konsisten, biasanya database transaksional.

// pseudo-code pemrosesan job dengan idempotency key
function processJob(job) {
  beginTransaction()

  row = select * from idempotency_keys where key = job.idempotencyKey for update

  if row exists and row.status == 'completed' {
    commit()
    return row.savedResult
  }

  if row not exists {
    insert into idempotency_keys(key, status, created_at)
      values(job.idempotencyKey, 'processing', now())
  }

  // lakukan efek bisnis yang harus tepat sekali secara logis
  result = createInvoiceIfNotExists(job.orderId)

  update idempotency_keys
    set status = 'completed', saved_result = result, completed_at = now()
    where key = job.idempotencyKey

  commit()
  return result
}

Kapan dipakai: saat duplikasi paling berbahaya ada pada level bisnis, bukan sekadar level eksekusi worker.

Keterbatasan: perlu desain skema penyimpanan, kebijakan retensi key, dan penanganan status processing yang tertinggal akibat crash.

2. Distributed lock: membatasi konkurensi, bukan menjamin exactly-once

Distributed lock berguna jika Anda ingin hanya satu worker memproses resource tertentu pada satu waktu, misalnya per account_id, report_id, atau cache_key. Lock sangat membantu untuk mengurangi kerja ganda, tetapi tidak boleh dianggap cukup untuk menjamin tidak ada duplikasi sama sekali.

Masalah klasiknya adalah lock expiry. Jika TTL lock lebih pendek daripada durasi kerja aktual, worker kedua bisa mengambil lock yang sama dan memulai eksekusi paralel. Karena itu:

  • set TTL berdasarkan durasi nyata, bukan tebakan optimistis,
  • gunakan mekanisme renew/heartbeat bila proses bisa lama,
  • simpan owner token acak dan validasi token itu saat release,
  • tetap lindungi operasi bisnis dengan idempotency atau constraint database.
// pseudo-code lock berbasis cache/Redis
function handle(job) {
  ownerToken = randomUUID()
  lockKey = "lock:invoice:" + job.orderId

  acquired = cache.set(lockKey, ownerToken, nx=true, ttl=120s)
  if !acquired {
    requeueWithJitter(job)
    return
  }

  try {
    startHeartbeat(lockKey, ownerToken, every=30s, extendBy=120s)
    processIdempotentBusinessLogic(job)
  } finally {
    stopHeartbeat()
    // hapus lock hanya jika masih milik worker ini
    cache.compareAndDelete(lockKey, ownerToken)
  }
}

Kapan dipakai: saat biaya eksekusi paralel mahal, saat perlu serialisasi per resource, atau untuk menahan cache stampede.

Kapan tidak cukup: saat operasi menyentuh pembayaran, inventori, atau mutasi data penting tanpa perlindungan idempotency di bawahnya.

3. Dedupe table dan unique constraint: sederhana dan kuat

Jika sumber duplikasi banyak berasal dari producer yang mengirim job dua kali, pertimbangkan dedupe table atau unique constraint. Producer mencoba mencatat fingerprint job sebelum enqueue. Jika insert gagal karena kunci unik sudah ada, enqueue kedua dibatalkan.

// pseudo-code producer
function enqueueShipment(orderId) {
  dedupeKey = "shipment:" + orderId

  beginTransaction()
  inserted = insert into job_dedupe(dedupe_key, created_at)
             values(dedupeKey, now())
             on conflict do nothing

  if !inserted {
    commit()
    return "already-enqueued"
  }

  insert into outbox(event_type, payload, created_at)
    values('ship-order', json({orderId: orderId, dedupeKey: dedupeKey}), now())

  commit()
}

Kelebihannya adalah konsistensi kuat jika berada di database yang sama dengan data bisnis. Kekurangannya: tabel dedupe bisa tumbuh besar dan perlu strategi pembersihan atau retensi.

4. Unique job: cegah enqueue ganda, tetapi pahami batasannya

Beberapa framework menyediakan konsep unique job atau job yang tidak boleh ada lebih dari satu dalam antrean untuk kunci tertentu. Ini baik untuk kasus seperti regenerasi laporan atau sinkronisasi profil, tetapi penting memahami bahwa unique job biasanya menyelesaikan enqueue duplication, bukan seluruh spektrum duplicate execution.

Jika job sudah mulai diproses lalu worker crash, queue tetap bisa mendeliver ulang. Jadi unique job lebih tepat dianggap sebagai lapisan pengurang duplikasi, bukan proteksi akhir.

5. Transactional outbox: hindari gap antara commit data dan publish job

Salah satu sumber bug yang sering diabaikan adalah urutan: aplikasi menyimpan perubahan bisnis, lalu mengirim job ke queue secara terpisah. Jika proses mati di antara dua langkah itu, data bisa sudah berubah tetapi job tidak pernah terkirim, atau kebalikannya.

Transactional outbox menyimpan event/job di tabel outbox dalam transaksi yang sama dengan perubahan bisnis. Setelah commit, ada publisher terpisah yang membaca outbox dan mengirim ke broker. Ini tidak otomatis mencegah duplicate delivery, tetapi membuat aliran data lebih konsisten dan memudahkan dedupe karena ada identifier stabil dari awal.

6. Optimistic concurrency: lindungi perubahan state yang balapan

Saat beberapa worker boleh membaca data yang sama tetapi hanya satu perubahan yang boleh menang, gunakan optimistic concurrency, misalnya kolom versi atau kondisi where status = 'pending'. Ini berguna ketika duplicate processing tidak bisa dihindari sepenuhnya, tetapi Anda ingin memastikan hanya satu update yang benar-benar diterapkan.

// pseudo-code update status dengan optimistic concurrency
updated = update payouts
          set status = 'processed', version = version + 1
          where payout_id = :id
            and status = 'pending'
            and version = :currentVersion

if updated == 0 {
  // sudah diproses worker lain atau ada perubahan balapan
  return "skip"
}

Diagram alur sederhana: dari enqueue sampai ack yang aman

Producer
  |
  |--(1) simpan perubahan bisnis + outbox/dedupe dalam 1 transaksi
  v
Outbox Publisher
  |
  |--(2) publish job dengan idempotency key / dedupe key
  v
Queue
  |
  |--(3) deliver ke worker A
  v
Worker A
  |
  |--(4) ambil lock per resource (opsional, untuk serialisasi)
  |--(5) cek idempotency / status bisnis di DB
  |--(6) jalankan efek bisnis
  |--(7) commit hasil + tandai completed
  |--(8) ack job
  v
Selesai

Jika Worker A crash setelah (6) tapi sebelum (8):
Queue bisa redeliver ke Worker B
Worker B cek idempotency/status --> mendeteksi sudah selesai --> skip aman

Urutan ini penting karena menunjukkan kenapa ack bukan sumber kebenaran bisnis. Status sukses sebaiknya ditentukan oleh data bisnis atau catatan idempotency yang persisten.

Failure mode umum dan cara menanganinya

Retry race

Retry race terjadi saat timeout pada sisi caller lebih pendek dari waktu proses sebenarnya. Caller mengirim ulang, sementara eksekusi pertama masih berjalan atau bahkan sudah sukses.

  • Gunakan idempotency key pada batas API atau job boundary.
  • Tambahkan jitter pada retry agar tidak menumpuk serentak.
  • Bedakan timeout jaringan dengan kegagalan bisnis.

Visibility timeout terlalu pendek

Jika visibility timeout queue lebih pendek dari durasi kerja p95/p99, duplikasi akan sering muncul.

  • Set timeout berdasar durasi proses nyata dengan margin.
  • Jika tersedia, perpanjang visibility selama worker masih sehat.
  • Catat selisih antara durasi job dan timeout sebagai metrik.

Worker crash setelah side effect

Ini kegagalan paling berbahaya karena sistem cenderung mengulang pekerjaan yang sebenarnya sudah terjadi.

  • Pastikan side effect penting punya identitas unik yang bisa dicek ulang.
  • Simpan hasil atau status completed sebelum atau bersamaan dengan boundary bisnis yang tepat.
  • Hindari mengandalkan memori worker untuk status eksekusi.

Duplicate delivery dari broker

Asumsikan duplicate delivery selalu mungkin, terutama setelah reconnect, failover, atau ack yang tidak sempat tercatat.

  • Jangan letakkan logika dedupe hanya di cache volatil.
  • Gunakan database constraint atau idempotency store untuk operasi kritikal.

Cache stampede

Saat cache item kedaluwarsa, banyak worker dapat menghitung ulang data yang sama dan menulis hasil secara bersamaan.

  • Gunakan lock per cache key.
  • Pertimbangkan stale-while-revalidate agar pembaca tetap mendapat data lama sementara satu worker melakukan refresh.
  • Tambahkan TTL acak untuk menghindari expiry serentak pada banyak key.

Lock expiry

TTL lock yang terlalu pendek atau heartbeat yang macet akan membuka jalan untuk eksekusi ganda.

  • Gunakan owner token saat release.
  • Monitor rasio lock timeout versus durasi job.
  • Jangan gunakan lock tanpa fallback idempotency untuk operasi kritikal.

Clock skew

Jika keputusan lock atau expiry sangat bergantung pada jam lokal, skew kecil antarmesin bisa memicu perilaku aneh.

  • Usahakan expiry dievaluasi oleh sistem penyimpanan pusat, bukan aplikasi lokal.
  • Minimalkan logika yang membandingkan timestamp dari node berbeda untuk keputusan korektness.

Split-brain ringan

Dalam failover atau gangguan jaringan singkat, dua proses bisa merasa berhak memegang lock atau memproses partition yang sama.

  • Untuk kasus kritikal, anggap lock cache hanya mekanisme pengurang konkurensi, bukan sumber kebenaran final.
  • Validasi perubahan di database dengan constraint, versi, atau status transisi yang atomik.

Kapan memilih pendekatan yang mana?

Pilih idempotency key jika

  • operasi punya identitas bisnis yang jelas,
  • efek samping harus aman terhadap retry dan duplicate delivery,
  • hasil lama perlu bisa dikembalikan untuk permintaan yang sama.

Pilih distributed lock jika

  • Anda ingin membatasi proses paralel per resource,
  • pekerjaan mahal dan duplikasi menambah beban signifikan,
  • Anda menangani cache rebuild, sinkronisasi, atau agregasi.

Pilih dedupe table atau unique constraint jika

  • masalah utama ada di enqueue ganda dari producer,
  • Anda butuh jaminan konsisten yang dekat dengan data bisnis,
  • fingerprint job bisa ditentukan sebelum publish.

Pilih unique job jika

  • framework sudah mendukung mekanisme ini dengan baik,
  • Anda ingin menekan penumpukan job identik di antrean,
  • konsekuensi duplicate execution masih dilindungi lapisan lain.

Pilih transactional outbox jika

  • perubahan database dan publish job harus tetap sinkron,
  • Anda ingin pola producer yang tahan crash di titik transisi,
  • arsitektur Anda sudah event-driven atau integrasi antarsistem.

Pilih optimistic concurrency jika

  • beberapa worker bisa membaca objek yang sama,
  • hanya satu transisi state yang boleh berhasil,
  • Anda ingin perlindungan langsung di row data.

Trade-off Redis vs database lock

Redis lock

Kelebihan:

  • latensi rendah, cocok untuk lock berumur pendek dan throughput tinggi,
  • baik untuk serialisasi kerja dan mencegah cache stampede,
  • operasional sederhana jika Redis sudah menjadi komponen inti.

Kekurangan:

  • TTL expiry mudah salah setel,
  • lebih rentan terhadap isu korektness saat failover atau gangguan jaringan dibanding constraint database,
  • bukan tempat ideal sebagai satu-satunya bukti bahwa operasi bisnis belum pernah dieksekusi.

Database lock

Kelebihan:

  • berada dekat dengan sumber kebenaran bisnis,
  • bisa digabung dengan transaksi, unique constraint, dan kondisi update atomik,
  • lebih mudah diaudit saat investigasi duplikasi.

Kekurangan:

  • lebih berat untuk workload lock yang sangat sering dan singkat,
  • bisa menambah kontensi pada database utama,
  • perlu desain query dan indeks yang hati-hati agar tidak menciptakan bottleneck.

Aturan praktis: gunakan Redis lock untuk koordinasi cepat, dan database untuk jaminan korektness bisnis. Jika harus memilih satu untuk operasi kritikal, database biasanya lebih aman sebagai lapisan final.

Contoh arsitektur praktis yang seimbang

Untuk banyak backend production, kombinasi berikut cukup realistis:

  1. Producer menulis perubahan bisnis dan baris outbox dalam satu transaksi.
  2. Outbox publisher mengirim job dengan job_id stabil dan idempotency_key.
  3. Worker mengambil distributed lock per resource untuk mencegah konkurensi mahal.
  4. Worker memeriksa idempotency table atau state bisnis di database sebelum side effect.
  5. Update dilakukan dengan optimistic concurrency atau constraint unik bila relevan.
  6. Setelah commit, worker melakukan ack.

Pola ini tidak menghapus seluruh duplikasi fisik di infrastruktur, tetapi membuat duplikasi itu tidak mengubah hasil bisnis.

Checklist observability untuk mendeteksi job ganda

Tanpa observability yang tepat, Anda sering baru sadar ada duplikasi setelah pelanggan komplain. Minimal pantau hal berikut:

  • Jumlah duplicate execution per job type dan per resource key.
  • Rasio lock acquire gagal dan waktu tunggu lock.
  • Jumlah lock expired saat job masih berjalan.
  • Durasi job p50/p95/p99 dibanding visibility timeout dan TTL lock.
  • Jumlah redelivery atau retry per pesan.
  • Jumlah idempotency hit versus eksekusi baru.
  • Outbox lag: selisih waktu antara commit data dan publish broker.
  • Conflict optimistic concurrency atau update yang gagal karena versi/status berubah.
  • Crash loop worker dan deployment correlation.
  • Log terstruktur yang memuat job_id, idempotency_key, resource_key, lock_owner, dan delivery_attempt.

Tip debugging: saat ada laporan job ganda, cari berdasarkan idempotency_key atau resource key, lalu susun timeline: enqueue, delivery pertama, lock acquire, side effect, commit, ack, redelivery. Urutan kejadian hampir selalu mengungkap apakah akar masalahnya ada di retry, timeout, crash, atau lock expiry.

Kesalahan implementasi yang sering terjadi

  • Menganggap lock sama dengan idempotency. Lock hanya mengurangi peluang paralelisme, bukan menghapus duplicate delivery.
  • Menyimpan dedupe hanya di cache untuk operasi penting. Saat cache hilang, proteksi ikut hilang.
  • TTL lock terlalu agresif tanpa heartbeat.
  • Melepas lock tanpa owner token, sehingga worker lain bisa menghapus lock yang bukan miliknya.
  • Mengandalkan jam aplikasi untuk validasi expiry lintas node.
  • Ack terlalu dini sebelum efek bisnis benar-benar persisten.
  • Tidak menguji crash di titik kritis, misalnya tepat setelah write DB atau tepat sebelum ack.

Cara menguji di kondisi operasional nyata

Seperti analogi ReactOS yang diuji menjalankan game lama di hardware nyata, sistem queue juga harus diuji dalam kondisi yang menyerupai produksi. Uji lokal yang bersih sering tidak cukup. Lakukan uji yang sengaja memicu failure mode:

  • matikan worker tepat setelah commit tetapi sebelum ack,
  • naikkan latensi broker atau database,
  • paksa retry serentak dari banyak producer,
  • set visibility timeout lebih pendek lalu amati redelivery,
  • simulasikan lock expiry saat pekerjaan masih aktif,
  • uji failover cache/Redis dan amati apakah proteksi bisnis tetap aman.

Tujuan pengujian bukan membuktikan sistem tidak pernah menduplikasi job, melainkan membuktikan hasil akhir tetap benar ketika duplikasi terjadi.

Penutup

Mencegah job ganda pada queue terdistribusi hampir selalu berarti menerima bahwa duplikasi infrastruktur bisa terjadi, lalu merancang agar duplikasi itu tidak merusak state bisnis. Untuk itu, gunakan kombinasi yang tepat: idempotency key untuk efek samping, distributed lock untuk serialisasi kerja, dedupe table atau unique job untuk menahan enqueue ganda, transactional outbox untuk publish yang konsisten, dan optimistic concurrency untuk update yang saling balapan.

Jika harus memprioritaskan, mulailah dari pertanyaan ini: kalau job yang sama diproses dua kali, bukti final di mana yang memastikan hasil bisnis tetap satu? Jawaban yang baik biasanya bukan “di lock cache”, melainkan “di state bisnis yang persisten dan bisa diverifikasi”.