Pada sistem queue dengan jaminan at-least-once, pertanyaan utamanya bukan apakah duplikasi akan terjadi, melainkan kapan duplikasi itu muncul. Retry otomatis, worker crash setelah menulis ke database tetapi sebelum ack, visibility timeout yang terlalu pendek, atau gangguan jaringan kecil saja sudah cukup untuk membuat satu job diproses lebih dari sekali.

Karena itu, solusi yang benar biasanya bukan mengejar exactly-once delivery di level queue, melainkan membangun idempotensi worker dan kontrol konsistensi di sekitar efek samping. Dengan pendekatan ini, job boleh datang berkali-kali, tetapi hasil akhirnya tetap satu kali secara logis: tidak ada pembayaran ganda, email ganda, stok berkurang dua kali, atau status bisnis yang meloncat tidak semestinya.

Artikel ini fokus pada tindakan praktis yang bisa diterapkan tim backend untuk merancang At-Least-Once Queue: Idempotensi Worker dan Retry Tanpa Duplikasi, mulai dari penyebab duplikasi, pola implementasi, trade-off, pseudo-code, observabilitas, sampai checklist operasional.

Mengapa duplikasi job terjadi pada at-least-once queue

Model at-least-once menjamin pesan akan dikirim minimal satu kali. Implikasinya, sistem boleh mengirim ulang pesan yang sama bila ada ketidakpastian apakah pemrosesan sebelumnya berhasil penuh atau tidak.

Penyebab duplikasi yang paling umum

  • Retry eksplisit: aplikasi atau broker melakukan retry karena handler melempar error, timeout, atau respons tidak meyakinkan.
  • Timeout sebelum ack: worker sebenarnya sudah menjalankan efek samping, tetapi belum sempat mengirim ack; broker menganggap job gagal lalu mengirim ulang.
  • Worker crash: proses mati setelah update database atau memanggil API eksternal, namun sebelum menyelesaikan siklus job.
  • Visibility timeout terlalu pendek: satu worker masih memproses job lama, tetapi broker sudah menganggap job tersedia lagi untuk worker lain.
  • Network glitch: ack atau hasil pemanggilan API hilang di jaringan; pihak pengirim tidak tahu apakah operasi sukses atau tidak, lalu mengulang.

Semua skenario di atas memiliki pola yang sama: ada bagian sistem yang tidak bisa memastikan status akhir sebuah operasi. Di titik itulah duplikasi lahir.

Prinsip desain: terima duplikasi, netralkan efek samping

Pola yang paling tahan lama adalah menerima bahwa queue dapat mendeliver job lebih dari sekali, lalu memastikan bahwa pemrosesan job bersifat idempotent. Idempotent berarti menjalankan operasi yang sama beberapa kali menghasilkan keadaan akhir yang setara dengan menjalankannya sekali.

Contoh yang tidak idempotent:

  • balance = balance - 100 tanpa penanda transaksi unik.
  • Mengirim email setiap kali job dipanggil, tanpa pencatatan bahwa email sudah pernah dikirim.
  • Menambah record pembayaran baru setiap retry.

Contoh yang lebih idempotent:

  • Mencatat transaksi dengan payment_id unik dan menolak insert duplikat.
  • Mengubah status order dari PENDING ke PAID hanya jika transisi valid dan belum pernah selesai.
  • Memanggil API eksternal dengan idempotency key yang sama untuk satu operasi bisnis.

Catatan penting: idempotensi bukan berarti semua operasi aman diulang tanpa desain tambahan. Kebanyakan operasi menjadi idempotent karena kita menambahkan identitas unik, status yang jelas, dan aturan persistensi yang tepat.

Pola idempotensi di level worker

1. Gunakan kunci idempotensi per operasi bisnis

Jangan memakai ID job broker sebagai identitas utama. Job yang sama secara bisnis bisa dikirim ulang sebagai message yang berbeda. Gunakan business idempotency key seperti order_id + event_type, payment_request_id, atau shipment_id.

Kunci ini harus stabil di semua retry dan diketahui oleh semua komponen yang terlibat.

2. Buat status mesin sederhana

Alih-alih hanya menyimpan flag done=true, gunakan status yang eksplisit. Contoh sederhana:

  • RECEIVED
  • PROCESSING
  • COMPLETED
  • FAILED_RETRYABLE
  • FAILED_FINAL

Status mesin membantu menjawab pertanyaan penting: apakah operasi belum mulai, sedang berjalan, sukses, atau gagal permanen? Ini juga mencegah transisi yang tidak sah, misalnya job yang sudah COMPLETED diproses ulang menjadi PROCESSING.

3. Pisahkan langkah deterministik dan efek samping

Jika memungkinkan, hitung keputusan bisnis secara deterministik dulu, simpan hasilnya, lalu baru lakukan efek samping. Dengan begitu, saat retry terjadi, worker bisa membaca status tersimpan dan tahu bagian mana yang masih aman dilanjutkan.

4. Jangan mengandalkan lock sebagai satu-satunya perlindungan

Lock dapat membantu mencegah dua worker memproses entitas yang sama secara bersamaan, tetapi lock bukan pengganti idempotensi. Lock bisa hilang saat proses mati, bisa salah konfigurasi TTL-nya, dan sering menjadi bottleneck saat throughput naik.

Gunakan lock bila:

  • Ada konflik konkuren yang mahal atau berbahaya bila dua worker jalan bersamaan.
  • Operasi pendek dan batas entitas yang dikunci jelas, misalnya per order_id.

Hindari menjadikan lock sebagai mekanisme utama bila:

  • Semua request harus antre pada satu lock global.
  • Anda bisa menyelesaikan masalah dengan unique constraint atau transisi status atomik di database.
  • Lock dipakai untuk menutupi desain yang tidak punya identitas operasi yang jelas.

Pola idempotensi di level database

1. Dedup table

Buat tabel khusus untuk mencatat bahwa operasi dengan kunci tertentu sudah pernah diproses. Biasanya berisi:

  • idempotency_key
  • operation_type
  • status
  • result_reference
  • created_at, updated_at

Dengan pola ini, worker dapat melakukan check-and-set secara atomik. Jika insert pertama berhasil, worker melanjutkan. Jika gagal karena key sudah ada, worker membaca status lama dan memutuskan apakah harus skip, melanjutkan langkah yang belum selesai, atau menandai duplikat.

2. Unique constraint lebih kuat daripada cek manual

Pola SELECT dulu lalu INSERT rawan race condition. Dua worker bisa membaca “belum ada” pada waktu hampir bersamaan, lalu keduanya menulis.

Lebih aman:

  • Pasang unique constraint pada idempotency_key atau identitas bisnis lain.
  • Lakukan insert langsung.
  • Tangani error pelanggaran unik sebagai sinyal bahwa operasi sudah pernah dibuat.

Ini penting karena database biasanya lebih andal menjaga atomisitas dibanding logika aplikasi yang tersebar di beberapa instance.

3. Transaksi untuk perubahan status dan pencatatan hasil

Jika worker perlu memperbarui beberapa tabel yang harus konsisten satu sama lain, lakukan dalam satu transaksi database selama masih berada dalam satu boundary penyimpanan yang sama. Contoh:

  • Simpan status idempotensi.
  • Buat record pembayaran internal.
  • Update status order.

Namun hati-hati: jangan menaruh panggilan API eksternal di dalam transaksi database yang panjang. Itu memperbesar lock duration, meningkatkan contention, dan menyulitkan recovery.

4. Transactional outbox dan inbox

Untuk integrasi antar layanan, pola transactional outbox sangat berguna. Intinya, saat layanan A melakukan perubahan state bisnis, ia juga menulis event ke tabel outbox dalam transaksi yang sama. Proses terpisah kemudian mengirim event tersebut ke broker.

Keuntungannya:

  • Perubahan database dan niat mengirim event menjadi atomik secara lokal.
  • Mengurangi risiko state sudah berubah tetapi event tidak pernah terkirim.

Di sisi konsumen, pola inbox atau tabel penerimaan event membantu mencatat event yang sudah diproses berdasarkan message ID atau business key. Ini mencegah event yang sama diproses dua kali.

Idempotensi untuk external API

Bagian tersulit biasanya bukan database internal, melainkan API pihak ketiga seperti pembayaran, email provider, atau layanan pengiriman. Jika API mendukung idempotency key, gunakan fitur itu. Satu operasi bisnis harus selalu dikirim dengan key yang sama selama retry.

Contoh alur saat memanggil external API

  1. Worker membaca job dengan payment_request_id.
  2. Worker memastikan ada record idempotensi internal untuk request tersebut.
  3. Worker memanggil gateway pembayaran dengan header atau parameter idempotency_key = payment_request_id.
  4. Jika timeout terjadi, worker jangan langsung mengasumsikan gagal.
  5. Saat retry, gunakan key yang sama, lalu cocokkan respons dengan record internal.

Jika API eksternal tidak mendukung idempotency key, pilihan Anda lebih terbatas:

  • Cari endpoint query status agar worker bisa melakukan rekonsiliasi setelah timeout.
  • Simpan request/response reference secara detail untuk investigasi.
  • Desain operasi sebagai dua tahap, misalnya reserve lalu confirm, bila domain memungkinkan.
  • Gunakan kompensasi bisnis jika duplikasi benar-benar tidak bisa dihindari.

Prinsip praktis: jika external API tidak memberi jaminan idempotensi dan tidak punya endpoint rekonsiliasi, maka sistem Anda tidak bisa memberi jaminan kuat di atasnya tanpa kompromi operasional.

Alur proses yang disarankan

Flow dasar worker idempotent

  1. Terima job dari queue.
  2. Ambil business_key yang stabil.
  3. Coba buat atau klaim record idempotensi secara atomik.
  4. Jika sudah COMPLETED, log sebagai duplikat lalu ack.
  5. Jika baru atau masih bisa dilanjutkan, set status ke PROCESSING.
  6. Lakukan perubahan internal yang perlu dalam transaksi.
  7. Jika perlu memanggil external API, kirim dengan idempotency key yang sama.
  8. Simpan hasil akhir dan transisi ke COMPLETED.
  9. Baru kirim ack ke broker.

Pseudo-code worker

function handleJob(job):
    key = buildBusinessKey(job)

    record = idempotencyStore.tryCreateOrLoad(key)

    if record.status == "COMPLETED":
        log("duplicate_job_ignored", key)
        ack(job)
        return

    if record.status == "PROCESSING" and not record.isExpired():
        requeueOrDelay(job)
        return

    idempotencyStore.markProcessing(key)

    try:
        beginTransaction()

        order = orders.getForUpdate(job.orderId)
        if order.status == "PAID":
            idempotencyStore.markCompleted(key, resultRef=order.paymentId)
            commit()
            ack(job)
            return

        payment = payments.insertUnique({
            paymentRequestId: job.paymentRequestId,
            orderId: job.orderId,
            amount: job.amount
        })

        orders.markPaid(job.orderId, payment.id)
        outbox.enqueue("payment.completed", {
            orderId: job.orderId,
            paymentId: payment.id
        })

        idempotencyStore.markCompleted(key, resultRef=payment.id)
        commit()

        ack(job)
    catch UniqueConstraintViolation:
        rollback()
        existing = payments.findByPaymentRequestId(job.paymentRequestId)
        idempotencyStore.markCompleted(key, resultRef=existing.id)
        ack(job)
    catch RetryableExternalError:
        rollback()
        idempotencyStore.markRetryableFailure(key)
        retry(job)
    catch Exception:
        rollback()
        idempotencyStore.markRetryableFailure(key)
        retry(job)

Pseudo-code di atas menunjukkan beberapa prinsip penting:

  • Business key dipakai sebagai dasar deduplikasi, bukan message ID broker.
  • Unique constraint diperlakukan sebagai mekanisme normal, bukan hanya error.
  • Completed state diakui walau retry masuk lagi.
  • Outbox dipakai agar event lanjutan tidak hilang setelah state internal berubah.

Skenario gagal yang paling sering salah ditangani

1. Sukses di database, gagal sebelum ack

Ini kasus klasik. Worker sudah commit transaksi, lalu proses mati sebelum ack. Job akan dikirim ulang. Jika tidak ada idempotensi, efek samping terjadi dua kali. Jika ada dedup table atau unique constraint, retry akan melihat operasi sudah selesai dan cukup ack.

2. Timeout saat memanggil API eksternal

Timeout tidak sama dengan gagal total. Bisa jadi provider menerima request dan memprosesnya, tetapi responsnya hilang. Jika Anda retry dengan request baru tanpa idempotency key yang sama, hasilnya bisa menjadi duplikasi nyata.

3. Visibility timeout lebih pendek dari waktu proses

Job yang sama bisa dikerjakan dua worker secara paralel. Solusinya bukan hanya memperpanjang timeout, tetapi juga memastikan operasi aman bila dua eksekusi berjalan bersamaan. Timeout yang terlalu panjang pun punya trade-off: recovery lebih lambat saat worker benar-benar mati.

4. Lock kedaluwarsa di tengah proses

Jika memakai lock dengan TTL pendek dan tidak ada perpanjangan yang andal, worker kedua bisa masuk sementara worker pertama masih berjalan. Akibatnya, Anda memiliki rasa aman palsu. Karena itu, lock harus dipandang sebagai optimisasi konkruensi, bukan bukti tunggal bahwa operasi pasti satu kali.

5. Dedup record dibuat, tetapi hasil akhir tidak pernah disimpan

Misalnya status berhenti di PROCESSING karena crash. Tanpa mekanisme expiry atau recovery, semua retry setelahnya bisa macet. Karena itu, status mesin perlu aturan timeout dan rekonsiliasi untuk record yang terlalu lama berada di PROCESSING.

Observabilitas: apa yang harus dipantau

Queue idempotent yang baik harus mudah ditelusuri ketika sesuatu tidak konsisten. Tanpa observabilitas, tim sering keliru membedakan antara retry yang sehat dan duplikasi yang merusak.

Log yang sebaiknya selalu ada

  • message_id dari broker
  • business_key atau idempotency_key
  • Status transisi: RECEIVED, PROCESSING, COMPLETED, FAILED
  • Jumlah retry dan alasan retry
  • Reference ke entitas hasil seperti payment_id, order_id
  • Correlation ID antar layanan

Metrik penting

  • Duplicate detection rate: berapa banyak job terdeteksi sebagai duplikat.
  • Retry rate: berapa banyak job harus dicoba ulang.
  • Processing latency: durasi dari pesan diterima sampai selesai.
  • Age of oldest message: indikator backlog dan worker macet.
  • In-flight jobs: jumlah job yang sedang diproses.
  • Stuck PROCESSING count: record idempotensi yang terlalu lama tidak berubah.
  • Outbox lag: jeda antara event ditulis ke outbox dan benar-benar terpublikasi.
  • DLQ volume: jumlah job masuk dead-letter queue.

Tracing dan debugging

Jika memungkinkan, hubungkan satu idempotency_key ke seluruh jejak pemrosesan: message queue, log aplikasi, database row, dan request ke external API. Saat insiden terjadi, tim dapat menjawab pertanyaan berikut dengan cepat:

  • Apakah job diproses lebih dari sekali?
  • Apakah efek samping internal sudah commit?
  • Apakah external API menerima request?
  • Apakah retry sehat atau berbahaya?

Kapan lock membantu, kapan malah jadi bottleneck

Lock membantu bila

  • Ada kontensi tinggi pada entitas yang sama, misalnya banyak event untuk satu order.
  • Operasi non-idempotent sementara belum bisa diubah total, sehingga lock dipakai sebagai lapisan tambahan.
  • Anda ingin menekan kerja ganda yang mahal, misalnya komputasi berat atau panggilan API mahal.

Lock menjadi masalah bila

  • Dipakai sebagai lock global untuk semua job.
  • TTL lock sulit disesuaikan dengan variasi durasi proses.
  • Worker harus menunggu lama sehingga throughput turun.
  • Sistem tetap tidak punya dedup di database; saat lock gagal, duplikasi kembali terjadi.

Pendekatan yang biasanya lebih sehat adalah:

  • Gunakan idempotency key + unique constraint sebagai garis pertahanan utama.
  • Pakai lock per entitas hanya bila benar-benar membantu mengurangi kontensi.
  • Jaga lock sesingkat mungkin dan jangan menggabungkannya dengan transaksi panjang.

Trade-off yang perlu dipahami tim backend

At-least-once + idempotensi vs exactly-once

Mengejar exactly-once end-to-end lintas queue, database, dan external API biasanya mahal, rumit, dan sering tetap tidak mutlak karena batas sistem terdistribusi. Kombinasi at-least-once delivery + idempotent processing umumnya lebih realistis dan bisa diaudit.

Dedup storage menambah kompleksitas

Tabel dedup/inbox/outbox menambah skema, cleanup, indeks, dan beban penyimpanan. Namun biaya ini sering lebih kecil dibanding biaya insiden bisnis akibat duplikasi transaksi.

Retention dedup harus disesuaikan

Menyimpan key terlalu singkat berisiko menerima duplikasi yang terlambat. Menyimpan terlalu lama meningkatkan ukuran data dan biaya indeks. Retention harus menyesuaikan SLA retry, karakter broker, dan potensi replay.

Consistency vs throughput

Semakin banyak validasi atomik, transaksi, dan kontrol status, throughput mentah bisa turun. Namun ini sering trade-off yang benar untuk domain seperti pembayaran, billing, inventory, dan notifikasi penting.

Checklist implementasi operasional

  • Tentukan business idempotency key untuk setiap jenis job penting.
  • Pasang unique constraint pada identitas operasi yang harus unik.
  • Buat status mesin sederhana, jangan hanya boolean sukses/gagal.
  • Tangani duplicate insert sebagai alur normal.
  • Gunakan transactional outbox untuk event yang dipublikasikan setelah perubahan state internal.
  • Gunakan inbox/dedup table pada consumer untuk event yang bisa dikirim ulang.
  • Jika external API mendukung idempotency key, gunakan key yang stabil pada semua retry.
  • Jika API tidak mendukung, siapkan rekonsiliasi status dan prosedur kompensasi.
  • Pastikan visibility timeout sejalan dengan durasi proses dan strategi retry.
  • Pantau stuck processing, duplicate rate, retry rate, dan DLQ.
  • Uji skenario crash: setelah commit sebelum ack, timeout API, network glitch, dan replay message.
  • Tentukan retention untuk dedup key dan prosedur cleanup yang aman.

Penutup

Dalam praktik backend, sistem queue dengan jaminan at-least-once hampir pasti memunculkan duplikasi pada suatu saat. Cara yang paling andal bukan berusaha meniadakan semua retry, melainkan membangun idempotensi worker dan retry tanpa duplikasi efek samping.

Pondasi teknisnya relatif konsisten di banyak stack: business idempotency key, unique constraint, dedup table, status mesin yang jelas, serta outbox/inbox untuk integrasi. Lock bisa membantu pada kasus tertentu, tetapi tidak boleh menjadi satu-satunya pagar. Jika tim Anda menerapkan pola-pola ini dengan observabilitas yang baik, retry akan menjadi mekanisme pemulihan yang aman, bukan sumber insiden baru.