Worker queue dengan jaminan at-least-once delivery harus dirancang dengan asumsi bahwa satu pesan dapat diterima lebih dari sekali. Cara menghindari efek samping ganda bukan dengan mengejar exactly-once delivery di seluruh sistem, melainkan dengan membuat consumer bersifat idempoten: pemrosesan ulang pesan yang sama menghasilkan keadaan bisnis yang sama.
Praktiknya, gunakan idempotency key yang stabil, penyimpanan status pemrosesan dengan constraint unik, klaim atomik sebelum menjalankan efek samping, retry dengan exponential backoff dan jitter, serta dead-letter queue (DLQ) untuk kegagalan permanen atau retry yang habis.
Mengapa Duplicate Delivery Terjadi pada Queue
Mayoritas broker queue memberikan jaminan at-least-once karena lebih aman mengirim ulang pesan daripada kehilangan pesan. Konsekuensinya, worker harus menganggap delivery bersifat tidak unik.
- Worker crash setelah efek samping terjadi. Worker berhasil meminta provider mengirim email, lalu proses mati sebelum melakukan acknowledgement ke broker. Broker mengirim ulang pesan meskipun email pertama kemungkinan sudah terkirim.
- Timeout jaringan yang ambigu. Worker memanggil API pembayaran atau email. Server provider mungkin sudah menerima dan memproses permintaan, tetapi respons tidak sampai ke worker. Jika worker retry tanpa idempotency key, efek samping dapat terjadi dua kali.
- Visibility timeout habis. Pada queue yang menyembunyikan pesan selama sedang diproses, pesan akan terlihat lagi ketika batas visibilitas terlewati. Jika pekerjaan lebih lama dari batas tersebut, worker kedua dapat mengambil pesan yang masih dikerjakan worker pertama.
- Acknowledgement gagal atau terlambat. Worker menyelesaikan pekerjaan, tetapi koneksi ke broker putus sebelum ack diterima. Broker tidak dapat membuktikan pekerjaan selesai dan akan melakukan redelivery.
Karena itu, acknowledgement sebaiknya dilakukan setelah status pemrosesan tersimpan dengan benar. Namun, urutan tersebut tetap tidak dapat membuat panggilan ke sistem eksternal dan ack broker menjadi satu transaksi atomik. Idempotensi tetap diperlukan.
Prinsip Desain Retry Queue Tanpa Duplikasi
Gunakan idempotency key yang deterministik
Idempotency key adalah identitas operasi bisnis, bukan identitas satu kali delivery dari broker. Key harus sama saat pesan diretry atau dikirim ulang karena redelivery.
- Pengiriman email invoice:
invoice:{invoice_id}:email:v1 - Pembuatan invoice dari order:
order:{order_id}:invoice - Webhook ke partner:
webhook:{event_id}:{destination_id}
Jangan memakai UUID baru yang dibuat worker pada setiap percobaan. UUID baru membuat setiap retry terlihat sebagai operasi baru dan menghilangkan manfaat idempotensi. Jika payload dapat berubah, masukkan versi atau jenis operasi ke key agar perubahan yang memang sah tidak tertahan oleh hasil lama.
Simpan status pemrosesan dan hasil yang relevan
Satu tabel relasional sering cukup untuk banyak kasus. Constraint unik pada idempotency_key menjadi pagar utama ketika beberapa worker menerima pesan yang sama secara bersamaan.
CREATE TABLE job_executions (
id BIGINT PRIMARY KEY,
idempotency_key VARCHAR(255) NOT NULL,
job_type VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
lease_until TIMESTAMP NULL,
result_ref VARCHAR(255) NULL,
last_error_code VARCHAR(100) NULL,
last_error_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (idempotency_key)
);
Status minimal yang berguna adalah processing, succeeded, retrying, dan failed. Kolom result_ref dapat menyimpan ID email dari provider, ID invoice, atau referensi hasil lain. Hindari menyimpan payload sensitif atau isi error mentah tanpa kebijakan redaksi dan retensi data.
Lakukan atomic claim, bukan pengecekan lalu insert terpisah
Pola yang rentan adalah: baca apakah key ada, lalu jika belum ada lakukan pekerjaan. Dua worker dapat membaca kondisi yang sama sebelum salah satunya menulis hasil. Sebagai gantinya, gunakan operasi atomik di database: insert dengan unique constraint, atau update bersyarat yang hanya berhasil ketika lease telah berakhir.
Secara konsep, worker hanya boleh memproses jika berhasil mengklaim key. Jika key sudah berstatus succeeded, worker langsung ack pesan karena efek bisnis telah selesai. Jika key masih diklaim worker lain dan lease_until belum lewat, pesan ditunda agar tidak dikerjakan paralel.
Catatan: lease bukan bukti bahwa worker sebelumnya mati. Lease hanya mekanisme pemulihan ketika worker crash atau macet. Karena lease dapat kedaluwarsa saat worker lama sebenarnya masih berjalan, operasi eksternal tetap harus idempoten.
Contoh Alur: Mengirim Email Invoice
Misalkan layanan billing menerbitkan event invoice.created. Worker harus mengirim email invoice tepat satu kali secara efektif, walaupun event diterima berulang.
- Producer membuat event dengan
invoice_id,recipient_id, danevent_id. Gunakan transactional outbox bila event diterbitkan bersamaan dengan perubahan database invoice, agar invoice tersimpan tetapi event tidak hilang akibat crash. - Worker membentuk key
invoice:{invoice_id}:email:v1. - Worker melakukan atomic claim pada
job_executionsdan menetapkan lease. - Worker memanggil provider email dengan idempotency key yang sama, jika provider mendukungnya. Simpan ID pesan dari provider sebagai
result_ref. - Worker menandai eksekusi sebagai
succeededdalam database, lalu meng-ack pesan queue. - Jika worker crash setelah provider menerima permintaan tetapi sebelum status sukses tersimpan, retry berikutnya mengirim key yang sama ke provider. Provider yang idempoten dapat mengembalikan hasil pengiriman sebelumnya, bukan membuat email kedua.
Jika provider email tidak mendukung idempotency key, tidak ada cara universal untuk membuktikan apakah request timeout telah menghasilkan pengiriman. Kurangi risiko dengan memakai provider yang mendukung deduplikasi, menyimpan request ID provider bila tersedia, atau memilih desain bisnis yang dapat mentoleransi duplikasi. Untuk pembuatan invoice internal, unique constraint seperti UNIQUE(order_id) sering menjadi pagar tambahan yang lebih kuat.
Pseudocode Worker: Claim, Retry, dan Ack
handle(message):
key = "invoice:" + message.invoice_id + ":email:v1"
now = clock.now()
claim = executionStore.claimAtomically(
key = key,
jobType = "send_invoice_email",
leaseUntil = now + 5 minutes
)
if claim.status == "already_succeeded":
queue.ack(message)
return
if claim.status == "leased_by_other_worker":
queue.defer(message, shortDelay())
return
try:
response = emailProvider.send(
recipient = message.recipient,
template = "invoice",
invoiceId = message.invoice_id,
idempotencyKey = key
)
executionStore.markSucceeded(key, response.messageId)
queue.ack(message)
catch RetryableError as error:
attempt = claim.attemptCount
if attempt >= MAX_ATTEMPTS:
executionStore.markFailed(key, error.code)
queue.moveToDlq(message, reason = error.code)
return
delay = backoffWithJitter(attempt)
executionStore.markRetrying(key, error.code)
queue.defer(message, delay)
catch PermanentError as error:
executionStore.markFailed(key, error.code)
queue.moveToDlq(message, reason = error.code)
Implementasi claimAtomically harus menangani dua kondisi: membuat baris baru bila key belum ada, atau mengambil kembali pekerjaan yang lease-nya kadaluwarsa dan belum sukses. Detail sintaks berbeda antar-database, tetapi invariant-nya sama: hanya satu transaksi yang boleh memperoleh hak proses aktif untuk key yang sama.
Backoff Eksponensial, Jitter, dan Batas Retry
Retry langsung dapat memperparah gangguan: ribuan worker akan menekan API yang sedang gagal pada waktu bersamaan. Gunakan backoff eksponensial agar jeda meningkat per percobaan, lalu tambahkan jitter untuk menyebarkan waktu retry.
baseDelay = 5 seconds
maxDelay = 15 minutes
cap = min(maxDelay, baseDelay * 2^attempt)
delay = random(0, cap) // full jitter
Full jitter memilih delay acak antara nol dan nilai batas. Pendekatan ini membantu mengurangi retry storm ketika banyak pesan gagal pada waktu yang sama. Tetapkan juga nilai maksimum agar retry tidak tumbuh tanpa batas.
Klasifikasikan error sebelum retry
- Retryable: timeout, gangguan jaringan sementara, respons rate limit, atau kegagalan layanan upstream yang bersifat sementara.
- Permanen: alamat email tidak valid, payload melanggar schema, kredensial salah, atau referensi invoice tidak ditemukan dan tidak mungkin tersedia kemudian.
- Ambigu: timeout setelah request dikirim. Perlakukan sebagai retryable, tetapi hanya aman bila endpoint tujuan atau operasi bisnis memiliki idempotency key.
Batas retry harus mempertimbangkan nilai bisnis, SLA, dan durasi gangguan yang realistis. Setelah batas tercapai, pindahkan pesan ke DLQ; jangan mengulang selamanya karena itu menyembunyikan bug permanen dan membebani sistem.
DLQ: Karantina untuk Kegagalan yang Perlu Tindakan
DLQ bukan tempat membuang pesan. DLQ adalah antrean karantina untuk pesan yang gagal permanen, melebihi batas retry, atau tidak dapat diproses karena kontrak payload berubah. Simpan metadata yang cukup untuk diagnosis:
- referensi pesan atau payload yang telah disanitasi;
- idempotency key, jenis job, dan versi schema;
- jumlah percobaan, waktu gagal pertama dan terakhir;
- kode error terklasifikasi, bukan hanya stack trace;
- correlation ID atau trace ID untuk menelusuri request asal.
Replay dari DLQ harus terkontrol. Perbaiki penyebabnya terlebih dahulu, validasi payload, lalu replay secara bertahap. Karena consumer idempoten, replay dengan key yang sama aman untuk pesan yang sebenarnya sudah selesai tetapi salah diklasifikasikan. Jangan replay seluruh DLQ secara massal tanpa rate limit karena dapat menciptakan lonjakan beban baru.
Metrik, Alert, dan Checklist Saat Retry Melonjak
Metrik yang perlu dipantau
- kedalaman queue utama, queue retry, dan DLQ;
- umur pesan tertua serta waktu tunggu end-to-end;
- jumlah attempt per job dan persentase sukses per attempt;
- laju error menurut dependency, kode error, dan jenis job;
- jumlah lease yang kedaluwarsa atau pesan yang diproses ulang;
- rasio idempotency hit: pesan yang di-ack karena statusnya sudah sukses;
- latensi worker dan durasi panggilan dependency dibanding visibility timeout.
Buat alert untuk pertumbuhan retry yang berkelanjutan, DLQ yang tidak kosong melebihi ambang operasional, umur pesan tertua yang melanggar SLA, dan peningkatan timeout dependency. Alert berbasis laju atau tren biasanya lebih berguna daripada hanya alert saat queue tidak kosong.
Checklist investigasi lonjakan retry
- Kelompokkan error berdasarkan dependency dan kode error: apakah dominan timeout, rate limit, autentikasi, atau validasi payload?
- Bandingkan lonjakan dengan deploy aplikasi, perubahan konfigurasi, rotasi secret, atau perubahan schema event.
- Periksa latency p95/p99 worker dan dependency. Pastikan visibility timeout serta lease lebih panjang dari durasi proses terburuk yang wajar.
- Verifikasi apakah worker bertambah atau berkurang, mengalami crash loop, kehabisan koneksi database, CPU, atau memori.
- Periksa distribusi idempotency key. Banyak retry pada key yang sama mengarah ke masalah data atau dependency spesifik; banyak key berbeda mengarah ke gangguan sistemik.
- Periksa apakah backoff benar-benar diterapkan dan pesan tidak segera masuk kembali ke queue utama.
- Jika upstream sedang gagal, kurangi concurrency atau terapkan circuit breaker/rate limit untuk mencegah backlog berubah menjadi retry storm.
Trade-off: Exactly-Once vs Idempotent Consumer
Exactly-once lintas broker, database, dan layanan eksternal sulit dicapai karena membutuhkan koordinasi transaksi antar-sistem atau protokol khusus. Bahkan jika broker memiliki fitur deduplikasi, fitur tersebut tidak otomatis membuat API email, payment gateway, dan database aplikasi menjadi satu operasi atomik.
Idempotent consumer menerima kenyataan bahwa delivery dapat berulang, lalu memastikan pengulangan tidak menghasilkan efek bisnis tambahan. Pendekatan ini membutuhkan desain key, constraint database, status store, dan dukungan idempotensi pada dependency eksternal. Kompleksitasnya tetap ada, tetapi batas kegagalannya lebih jelas dan dapat diuji.
Target praktisnya adalah effectively-once processing: pesan boleh delivered berkali-kali, tetapi invoice tidak dibuat dua kali dan email tidak dikirim dua kali secara efektif. Kombinasikan idempotency key, atomic claim, unique constraint bisnis, retry yang terukur, serta DLQ yang dioperasikan aktif untuk mencapai target tersebut.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!