Otomasi kecil sering memberi dampak besar pada alur kerja developer. Ide di balik git-absorb adalah contoh yang bagus: tugas rutin dipercepat agar developer tidak perlu mengulang langkah manual yang membosankan. Namun pelajaran pentingnya bukan hanya soal otomatisasi, melainkan soal otomatisasi yang aman. Di backend, worker queue juga merupakan bentuk otomatisasi. Bedanya, jika worker salah memproses satu job dua kali, dampaknya bisa jauh lebih serius: email terkirim ganda, stok terpotong dua kali, invoice dobel, atau integrasi pihak ketiga menerima request berulang.

Karena itu, mencegah job ganda tidak cukup dengan menambahkan queue lalu menjalankan banyak worker. Kita perlu memahami model pengiriman pesan, batasan sistem terdistribusi, dan teknik perlindungan seperti idempotency, visibility timeout, distributed lock, deduplication key, retry yang terkontrol, serta penanganan poison job. Artikel ini fokus pada desain praktis yang aman, gejala operasional yang perlu diwaspadai, dan pola implementasi yang bisa diterapkan lintas bahasa maupun framework.

Kenapa job bisa diproses ganda?

Job ganda biasanya bukan bug tunggal, melainkan hasil interaksi beberapa mekanisme yang masing-masing terlihat masuk akal:

  • Worker crash setelah mengeksekusi efek samping, tetapi sebelum mengakui bahwa job sudah selesai.
  • Visibility timeout terlalu pendek, sehingga job dianggap hilang dan diambil worker lain padahal worker pertama masih bekerja.
  • Retry otomatis saat koneksi ke database atau API eksternal gagal di titik yang ambigu.
  • Race condition ketika dua worker memproses entitas yang sama tanpa koordinasi.
  • Deduplication yang lemah, misalnya hanya mengandalkan timestamp atau payload mentah yang tidak stabil.
  • Lock cache kedaluwarsa terlalu cepat, sehingga worker kedua masuk saat worker pertama belum selesai.

Secara operasional, gejalanya sering terlihat seperti ini:

  • Pelanggan menerima email atau notifikasi dua kali.
  • Log menunjukkan job yang sama memiliki dua attempt yang overlap waktunya.
  • Order yang sama berubah status bolak-balik.
  • Debit saldo, potong stok, atau pembuatan invoice terjadi lebih dari sekali.
  • Lonjakan retry tanpa error yang jelas di sisi aplikasi.

Masalah ini hampir selalu muncul pada sistem yang mengandalkan asumsi salah: "satu job pasti dieksekusi tepat satu kali".

At-least-once vs exactly-once: pahami jaminannya dulu

At-least-once delivery

Pada banyak sistem queue, jaminan yang realistis adalah at-least-once: job akan dikirim setidaknya sekali, tetapi bisa lebih dari sekali. Ini adalah model yang umum karena lebih sederhana dan tahan terhadap crash atau gangguan jaringan.

Implikasinya jelas: consumer harus siap menerima duplikasi. Jika handler job tidak idempoten, bug duplikasi bukan kemungkinan kecil, melainkan konsekuensi desain.

Exactly-once processing

Exactly-once terdengar ideal, tetapi dalam sistem terdistribusi konsep ini jauh lebih sulit daripada yang sering dibayangkan. Beberapa platform menawarkan semacam exactly-once di batas tertentu, tetapi biasanya:

  • hanya berlaku pada komponen tertentu,
  • tetap membutuhkan idempotensi di level aplikasi, atau
  • bergantung pada transaksi dan batas sistem yang sempit.

Dalam praktik backend aplikasi bisnis, pendekatan yang lebih aman adalah:

Asumsikan queue bersifat at-least-once, lalu desain handler job agar idempoten dan aman terhadap eksekusi ulang.

Fondasi utama: idempotensi sebelum locking

Idempotensi berarti menjalankan operasi yang sama berkali-kali menghasilkan efek akhir yang sama. Ini adalah pertahanan paling penting terhadap job ganda.

Contoh yang tidak idempoten

Misalnya job charge payment langsung memanggil gateway pembayaran setiap kali dijalankan. Jika job diproses dua kali, pelanggan bisa ditagih dua kali.

Contoh yang lebih aman

Gunakan idempotency key yang stabil, misalnya berdasarkan order_id atau payment_request_id. Simpan status pemrosesan di database, lalu pastikan request ke sistem eksternal juga menggunakan kunci yang sama jika didukung.

Poin pentingnya:

  • Kunci harus stabil untuk satu aksi bisnis, bukan per-attempt worker.
  • State harus disimpan secara durable, biasanya di database yang konsisten.
  • Efek samping eksternal perlu diikat ke identifier yang bisa dideteksi ulang.

Contoh skenario nyata

Untuk job kirim invoice, jangan gunakan kunci acak yang dibuat saat worker berjalan. Gunakan identifier bisnis seperti invoice_id. Sebelum mengirim email atau membuat invoice PDF final, cek apakah invoice untuk invoice_id tersebut sudah berstatus sent atau sudah memiliki external_reference.

Peran visibility timeout dan kenapa sering disetel terlalu pendek

Visibility timeout adalah durasi saat sebuah job yang sudah diambil worker disembunyikan sementara dari worker lain. Jika worker tidak menyelesaikan atau tidak memperpanjang masa ini sebelum habis, queue dapat menganggap job gagal dan memberikannya ke worker lain.

Dampak timeout yang terlalu pendek

Misalnya sebuah job sinkronisasi membutuhkan 90 detik, tetapi visibility timeout hanya 30 detik. Pada detik ke-31, worker kedua bisa mengambil job yang sama. Akibatnya, dua worker memproses satu unit kerja yang sama secara paralel.

Praktik aman

  • Setel visibility timeout lebih panjang daripada durasi normal job, dengan margin untuk lonjakan latensi.
  • Jika queue mendukungnya, gunakan heartbeat atau perpanjangan timeout selama job masih aktif.
  • Pisahkan job cepat dan job lambat ke queue berbeda agar konfigurasi timeout lebih presisi.
  • Ukur durasi aktual job, jangan menebak.

Kesalahan umum adalah menyamakan timeout queue dengan timeout HTTP, atau mengambil angka kecil agar retry terasa cepat. Ini justru memicu duplikasi.

Locking worker: kapan perlu, kapan tidak cukup

Distributed lock berguna untuk mencegah dua worker memproses resource yang sama secara bersamaan. Namun lock bukan pengganti idempotensi. Lock membantu mengurangi paralelisme yang berbahaya; idempotensi melindungi dari eksekusi ulang yang tetap bisa terjadi.

Kasus yang cocok untuk lock

  • Satu account_id tidak boleh dimutasi simultan.
  • Satu order_id hanya boleh punya satu proses fulfillment aktif.
  • Sinkronisasi ke API eksternal yang sensitif terhadap urutan operasi.

Kasus di mana lock saja tidak cukup

Jika worker A mengambil lock, memanggil API eksternal, lalu crash sebelum menyimpan hasil, job akan diretry. Worker B bisa mengambil lock nanti dan mengulangi panggilan. Tanpa idempotensi, efek samping tetap bisa dobel.

Masalah cache lock yang expired terlalu cepat

Ini salah satu sumber bug yang sering diabaikan. Misalnya lock disimpan di Redis dengan TTL 15 detik, tetapi job kadang butuh 40 detik. Worker A masih jalan, lock kedaluwarsa, lalu worker B memperoleh lock baru dan memproses resource yang sama.

Akibatnya:

  • terjadi overlap eksekusi,
  • log seolah menunjukkan lock bekerja, padahal TTL terlalu pendek,
  • bug sulit direproduksi karena hanya muncul saat sistem lambat.

Praktik yang lebih aman:

  • TTL lock harus lebih panjang dari durasi kerja yang realistis.
  • Jika memungkinkan, gunakan mekanisme lock renewal selama worker masih hidup.
  • Sertakan owner token agar hanya pemilik lock yang boleh melepas atau memperpanjang lock.
  • Anggap lock sebagai proteksi tambahan, bukan satu-satunya garis pertahanan.

Deduplication key: mencegah job yang sama masuk berulang

Deduplication key dipakai untuk menahan job identik agar tidak diterbitkan atau diproses berkali-kali dalam jendela waktu tertentu. Ini berguna di sisi producer maupun consumer.

Kapan dipakai

  • Event yang bisa terpicu berulang dari UI atau webhook.
  • Scheduler yang kadang menjalankan trigger dua kali.
  • Producer yang retry saat status publish tidak jelas.

Desain key yang baik

Gunakan identifier bisnis yang mewakili satu niat operasi, misalnya:

  • send-invoice:{invoice_id}
  • recalculate-balance:{account_id}:{period}
  • charge-payment:{payment_request_id}

Hindari key yang bergantung pada field yang berubah-ubah seperti timestamp pembuatan request, urutan field JSON, atau UUID baru per retry.

Batas deduplication

Deduplication key membantu menahan duplikasi di pintu masuk, tetapi bukan pengganti idempotensi. Jika job sudah terlanjur diproses dua kali karena crash atau timeout, dedup key di producer tidak akan menyelamatkan sisi consumer.

Retry yang aman dan poison job

Retry harus mengasumsikan efek samping mungkin sudah terjadi

Retry adalah fitur penting, tetapi setiap retry harus diasumsikan berjalan pada keadaan yang ambigu. Mungkin request sebelumnya gagal total, mungkin juga sukses tetapi ack tidak sempat tercatat.

Karena itu, sebelum mengulangi:

  • cek state terakhir yang tersimpan,
  • gunakan idempotency key yang sama,
  • hindari membuat entitas baru jika yang lama mungkin sudah ada.

Backoff dan pembatasan percobaan

Retry tanpa jeda sering memperburuk kemacetan. Gunakan backoff progresif dan batasi jumlah attempt. Setelah batas tertentu, pindahkan job ke dead letter queue atau mekanisme karantina agar bisa dianalisis.

Poison job

Poison job adalah job yang selalu gagal karena payload rusak, bug deterministik, atau data yang tidak memenuhi prasyarat. Jika tidak dipisahkan, poison job bisa memenuhi queue dengan retry sia-sia.

Gejalanya antara lain:

  • job yang sama gagal dengan stack trace identik berulang-ulang,
  • throughput worker turun karena waktu habis untuk job yang pasti gagal,
  • retry count tinggi pada subset payload tertentu.

Tindakan yang aman:

  • tetapkan batas retry,
  • kirim ke dead letter queue,
  • simpan payload, error, dan metadata attempt untuk investigasi,
  • sediakan prosedur replay manual setelah diperbaiki.

Desain yang aman: urutan kerja yang disarankan

Pola berikut cukup umum dan aman untuk banyak kasus bisnis:

  1. Producer membuat job dengan deduplication key atau identifier bisnis yang stabil.
  2. Worker menerima job dan menentukan resource key yang akan dilindungi, misalnya order_id.
  3. Worker mencoba mengambil distributed lock untuk mencegah pemrosesan paralel pada resource yang sama.
  4. Worker memeriksa state idempoten di database: apakah aksi ini sudah pernah berhasil?
  5. Jika belum, worker mengeksekusi operasi bisnis dan menyimpan hasilnya secara konsisten.
  6. Jika ada integrasi eksternal, worker memakai idempotency key yang sama saat memanggil sistem luar.
  7. Worker menandai job selesai hanya setelah status akhir tersimpan.
  8. Jika gagal, worker melepaskan lock dengan aman dan membiarkan mekanisme retry berjalan sesuai kebijakan.

Catatan penting: urutan detail bisa berbeda tergantung storage dan transaksi yang tersedia, tetapi prinsip utamanya tetap sama: hindari efek samping tanpa jejak state yang bisa diverifikasi saat retry.

Pseudocode netral bahasa

function processJob(job):
    operationKey = job.operation_key
    resourceKey = job.resource_key
    lockKey = "lock:" + resourceKey

    lock = lockProvider.acquire(lockKey, ttl = SAFE_TTL, ownerToken = randomToken())
    if lock == null:
        return retryLater("resource is busy")

    try:
        existing = repository.findOperationByKey(operationKey)
        if existing != null and existing.status == "completed":
            return ack("already processed")

        if existing == null:
            repository.createOperationRecord(
                key = operationKey,
                resource = resourceKey,
                status = "processing"
            )

        latest = repository.findOperationByKey(operationKey)
        if latest.status == "completed":
            return ack("already processed")

        result = externalService.execute(
            idempotencyKey = operationKey,
            payload = job.payload
        )

        repository.markOperationCompleted(
            key = operationKey,
            externalRef = result.reference,
            completedAt = now()
        )

        return ack("success")

    catch transientError:
        repository.markOperationForRetry(key = operationKey, reason = transientError.message)
        return retryWithBackoff()

    catch fatalError:
        repository.markOperationFailed(key = operationKey, reason = fatalError.message)
        return moveToDeadLetterQueue()

    finally:
        lockProvider.release(lockKey, ownerToken = lock.ownerToken)

Apa yang penting dari pseudocode di atas:

  • operation_key adalah identitas idempoten untuk aksi bisnis.
  • resource_key dipakai untuk membatasi konkurensi pada entitas yang sama.
  • Lock tidak berdiri sendiri; ada record operasi di storage yang bisa dicek saat retry.
  • Panggilan ke sistem eksternal juga membawa idempotency key.

Skenario nyata: order fulfillment yang mudah rusak

Kondisi awal

Sebuah toko online memiliki job FulfillOrder(order_id). Tugasnya:

  1. memotong stok,
  2. membuat invoice,
  3. mengirim email konfirmasi.

Worker dijalankan paralel di beberapa instance. Queue memiliki retry otomatis. Sistem tampak sehat sampai sesekali pelanggan menerima invoice ganda.

Akar masalah

  • Job kadang memakan waktu lebih lama dari visibility timeout.
  • Lock berbasis cache dipasang dengan TTL terlalu pendek.
  • Handler tidak mengecek apakah invoice untuk order_id itu sudah ada.
  • Retry membuat invoice baru, bukan melanjutkan state lama.

Perbaikan desain

  • Gunakan order_id sebagai resource key untuk locking.
  • Gunakan fulfill-order:{order_id} sebagai operation key idempoten.
  • Simpan tabel operasi fulfillment dengan status processing, completed, failed.
  • Buat invoice dengan constraint unik terhadap order_id atau kunci bisnis yang setara.
  • Atur visibility timeout melebihi durasi fulfillment normal, plus margin.
  • Jika lock perlu lama, sediakan renewal atau TTL yang realistis.
  • Retry hanya melanjutkan operasi yang belum selesai, bukan membuat artefak baru tanpa pengecekan.

Dalam banyak kasus, constraint unik di database adalah perlindungan yang sangat efektif. Jika bisnis memang mensyaratkan satu invoice per order, nyatakan aturan itu secara eksplisit di storage, bukan hanya di kode aplikasi.

Anti-pattern umum yang sering menyebabkan job ganda

  • Mengandalkan lock tanpa idempotensi. Lock membantu, tetapi tidak menutup kasus crash, timeout, dan retry ambigu.
  • TTL lock lebih pendek dari durasi kerja. Ini menciptakan overlap yang sulit dilihat.
  • Menggunakan key dedup yang tidak stabil. Retry malah dianggap job baru.
  • Meng-ack job terlalu awal. Misalnya job dianggap selesai sebelum efek samping benar-benar tersimpan.
  • Membuat resource baru setiap attempt. Contoh: invoice baru, token baru, atau record baru tanpa cek existing state.
  • Retry tanpa klasifikasi error. Error validasi permanen seharusnya tidak diperlakukan seperti gangguan jaringan sementara.
  • Tidak memonitor durasi eksekusi. Akibatnya visibility timeout dan TTL lock disetel berdasarkan asumsi, bukan data.

Checklist implementasi mencegah job ganda

  1. Tentukan aksi bisnis yang harus idempoten.
  2. Buat operation key yang stabil untuk aksi tersebut.
  3. Tambahkan constraint unik di database jika aturan bisnis memungkinkan.
  4. Tentukan apakah perlu resource-level lock untuk mencegah konkurensi paralel.
  5. Setel visibility timeout berdasarkan durasi nyata job, bukan perkiraan kasar.
  6. Pastikan TTL lock tidak habis sebelum job selesai; pertimbangkan renewal.
  7. Gunakan idempotency key saat memanggil API eksternal jika didukung.
  8. Bedakan transient error dan fatal error.
  9. Terapkan retry dengan backoff dan batas attempt.
  10. Sediakan dead letter queue untuk poison job.
  11. Catat metadata penting: operation key, attempt number, worker id, lock owner, durasi.
  12. Siapkan prosedur replay yang aman untuk job yang gagal.

Tips debugging di produksi

Cari pola overlap

Jika satu operation key muncul pada dua worker di waktu beririsan, curigai visibility timeout atau TTL lock yang terlalu pendek.

Bandingkan durasi job dengan timeout

Ambil distribusi durasi aktual, terutama persentil tinggi. Jika timeout mendekati durasi puncak, duplikasi bisa muncul hanya saat sistem sedang lambat.

Audit jejak idempoten

Pastikan setiap efek samping penting meninggalkan bukti yang bisa dicek saat retry: record operasi, external reference, status final, atau constraint unik.

Periksa error ambigu

Kasus seperti putus koneksi setelah request terkirim sangat rawan. Di sini idempotency key dan pengecekan state pasca-gagal sangat membantu.

Penutup

Seperti otomasi kecil pada alur developer dapat mempercepat kerja sehari-hari, worker queue juga mempercepat backend dengan memindahkan tugas ke proses asinkron. Tetapi otomasi worker harus aman terhadap race condition. Pada praktiknya, cara paling andal untuk mencegah job ganda adalah menggabungkan beberapa lapisan: asumsi at-least-once, handler yang idempoten, visibility timeout yang benar, distributed lock yang realistis, deduplication key, retry yang disiplin, dan isolasi poison job.

Jika harus memilih prioritas, mulailah dari idempotensi dan state yang bisa diverifikasi. Locking penting, tetapi tidak boleh menjadi satu-satunya pertahanan. Sistem queue yang aman bukan sistem yang menganggap duplikasi mustahil, melainkan sistem yang tetap benar walaupun duplikasi terjadi.