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:
queuedclaimedprocessingsucceededfailed_terminalretry_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:
- Producer membuat record job di database dengan status
queueddan idempotency key. - Producer mengirim referensi job ke broker.
- Worker menerima pesan, lalu mencoba mengubah status dari
queuedatauretry_scheduledmenjadiclaimedsecara atomik. - Jika claim gagal, worker berhenti tanpa side effect. Artinya ada worker lain yang sudah menang.
- Worker menjalankan logika bisnis dengan idempotency key yang sama.
- Jika sukses, worker menandai
succeededlalu invalidasi atau refresh cache yang relevan. - Jika gagal transient, worker menghitung next retry deterministik dan menandai
retry_scheduled. - 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:
- Tulis state durable dulu.
- Commit transaksi.
- 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_ididempotency_keyattemptprevious_statusdannext_statuserror_classdanerror_codedependency_namebila memanggil sistem luarlock_ownerjika memakai lockcache_keysyang 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_scheduleddalam 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!