Queue worker deterministik adalah pendekatan untuk membuat perilaku sistem antrean lebih mudah diprediksi di bawah kegagalan. Intinya sederhana: jangan biarkan worker “berimprovisasi” dengan terlalu banyak koordinasi dinamis. Gunakan aturan kecil yang jelas, state machine sempit, retry yang eksplisit, dan efek samping yang idempotent.

Masalah operasional seperti job bolak-balik antre, retry storm, duplicate execution, stale cache, lock orphan, dan inkonsistensi akhir sering muncul bukan karena sistem kurang canggih, tetapi karena terlalu banyak jalur eksekusi yang saling memengaruhi. Seperti ide bahwa perilaku kompleks bisa lahir dari aturan sederhana yang deterministik, desain worker backend juga sering lebih sehat jika dibangun dari mekanisme kecil yang konsisten.

Mengapa worker sering thrashing dan menciptakan retry chaos

Thrashing terjadi ketika job tidak benar-benar maju, tetapi terus memakan resource: diambil worker, gagal, diantre ulang, memicu cache invalidation, berebut lock, lalu mengulang lagi. Dari luar terlihat sistem “aktif”, padahal throughput efektif rendah.

Pola yang umum:

  • Retry terlalu agresif: semua error dianggap sementara, lalu langsung diulang tanpa jeda yang masuk akal.
  • Job tidak idempotent: eksekusi ulang menciptakan efek ganda, sehingga sistem perlu kompensasi tambahan.
  • Lock terlalu luas: satu lock melindungi terlalu banyak area, membuat antrean saling menunggu.
  • Cache diperlakukan sebagai sumber kebenaran: worker memperbarui cache secara tidak konsisten dengan database.
  • Koordinasi lintas worker terlalu kompleks: banyak sinyal, heartbeat, lease, dan status transien yang sulit dipastikan benar saat terjadi crash.

Akibatnya bukan cuma latensi naik. Sistem juga menjadi sulit di-debug karena satu gejala bisa berasal dari kombinasi timeout, retry policy, dan state yang tidak sinkron.

Prinsip desain queue worker deterministik

1. State machine kecil, bukan alur bercabang tanpa batas

Worker sebaiknya memiliki status minimum yang jelas. Contoh yang sehat untuk satu job:

  • queued
  • claimed
  • processing
  • succeeded
  • failed_terminal
  • retry_scheduled

Hindari status yang terlalu banyak seperti waiting_for_remote_retry_confirmation atau soft_failed_but_cache_pending kecuali benar-benar perlu. Semakin kecil state machine, semakin mudah memastikan semua transisi valid dan observability tetap jelas.

2. Retry policy harus eksplisit dan terbatas

Jangan anggap semua kegagalan pantas di-retry. Pisahkan error ke dalam kategori:

  • Transient: timeout jaringan, rate limit sementara, dependency overload.
  • Persistent: data input tidak valid, referensi hilang, invariant bisnis gagal.
  • Unknown: crash proses, panic, OOM, container restart.

Hanya error transient yang layak di-retry otomatis. Error persistent sebaiknya masuk failed_terminal secepat mungkin. Error unknown bisa di-retry terbatas dengan backoff konservatif.

3. Idempotency bukan fitur tambahan

Dalam queue, duplicate execution adalah kondisi normal. Bisa terjadi karena worker crash setelah menulis hasil tetapi sebelum meng-ack, lease habis, atau broker mengirim ulang. Karena itu, job harus aman dieksekusi lebih dari sekali.

Praktiknya:

  • Gunakan idempotency key yang stabil per operasi bisnis, bukan per attempt.
  • Simpan hasil atau jejak eksekusi di storage yang durable.
  • Pastikan side effect eksternal, seperti membuat invoice atau mengirim webhook, memiliki mekanisme deduplikasi.

4. Lock seperlunya, sedekat mungkin dengan resource yang diproteksi

Lock bukan alat untuk menambal desain state yang kabur. Pakai lock hanya jika ada resource bersama yang benar-benar perlu akses eksklusif. Bila memungkinkan, pilih salah satu:

  • Unique constraint di database untuk mencegah duplikasi.
  • Compare-and-set pada status row.
  • Optimistic concurrency dengan version column atau timestamp.

Lock terdistribusi boleh dipakai, tetapi harus diperlakukan sebagai mekanisme best effort, bukan jaminan mutlak. Selalu rancang sistem tetap aman jika lock hilang terlalu cepat atau orphan setelah crash.

5. Cache jangan memicu loop kerja palsu

Cache sering menjadi sumber thrashing jika worker menggunakannya untuk memutuskan perlu tidaknya reprocess, padahal cache bisa stale atau hilang. Prinsip aman:

  • Jangan jadikan cache satu-satunya dasar transisi state.
  • Invalidasi cache setelah commit state durable, bukan sebelumnya.
  • Gunakan TTL untuk mengontrol staleness, bukan untuk menyandikan state bisnis penting.
  • Untuk hot key, pertimbangkan single-flight atau request coalescing agar recompute tidak meledak.

Arsitektur praktis: sederhana, deterministik, dan tahan gagal

Arsitektur berikut cocok untuk banyak workload backend:

  • Queue broker untuk delivery job.
  • Job table di database sebagai sumber kebenaran status durable.
  • Worker stateless yang mengambil job, memvalidasi state, lalu memproses.
  • Idempotency store yang bisa berupa tabel khusus atau constraint pada tabel domain.
  • Cache hanya untuk akselerasi baca, bukan penentu status final.
  • Metrics dan structured logs untuk mendeteksi retry storm dan duplicate execution.

Alur sederhananya:

  1. Producer membuat record job di database dengan status queued dan idempotency key.
  2. Producer mengirim referensi job ke broker.
  3. Worker menerima pesan, lalu mencoba mengubah status dari queued atau retry_scheduled menjadi claimed secara atomik.
  4. Jika claim gagal, worker berhenti tanpa side effect. Artinya ada worker lain yang sudah menang.
  5. Worker menjalankan logika bisnis dengan idempotency key yang sama.
  6. Jika sukses, worker menandai succeeded lalu invalidasi atau refresh cache yang relevan.
  7. Jika gagal transient, worker menghitung next retry deterministik dan menandai retry_scheduled.
  8. Jika gagal terminal, worker menandai failed_terminal.

Poin pentingnya: broker hanya media pengiriman. Status durable ada di database, sehingga crash worker atau redelivery tidak mengacaukan kebenaran akhir.

Pseudocode worker yang kecil tetapi kuat

function handleMessage(jobId, now):
    job = db.findJob(jobId)
    if job is null:
        ackMessage()
        return

    if job.status in ["succeeded", "failed_terminal"]:
        ackMessage()
        return

    claimed = db.compareAndSetStatus(
        jobId=jobId,
        allowedCurrent=["queued", "retry_scheduled"],
        nextStatus="claimed",
        claimedAt=now,
        attempt=job.attempt + 1
    )

    if not claimed:
        ackMessage()
        return

    try:
        db.updateStatus(jobId, "processing")

        if idempotencyStore.alreadyCompleted(job.idempotencyKey):
            db.updateStatus(jobId, "succeeded")
            ackMessage()
            return

        result = performBusinessOperation(job.payload, job.idempotencyKey)

        idempotencyStore.recordSuccess(job.idempotencyKey, result.summary)
        db.updateStatus(jobId, "succeeded", completedAt=now)
        cache.invalidate(job.cacheKeys)
        ackMessage()

    except TransientError as err:
        if job.attempt >= job.maxAttempts:
            db.updateStatus(jobId, "failed_terminal", errorCode=err.code)
            ackMessage()
            return

        nextRunAt = computeBackoff(job.attempt, now)
        db.updateStatus(jobId, "retry_scheduled", nextRunAt=nextRunAt, errorCode=err.code)
        requeueFor(nextRunAt)
        ackMessage()

    except PersistentError as err:
        db.updateStatus(jobId, "failed_terminal", errorCode=err.code)
        ackMessage()

    except Exception as err:
        nextRunAt = computeConservativeBackoff(job.attempt, now)
        db.updateStatus(jobId, "retry_scheduled", nextRunAt=nextRunAt, errorCode="unknown")
        requeueFor(nextRunAt)
        ackMessage()

Pseudocode ini tampak biasa, tetapi ada beberapa sifat penting:

  • Claim atomik mencegah dua worker memproses job yang sama secara bersamaan dalam jalur normal.
  • Status final durable mencegah broker redelivery mengulang side effect tanpa kontrol.
  • Idempotency check menjaga duplicate execution tetap aman.
  • Retry policy deterministik mencegah chaos akibat keputusan retry yang berubah-ubah.

Desain retry yang tidak memicu storm

Gunakan backoff yang stabil

Retry cepat memang terasa responsif, tetapi jika dependency sedang sakit, itu hanya memperparah kondisi. Gunakan backoff bertahap yang bisa diprediksi. Anda boleh menambahkan jitter kecil untuk menghindari sinkronisasi massal, tetapi jangan sampai policy menjadi acak total dan sulit dipahami.

Contoh pendekatan:

  • Attempt 1: 30 detik
  • Attempt 2: 2 menit
  • Attempt 3: 10 menit
  • Attempt 4+: berhenti atau pindah dead-letter/manual review

Tidak perlu terlalu banyak attempt. Dalam banyak sistem, retry sedikit tapi disiplin lebih aman daripada retry panjang tanpa batas.

Retry harus berbasis jenis error

Kesalahan umum adalah menyamakan semua exception. Padahal:

  • HTTP 429/503 atau timeout sering layak di-retry.
  • HTTP 400 karena payload salah biasanya tidak akan membaik dengan retry.
  • Constraint violation bisa berarti duplicate operation yang justru perlu dipetakan ke sukses idempotent, bukan gagal total.

Batasi konkurensi per dependency yang rapuh

Jika semua worker bebas menghantam satu API eksternal yang sedang lambat, retry storm akan berubah jadi insiden. Gunakan pembatasan sederhana:

  • Semaphore per dependency.
  • Queue terpisah untuk job berat atau job yang memanggil sistem lambat.
  • Rate limit di sisi worker untuk operasi tertentu.

Ini lebih sederhana dan sering lebih efektif daripada menambah orkestrasi global yang sulit dipelihara.

Idempotency ketat di level storage dan side effect

Idempotency yang kuat biasanya membutuhkan dukungan storage. Beberapa pola yang praktis:

1. Tabel eksekusi idempotent

CREATE TABLE processed_operations (
  idempotency_key TEXT PRIMARY KEY,
  operation_type TEXT NOT NULL,
  status TEXT NOT NULL,
  result_ref TEXT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

Saat worker hendak menjalankan side effect, ia mencoba membuat atau mengubah record ini secara atomik. Jika key sudah ada dan status sukses, worker bisa menganggap operasi sudah selesai.

2. Unique constraint pada entitas bisnis

Jika job membuat resource baru, gunakan natural key atau external reference yang unik. Misalnya, satu pembayaran hanya boleh menghasilkan satu invoice dengan payment_id unik. Jika insert kedua gagal karena unique constraint, worker dapat memeriksa apakah hasil pertama valid lalu menandainya sebagai sukses idempotent.

3. Outbox untuk side effect eksternal

Jika perubahan database dan publish event harus konsisten, pola outbox sering lebih sederhana daripada transaksi terdistribusi. Worker menulis perubahan domain dan event ke database dalam satu transaksi, lalu publisher terpisah mengirim event dari outbox secara idempotent.

Trade-off-nya adalah kompleksitas tambahan di jalur publish, tetapi ini masih lebih deterministik daripada mencoba menyinkronkan banyak sistem sekaligus dalam satu langkah rapuh.

Locking seperlunya: kapan dipakai, kapan dihindari

Lock sering dipilih terlalu cepat untuk menyelesaikan duplicate execution. Padahal banyak kasus lebih aman diselesaikan dengan operasi atomik di database.

Gunakan lock jika:

  • Ada resource non-transaksional yang benar-benar harus diakses satu per satu.
  • Anda tidak punya primitive compare-and-set yang cukup di storage utama.
  • Durasi critical section pendek dan terukur.

Hindari lock jika:

  • Lock hanya dipakai untuk menutupi job yang tidak idempotent.
  • Lock harus dipegang lama sambil menunggu API eksternal lambat.
  • Anda tidak punya strategi jelas untuk expiry, renewal, dan orphan recovery.

Mengatasi lock orphan

Jika tetap memakai distributed lock, jangan berasumsi unlock selalu terjadi. Worker bisa crash, jaringan bisa terputus, atau proses bisa freeze. Karena itu:

  • Gunakan TTL yang masuk akal.
  • Simpan owner identity agar lock mudah diaudit.
  • Pastikan operasi tetap aman jika lock kedaluwarsa lalu worker lama masih berjalan.

Kalimat terakhir sangat penting. Jika desain Anda hancur ketika dua worker sempat overlap beberapa detik, masalah utamanya bukan di lock, tetapi di kurangnya idempotency dan state transition yang aman.

Cache yang tidak menyebabkan stale state dan thrashing

Jangan update cache sebelum state durable

Kesalahan klasik: worker menulis cache sukses, lalu gagal commit database. Akibatnya pembaca melihat hasil yang belum benar-benar final. Urutan yang lebih aman:

  1. Tulis state durable dulu.
  2. Commit transaksi.
  3. Baru invalidasi atau refresh cache.

Pilih invalidasi sederhana

Untuk banyak sistem, cache-aside dengan invalidasi setelah write cukup. Jangan terlalu cepat membangun skema invalidasi canggih lintas banyak key jika kebutuhan belum jelas. Semakin rumit invalidasi, semakin besar peluang stale cache sulit dilacak.

Hindari cache stampede dari worker

Jika banyak job selesai bersamaan lalu semua memicu recompute key yang sama, cache bisa menjadi sumber load tambahan. Solusi praktis:

  • Invalidasi key, tetapi biarkan pembaca pertama yang membangun ulang.
  • Gunakan single-flight agar hanya satu proses yang recompute.
  • Tambahkan TTL yang sedikit tersebar untuk mengurangi expiry serentak.

Failure mode yang harus diantisipasi sejak awal

1. Duplicate execution

Penyebab: redelivery broker, timeout ack, worker crash setelah side effect. Mitigasi: idempotency key, unique constraint, final state durable.

2. Job bolak-balik antre tanpa progres

Penyebab: retry semua error, state tidak berubah, dependency eksternal sakit. Mitigasi: klasifikasi error, max attempts kecil, backoff eksplisit, dead-letter atau review manual.

3. Retry storm

Penyebab: banyak job gagal bersama lalu diulang serentak. Mitigasi: backoff bertahap, jitter kecil, rate limit per dependency, pembatasan concurrency.

4. Stale cache

Penyebab: write-through tidak konsisten, invalidasi gagal, cache dijadikan sumber kebenaran. Mitigasi: durable write lebih dulu, cache-aside, audit path invalidasi.

5. Lock orphan

Penyebab: worker mati sebelum unlock. Mitigasi: TTL, owner tracking, desain aman saat overlap sementara tetap terjadi.

6. Konsistensi akhir yang membingungkan

Penyebab: status job sukses tetapi side effect belum terpublikasi, atau sebaliknya. Mitigasi: definisikan apa arti “sukses” secara sempit dan operasional. Jika publish event adalah bagian dari sukses, gunakan outbox. Jika tidak, pisahkan state domain dan state distribusi event dengan jelas.

Checklist observability untuk queue worker deterministik

Worker yang sederhana tetap perlu observability kuat. Tanpa ini, Anda tidak tahu apakah determinisme benar-benar membantu.

Log yang wajib ada

  • job_id
  • idempotency_key
  • attempt
  • previous_status dan next_status
  • error_class dan error_code
  • dependency_name bila memanggil sistem luar
  • lock_owner jika memakai lock
  • cache_keys yang diinvalidasi bila relevan

Metrik penting

  • Queue depth per tipe job
  • Job age: umur job tertua di antrean
  • Attempt count distribution
  • Success rate per attempt
  • Terminal failure rate
  • Retry scheduled rate
  • Duplicate detection rate dari idempotency store
  • Claim conflict rate untuk mendeteksi perebutan job
  • Processing latency dan end-to-end latency
  • Dependency error rate per sistem eksternal
  • Cache hit/miss untuk key penting
  • Dead-letter volume jika digunakan

Alert yang berguna

  • Lonjakan retry_scheduled dalam jangka pendek.
  • Kenaikan tajam attempt rata-rata.
  • Queue depth naik sementara throughput tetap atau turun.
  • Claim conflict tinggi yang menandakan duplikasi dispatch atau race.
  • Terminal failure tinggi untuk satu error code tertentu.

Contoh aturan operasional yang sengaja sederhana

Berikut contoh kebijakan yang sering cukup untuk sistem nyata:

  • Satu job hanya punya maksimal 4 attempt.
  • Retry hanya untuk timeout, 429, 503, dan crash tak terklasifikasi.
  • Semua operasi tulis ke sistem internal memakai idempotency key.
  • Lock hanya dipakai untuk resource eksternal yang tidak mendukung deduplikasi.
  • Cache tidak pernah dijadikan dasar penentuan sukses/gagal job.
  • Semua state transisi terekam di database dan bisa diaudit.

Kebijakan seperti ini mungkin terdengar terlalu sederhana. Justru itu keunggulannya. Ia mudah diuji, mudah dipantau, dan perilakunya konsisten saat insiden.

Trade-off: pendekatan sederhana vs koordinasi kompleks

Kelebihan pendekatan sederhana dan deterministik

  • Lebih mudah di-debug karena jalur eksekusi terbatas.
  • Lebih tahan terhadap crash parsial dan redelivery.
  • Observability lebih jelas karena state machine kecil.
  • Biaya mental tim lebih rendah.

Kekurangannya

  • Beberapa workflow kompleks perlu dipecah menjadi lebih banyak job kecil.
  • Anda mungkin mengorbankan sedikit optimasi lokal demi perilaku global yang stabil.
  • Tidak semua kasus bisa diselesaikan tanpa koordinasi tambahan, terutama jika banyak resource eksternal harus konsisten ketat secara serempak.

Kapan koordinasi lebih kompleks memang layak

Jika Anda punya workflow multi-langkah dengan kompensasi formal, dependency sangat banyak, atau kebutuhan audit state lintas domain yang ketat, orchestrator atau saga bisa masuk akal. Namun tetap bawa prinsip yang sama: state eksplisit, retry terbatas, dan idempotency di setiap langkah. Kompleksitas sebaiknya muncul karena kebutuhan bisnis nyata, bukan karena worker dasar sudah rapuh.

Penutup

Queue worker deterministik bukan berarti sistem jadi kaku. Artinya Anda membatasi sumber ketidakpastian ke aturan yang bisa diaudit: state machine kecil, retry yang jelas, idempotency ketat, locking seperlunya, dan cache yang tidak ikut menentukan kebenaran bisnis. Dari aturan sederhana ini, perilaku sistem secara keseluruhan justru menjadi lebih stabil di bawah kegagalan.

Jika saat ini antrean Anda sering thrashing, job bolak-balik masuk queue, atau duplicate execution sulit dijelaskan, mulailah dari penyederhanaan, bukan dari lapisan koordinasi baru. Dalam banyak kasus, sistem backend yang paling tenang bukan yang paling rumit, tetapi yang paling deterministik.