Masalah queue worker yang menumpuk hampir selalu berawal dari pola yang sama: job gagal di-retry terus-menerus, pesan rusak atau tidak valid tetap berputar di antrean, dan tidak ada batas umur kerja yang jelas. Akibatnya, queue berubah menjadi boneyard: penuh job lama, sulit dibedakan mana yang masih layak diproses, mana yang seharusnya dibuang, dan mana yang harus dipindahkan untuk investigasi.
Solusinya bukan sekadar “menambah worker”. Anda perlu kombinasi TTL, retry policy yang terbatas, dead letter queue, backoff, idempotensi, locking, dan visibilitas status job. Dengan desain ini, sistem tetap bisa memproses job yang sehat, mengisolasi job bermasalah, dan mencegah backlog menjadi kuburan permanen.
Mengapa worker menumpuk dan berubah menjadi “boneyard”
Secara operasional, antrean menumpuk bukan hanya karena trafik tinggi. Penyebab yang lebih berbahaya adalah job yang tidak pernah selesai secara definitif. Beberapa pola umum:
- Retry tanpa batas: job gagal terus lalu dikembalikan ke queue tanpa keputusan akhir.
- Poison message: payload selalu gagal diproses karena format salah, data hilang, atau bug deterministik.
- Dependency failure: layanan downstream mati, lalu ribuan job menumpuk karena semua mencoba ulang bersamaan.
- Job kadaluarsa tetap diproses: notifikasi, sinkronisasi, atau refresh cache yang sudah tidak relevan tetap mengonsumsi worker.
- Tanpa idempotensi: retry menimbulkan efek samping ganda, misalnya pembayaran atau pengiriman email dobel.
- Visibility timeout atau ack yang salah: worker crash setelah memproses sebagian, lalu job muncul lagi sebagai duplikat.
Jika masalah ini dibiarkan, queue bukan lagi alat penyangga beban, tetapi tempat parkir job rusak yang terus memakan kapasitas.
Prinsip desain: setiap job harus punya nasib yang jelas
Queue production yang sehat harus memaksa setiap job berakhir ke salah satu status berikut:
- Berhasil: job selesai dan di-ack.
- Retry nanti: job gagal sementara dan dijadwalkan ulang dengan backoff.
- Dibuang: job sudah melewati TTL atau tidak lagi relevan.
- Dipindah ke dead letter queue: job butuh investigasi atau reprocessing manual/terkontrol.
Ini terdengar sederhana, tetapi banyak sistem gagal karena status akhir tidak eksplisit. Akibatnya, job “mengambang” di antara gagal, retry, dan timeout.
Arsitektur sederhana yang operasional
Berikut pola arsitektur generik yang bisa diterapkan di banyak broker queue:
Producer --> Main Queue --> Worker Pool --> Ack / Retry / DLQ
| |
| +-- cek idempotensi
| +-- ambil lock per resource
| +-- cek TTL dan jumlah attempt
|
+-- metrics, logs, tracing, dashboard
Dead Letter Queue --> Inspector/Replay Tool --> Requeue terkontrol atau discardKomponen pentingnya:
- Main Queue: antrean utama untuk job normal.
- Worker Pool: pemroses paralel dengan batas concurrency yang jelas.
- Retry Scheduler: bisa native dari broker atau diatur di aplikasi dengan delay/backoff.
- Dead Letter Queue (DLQ): isolasi untuk job yang gagal permanen atau melebihi retry maksimum.
- State Store: tempat menyimpan status job, idempotency key, lock, dan metadata attempt.
- Observability: metrik, log terstruktur, dan alert agar backlog terdeteksi sebelum membusuk.
TTL: kapan job dianggap terlalu tua untuk diproses
TTL (time to live) adalah batas umur job. Setelah lewat batas itu, job tidak boleh lagi diproses normal karena nilai bisnisnya hilang atau risikonya terlalu tinggi.
Kapan TTL diperlukan
- Notifikasi yang relevansinya hanya beberapa menit atau jam.
- Sinkronisasi data yang akan digantikan oleh event lebih baru.
- Refresh cache yang tidak berguna jika terlalu terlambat.
- Job yang bergantung pada state yang cepat berubah.
Kenapa TTL penting
Tanpa TTL, job lama bercampur dengan job baru dan memenuhi worker. Sistem akhirnya menghabiskan energi untuk pekerjaan yang secara bisnis sudah tidak diperlukan. Ini salah satu penyebab utama queue terasa “sibuk” tapi output nyata rendah.
Cara menerapkan TTL
Simpan metadata waktu saat job dibuat, misalnya created_at atau expires_at. Di awal worker, cek apakah job masih valid.
function process(job):
now = currentTime()
if job.expiresAt != null and now > job.expiresAt:
markJobDiscarded(job, reason="expired")
ack(job)
return
handle(job)
ack(job)Praktiknya ada dua model:
- TTL di broker: pesan bisa dihapus otomatis setelah umur tertentu.
- TTL di level aplikasi: worker memeriksa validitas sebelum memproses.
Jika broker mendukung TTL, tetap pertimbangkan validasi di aplikasi untuk mencegah edge case, terutama bila expiry bergantung pada logika bisnis, bukan sekadar waktu mutlak.
Catatan: TTL tidak selalu berarti job harus masuk DLQ. Untuk job yang sekadar sudah tidak relevan, lebih masuk akal untuk discard dan mencatat alasannya daripada memenuhi DLQ.
Retry yang sehat: terbatas, punya backoff, dan paham jenis kegagalan
Retry tanpa batas adalah penyebab klasik worker menumpuk. Retry seharusnya hanya dipakai untuk kegagalan yang bersifat sementara, seperti timeout jaringan, rate limit, atau layanan downstream yang sedang gangguan.
Bedakan kegagalan transient dan permanent
- Transient: timeout, koneksi putus, error 502/503, rate limiting, lock contention sementara.
- Permanent: payload tidak valid, data wajib tidak ada, referensi entitas tidak ditemukan secara definitif, bug deterministik, kontrak API berubah dan parser gagal terus.
Job dengan kegagalan permanent tidak boleh diputar ulang berkali-kali. Itu hanya mengubah queue menjadi tempat parkir pesan rusak.
Gunakan retry budget dan backoff
Minimal, setiap job perlu:
- attempt count
- max attempts
- delay atau backoff
- klasifikasi error
Contoh pseudocode:
function process(job):
if isExpired(job):
discard(job, "expired")
ack(job)
return
if job.attempt >= job.maxAttempts:
moveToDLQ(job, "max_attempts_exceeded")
ack(job)
return
try:
handle(job)
markSuccess(job)
ack(job)
except ValidationError as e:
moveToDLQ(job, "invalid_payload", error=e)
ack(job)
except TransientError as e:
delay = computeBackoff(job.attempt)
requeue(job, delay=delay, error=e)
except Exception as e:
delay = computeBackoff(job.attempt)
if shouldRetryUnknownError(job, e):
requeue(job, delay=delay, error=e)
else:
moveToDLQ(job, "unknown_non_retryable", error=e)
ack(job)Backoff lebih aman daripada retry instan
Retry instan sering membuat gangguan kecil berubah menjadi badai. Saat layanan downstream melambat, ribuan job gagal lalu langsung mencoba lagi. Inilah retry storm.
Pilih salah satu strategi:
- Fixed backoff: jeda tetap, mudah dipahami, cocok untuk sistem sederhana.
- Exponential backoff: jeda makin besar tiap gagal, cocok untuk dependency yang mungkin butuh waktu pulih.
- Exponential backoff + jitter: paling aman untuk skala besar karena mengurangi lonjakan retry serentak.
Secara operasional, jitter penting agar banyak worker tidak bangun di detik yang sama.
Poison message dan peran dead letter queue
Poison message adalah job yang secara konsisten gagal diproses dan mengganggu throughput. Contohnya payload korup, field wajib hilang, atau referensi data yang mustahil dipenuhi. Job seperti ini harus segera diisolasi.
Kapan job masuk dead letter queue
- Melebihi jumlah retry maksimum.
- Teridentifikasi sebagai error permanent.
- Melanggar kontrak payload atau skema data.
- Menghasilkan exception yang diketahui tidak akan pulih dengan retry biasa.
- Mengandung data yang memerlukan pemeriksaan manual.
Apa yang harus disimpan di DLQ
Jangan hanya memindahkan payload mentah. Simpan metadata yang cukup untuk diagnosis dan replay:
- job id
- jenis job atau topic
- payload ringkas atau payload penuh bila aman
- waktu dibuat
- jumlah attempt
- alasan gagal terakhir
- error class / code / message
- trace atau correlation id
- waktu dipindah ke DLQ
DLQ bukan tempat sampah permanen. Ia adalah area karantina. Job di DLQ harus bisa:
- diinspeksi
- dicari berdasarkan jenis error
- di-replay secara terkontrol
- di-discard permanen jika memang tidak layak
Kesalahan umum: menjadikan DLQ sebagai arsip tanpa proses. Jika tidak ada dashboard, owner, dan prosedur replay/discard, Anda hanya memindahkan boneyard ke antrean lain.
Idempotensi: syarat wajib jika retry diperbolehkan
Begitu Anda mengizinkan retry, Anda harus mengasumsikan job bisa diproses lebih dari sekali. Penyebabnya bukan hanya error aplikasi, tetapi juga crash worker setelah efek samping terjadi namun sebelum ack terkirim.
Kenapa idempotensi penting
Tanpa idempotensi, retry bisa menyebabkan:
- email terkirim dua kali
- invoice dibuat ganda
- saldo terdebet dua kali
- sinkronisasi eksternal menghasilkan state kacau
Cara menerapkan idempotensi
Gunakan idempotency key yang stabil berdasarkan operasi bisnis, bukan berdasarkan attempt. Misalnya payment:{orderId} atau email:{template}:{userId}:{eventId}.
function handle(job):
key = job.idempotencyKey
if idempotencyStore.exists(key):
return
beginTransaction()
performSideEffect(job)
idempotencyStore.save(key, status="done")
commit()Trade-off-nya:
- Perlu storage tambahan.
- Perlu kebijakan retensi untuk key lama.
- Harus hati-hati dengan race condition bila banyak worker memproses job setara.
Untuk operasi yang sangat sensitif, gunakan kombinasi unique constraint, transaction, dan idempotency record.
Locking dan kontrol konkurensi
Worker yang menumpuk sering diperparah oleh banyak job yang berebut resource yang sama. Misalnya beberapa job mencoba mengubah record yang sama, memanggil endpoint eksternal yang sama, atau mengenerate laporan untuk tenant yang sama.
Kapan locking diperlukan
- Satu entitas tidak boleh diproses paralel.
- Operasi mahal harus dibatasi per akun/tenant/resource.
- Dependency eksternal punya limit ketat dan mudah timeout.
Pola locking generik
function process(job):
lockKey = "resource:" + job.resourceId
if !acquireLock(lockKey, ttl=60):
requeue(job, delay=shortDelay(), reason="lock_busy")
return
try:
handle(job)
ack(job)
finally:
releaseLock(lockKey)Perhatikan dua hal:
- TTL lock harus ada agar lock tidak menggantung saat worker crash.
- Durasi lock tidak boleh terlalu pendek sehingga job panjang kehilangan lock di tengah proses.
Jika broker atau runtime mendukung batas konkurensi per queue atau per key, itu bisa membantu. Namun secara aplikasi, Anda tetap perlu memikirkan granularitas lock.
Visibilitas status job: jangan hanya tahu “queue lagi panjang”
Queue yang sehat butuh visibilitas per status, bukan sekadar jumlah pesan total. Anda perlu bisa menjawab:
- Berapa job baru masuk per menit?
- Berapa yang sukses?
- Berapa yang retry?
- Berapa yang expired?
- Berapa yang pindah ke DLQ?
- Berapa usia job tertua?
- Jenis error apa yang paling sering?
Status minimum yang sebaiknya ada
- queued
- processing
- succeeded
- retry_scheduled
- discarded
- dead_lettered
Status ini bisa dicatat di database, cache, event stream, atau sistem observabilitas, tergantung kebutuhan. Tujuannya bukan membuat state machine rumit, tetapi memberi operasi cara melihat apa yang sebenarnya terjadi.
Alur failure yang direkomendasikan
Alur berikut cukup aman untuk banyak sistem backend production:
- Worker mengambil job dari queue.
- Cek apakah job sudah kedaluwarsa berdasarkan TTL bisnis.
- Cek apakah attempt sudah melebihi batas.
- Ambil lock bila job menyentuh resource yang harus serial.
- Cek idempotency key bila ada efek samping.
- Jalankan handler.
- Jika sukses, simpan status dan ack.
- Jika gagal transient, jadwalkan retry dengan backoff + jitter.
- Jika gagal permanent, pindah ke DLQ.
- Jika job sudah tidak relevan, discard dengan alasan yang tercatat.
Poin pentingnya: retry, discard, dan DLQ adalah keputusan yang berbeda. Jangan satukan semuanya di bawah “gagal, coba lagi nanti”.
Kapan job harus dibuang, kapan diproses ulang
Buang job jika
- TTL bisnis sudah lewat.
- Operasi tidak lagi relevan karena ada event yang lebih baru.
- Payload invalid dan tidak ada cara aman untuk memperbaikinya otomatis.
- Reprocessing justru berisiko menimbulkan efek samping yang salah.
Proses ulang jika
- Penyebab gagal jelas bersifat sementara.
- Payload valid dan masih relevan.
- Efek samping aman terhadap retry karena idempotent.
- Dependency sudah pulih dan ada prosedur replay yang terkontrol.
Pindah ke DLQ jika
- Job butuh inspeksi manual.
- Anda perlu mengelompokkan error untuk perbaikan kode atau data.
- Retry otomatis sudah habis tapi job belum boleh dibuang diam-diam.
Checklist observabilitas untuk queue worker
Berikut checklist yang praktis untuk operasi harian:
Metrik inti
- queue depth: jumlah job menunggu.
- oldest job age: umur job tertua di queue.
- processing latency: waktu dari enqueue sampai mulai diproses.
- end-to-end latency: waktu dari enqueue sampai selesai.
- success rate
- retry rate
- DLQ rate
- discard rate karena TTL
- attempt distribution: berapa banyak job selesai di attempt ke-1, ke-2, dst.
- worker concurrency/utilization
- dependency error rate: database, API eksternal, cache, storage.
Log yang wajib ada
- job id
- job type
- attempt
- max attempts
- correlation id / trace id
- enqueue time
- error type
- hasil akhir: success, retry, discard, dead-letter
Alert yang masuk akal
- umur job tertua melewati ambang aman
- laju DLQ naik tajam
- retry rate melonjak
- success rate turun
- worker aktif turun drastis atau crash loop
- satu jenis job mendominasi backlog
Alert terbaik biasanya berbasis tren dan dampak, bukan hanya panjang queue mentah. Queue panjang belum tentu masalah bila throughput tetap sehat dan TTL masih aman.
Pseudocode worker generik yang lebih lengkap
function workerLoop():
while true:
job = dequeue()
if job == null:
sleep(shortInterval)
continue
startedAt = currentTime()
try:
if isExpired(job):
record(job, status="discarded", reason="expired")
ack(job)
continue
if job.attempt >= job.maxAttempts:
moveToDLQ(job, reason="max_attempts_exceeded")
ack(job)
continue
if requiresLock(job):
if !acquireLock(lockKey(job), ttl=lockTTL(job)):
requeue(job, delay=backoffWithJitter(job.attempt), reason="lock_busy")
continue
try:
if hasIdempotencyKey(job) and idempotencyExists(job.idempotencyKey):
record(job, status="succeeded", reason="idempotent_skip")
ack(job)
continue
validate(job.payload)
handle(job)
saveIdempotency(job.idempotencyKey)
record(job, status="succeeded", duration=currentTime()-startedAt)
ack(job)
finally:
if requiresLock(job):
releaseLock(lockKey(job))
except ValidationError as e:
moveToDLQ(job, reason="validation_error", error=e)
ack(job)
except TransientDependencyError as e:
requeue(job, delay=backoffWithJitter(job.attempt), reason="dependency_error", error=e)
except Exception as e:
if isRetryable(e):
requeue(job, delay=backoffWithJitter(job.attempt), reason="unknown_retryable", error=e)
else:
moveToDLQ(job, reason="unknown_non_retryable", error=e)
ack(job)Pseudocode ini sengaja generik. Implementasi detail akan berbeda di setiap broker, tetapi urutan keputusannya tetap sama.
Trade-off dan kesalahan yang sering terjadi
1. Max retry terlalu tinggi
Nilai retry besar terasa aman, tetapi sering hanya menunda keputusan. Jika payload invalid, attempt ke-20 tidak lebih berguna dari attempt ke-2.
2. Semua error dianggap retryable
Ini akar poison message berputar tanpa henti. Klasifikasi error harus eksplisit.
3. TTL hanya di broker, tidak di bisnis
Pesan mungkin belum expired secara teknis, tetapi sudah tidak relevan secara bisnis. Cek dua-duanya bila perlu.
4. Tidak ada tool replay DLQ
Tanpa mekanisme replay terkontrol, DLQ hanya jadi gudang kedua.
5. Tidak ada idempotensi
Begitu worker restart, visibility timeout habis, atau network ack terganggu, duplikasi akan terjadi cepat atau lambat.
6. Menambah worker saat dependency rusak
Kalau bottleneck ada di API eksternal atau database, menambah concurrency justru memperparah kegagalan.
7. Tidak membatasi jenis job “berat” dan “ringan”
Mencampur semua job dalam satu queue dapat membuat job ringan tertahan di belakang job berat. Jika pola beban berbeda, pisahkan queue atau beri prioritas.
Praktik operasional yang layak untuk production
- Tentukan TTL bisnis per jenis job, bukan satu nilai untuk semua.
- Tentukan max attempt dan backoff policy yang berbeda untuk error transient dan permanent.
- Pastikan setiap job punya status final yang jelas.
- Gunakan DLQ dengan metadata lengkap dan dashboard inspeksi.
- Terapkan idempotency key pada job yang menghasilkan efek samping.
- Gunakan lock untuk resource yang rawan diproses paralel.
- Monitor oldest job age, bukan hanya jumlah queue.
- Sediakan prosedur replay DLQ yang dibatasi, diaudit, dan tidak menimbulkan badai retry baru.
- Pisahkan queue untuk job kritis, berat, atau sensitif jika karakteristiknya berbeda.
Penutup
TTL, retry, dan dead letter queue bukan fitur tambahan, melainkan mekanisme kontrol agar worker tidak berubah menjadi tempat parkir job bermasalah. Jika setiap job memiliki umur yang jelas, batas retry yang sehat, jalur karantina untuk poison message, serta perlindungan lewat idempotensi dan locking, backlog akan lebih mudah dikendalikan.
Intinya sederhana: jangan biarkan job gagal hidup selamanya. Di production, queue yang baik bukan queue yang tidak pernah gagal, tetapi queue yang bisa memutuskan dengan cepat apakah sebuah job harus diproses ulang, dikarantina, atau dibuang dengan aman.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!