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 discard

Komponen 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:

  1. Worker mengambil job dari queue.
  2. Cek apakah job sudah kedaluwarsa berdasarkan TTL bisnis.
  3. Cek apakah attempt sudah melebihi batas.
  4. Ambil lock bila job menyentuh resource yang harus serial.
  5. Cek idempotency key bila ada efek samping.
  6. Jalankan handler.
  7. Jika sukses, simpan status dan ack.
  8. Jika gagal transient, jadwalkan retry dengan backoff + jitter.
  9. Jika gagal permanent, pindah ke DLQ.
  10. 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.