Runbook queue yang sehat bukan sekadar daftar langkah saat sistem bermasalah. Di sistem backend yang memakai queue, masalah utama sering muncul karena tim terlalu cepat berasumsi: job pasti sekali jalan, retry pasti aman, event pasti berurutan, cache pasti segera sinkron. Asumsi seperti ini menghasilkan “jawaban instan” yang salah, lalu berubah menjadi duplikasi proses, lock yang tertinggal, cache stale, dan data yang akhirnya inkonsisten.
Pendekatan yang lebih aman adalah prediksi sebelum eksekusi: sebelum worker dijalankan atau diubah, identifikasi dulu failure mode yang paling mungkin terjadi. Setelah job selesai, jangan langsung percaya hasilnya—validasi efeknya di database, cache, dan event lanjutan. Disiplin ini mirip kebiasaan menulis prediksi sebelum membaca jawaban akhir: tujuannya bukan menebak dengan sempurna, tetapi memaksa kita berpikir tentang titik gagal sistem sebelum sistem gagal di produksi.
Mengapa queue sering terlihat sehat padahal datanya sudah rusak?
Banyak insiden queue tidak terlihat sebagai error besar. Dashboard worker bisa tampak normal, CPU tidak penuh, dan pesan tetap keluar-masuk. Namun di level bisnis, ada gejala yang jauh lebih penting:
- Job dobel: satu order dikirim dua kali, satu email terkirim berulang, satu saldo dipotong dua kali.
- Cache stale: worker berhasil update database, tetapi cache lama tetap tersaji ke API.
- Lock bocor: job berikutnya tertahan karena lock tidak pernah dilepas setelah crash.
- Retry storm: kegagalan kecil memicu ribuan retry serentak dan memperparah bottleneck.
- Out-of-order event: event pembatalan diproses sebelum event pembuatan, atau versi lama menimpa versi baru.
- Data inkonsisten: status di database, cache, search index, dan notifikasi pengguna tidak lagi cocok.
Akar masalahnya hampir selalu sama: queue adalah sistem at least once delivery dalam banyak implementasi praktis. Artinya, sistem harus dirancang dengan asumsi bahwa pesan bisa diproses lebih dari sekali, terlambat, atau dalam urutan yang tidak ideal. Karena itu, fokus utamanya bukan “bagaimana agar worker selalu sukses sekali jalan”, tetapi “bagaimana agar hasil akhirnya tetap benar walaupun eksekusi tidak ideal”.
Prinsip kerja: prediksi failure mode sebelum eksekusi, validasi hasil sesudahnya
Sebelum membahas pola teknis, ada satu disiplin operasional yang sangat berguna:
- Tulis prediksi failure mode untuk setiap jenis job penting.
- Tentukan invariant atau kondisi yang harus tetap benar setelah job dijalankan.
- Tambahkan validasi hasil di level data, bukan hanya status sukses di worker.
Contoh prediksi failure mode
Misalnya ada job ProcessPaymentAndShipOrder. Prediksi minimumnya bisa seperti ini:
- Job dieksekusi dua kali karena worker crash setelah menulis ke database tetapi sebelum ack.
- Lock aktif lebih lama dari durasi normal sehingga job tertahan.
- Cache order tidak di-invalidasi setelah status berubah.
- Event order.shipped terkirim sebelum status pembayaran benar-benar paid.
- Retry terjadi saat gateway pembayaran sebenarnya sudah memproses transaksi pertama.
Dari prediksi ini, tim lalu menentukan invariant:
- Satu order hanya boleh memiliki satu transaksi sukses yang aktif.
- Status shipped tidak boleh muncul bila status pembayaran belum valid.
- Cache order harus merefleksikan versi data terbaru setelah update.
- Event keluar harus dapat ditelusuri ke perubahan data yang sah.
Disiplin ini membuat runbook lebih tajam. Tim tidak hanya bereaksi pada error log, tetapi sudah punya hipotesis tentang bagaimana sistem bisa rusak dan apa yang harus diperiksa.
Pola teknis utama untuk queue yang sehat
1. Idempotency key untuk menahan efek ganda
Idempotency key adalah lapisan paling penting untuk mencegah job dobel menghasilkan efek bisnis ganda. Jika queue mengirim pesan dua kali, worker tetap boleh berjalan dua kali, tetapi hasil akhir harus tetap satu.
Praktiknya, pilih key yang stabil terhadap aksi bisnis, misalnya:
payment:{order_id}:{attempt_group}untuk pembayaranemail:{template}:{user_id}:{business_event_id}untuk emailsync_inventory:{sku}:{source_event_id}untuk sinkronisasi stok
Simpan key itu di storage yang bisa di-query dengan cepat, lalu pastikan ada constraint unik atau pencatatan status eksekusi.
BEGIN;
INSERT INTO processed_jobs (idempotency_key, status, created_at)
VALUES ('payment:ORD-123:checkout', 'started', NOW())
ON CONFLICT (idempotency_key) DO NOTHING;
-- jika tidak ada row yang masuk, job ini kemungkinan duplikat
-- cek status sebelumnya lalu putuskan skip / resume / verify
-- proses bisnis yang aman
UPDATE orders
SET payment_status = 'paid', updated_at = NOW()
WHERE id = 'ORD-123' AND payment_status != 'paid';
UPDATE processed_jobs
SET status = 'completed', completed_at = NOW()
WHERE idempotency_key = 'payment:ORD-123:checkout';
COMMIT;Mengapa ini bekerja? Karena worker tidak lagi mengandalkan asumsi bahwa pesan hanya datang sekali. Sistem memindahkan kebenaran ke data: apakah efek bisnis untuk key ini sudah pernah diterapkan atau belum.
Kesalahan umum:
- Idempotency key dibuat dari timestamp acak sehingga duplikat tidak terdeteksi.
- Key hanya dicek di memory worker, bukan di storage bersama.
- Constraint unik ada, tetapi side effect eksternal sudah terjadi sebelum pengecekan dilakukan.
2. Visibility timeout yang sesuai durasi kerja nyata
Pada banyak sistem queue, pesan yang sedang diproses akan “menghilang sementara” selama visibility timeout. Jika worker tidak selesai sebelum timeout habis, pesan bisa muncul lagi dan diproses worker lain.
Timeout yang terlalu pendek menyebabkan duplikasi. Timeout yang terlalu panjang memperlambat recovery saat worker mati. Maka nilainya harus mengikuti karakter job:
- P50/P95 durasi proses aktual
- waktu untuk dependency eksternal seperti API atau database
- kemampuan worker memperpanjang lease bila proses lama
Aturan praktis: jangan menebak dari teori. Ukur durasi nyata per jenis job, lalu beri margin wajar. Jika job bisa sangat lama dan bervariasi, pecah menjadi langkah lebih kecil atau gunakan mekanisme perpanjangan lease bila tersedia.
3. Distributed lock untuk serialisasi, bukan sebagai satu-satunya pagar
Distributed lock berguna ketika hanya satu worker yang boleh memproses entitas tertentu pada saat yang sama, misalnya satu akun, satu order, atau satu SKU. Namun lock tidak cukup untuk menjamin konsistensi data bila dipakai sendirian.
Gunakan lock untuk serialisasi, lalu tetap lindungi data dengan idempotency dan constraint di database. Jika lock hilang, bocor, atau timeout, lapisan data masih menahan kerusakan.
lock_key = "order:ORD-123"
lock_ttl = 300 # detik
if !acquire_lock(lock_key, lock_ttl):
requeue_with_backoff(job)
return
try:
process_order(job)
finally:
release_lock(lock_key)Trade-off:
- Lock mengurangi race condition, tetapi menambah kompleksitas timeout dan recovery.
- TTL yang terlalu pendek bisa membuat lock kedaluwarsa saat job masih berjalan.
- TTL yang terlalu panjang membuat lock bocor lebih menyakitkan.
Tips penting: simpan owner token saat acquire lock, lalu hanya owner yang boleh release lock. Ini mencegah worker lain melepas lock yang bukan miliknya.
4. Backoff dan jitter untuk mencegah retry storm
Retry storm terjadi saat banyak job gagal karena dependency yang sama, lalu semuanya mencoba ulang hampir bersamaan. Hasilnya adalah antrian membengkak, layanan downstream makin tertekan, dan waktu pemulihan menjadi lebih lama.
Gunakan exponential backoff dengan jitter agar retry tersebar, bukan menumpuk pada detik yang sama.
attempt = job.attempt
base_delay_seconds = min(300, 2 ** attempt)
jitter_seconds = random_between(0, 30)
next_delay = base_delay_seconds + jitter_secondsKapan retry aman?
- Error sementara: timeout jaringan, rate limit, service unavailable.
- Bukan error permanen: data invalid, payload rusak, invariant bisnis gagal.
Jangan retry buta untuk semua error. Jika payload salah atau record referensi tidak ada, job seperti itu lebih tepat masuk ke dead-letter queue daripada menghabiskan kapasitas worker.
5. Dead-letter queue untuk memisahkan job gagal yang butuh keputusan manusia
Dead-letter queue atau DLQ berguna untuk menampung job yang gagal melewati batas retry atau gagal karena kondisi yang tidak bisa pulih otomatis. DLQ bukan tempat “membuang error”; ia adalah mekanisme observasi dan triase.
Saat sebuah job masuk DLQ, simpan minimal:
- payload asli
- error terakhir
- jumlah attempt
- timestamp first seen dan last failed
- idempotency key atau correlation ID
Ini penting agar tim bisa menjawab: apakah aman untuk replay? Apakah payload harus diperbaiki? Apakah ada data yang sudah setengah terproses?
6. Dedup untuk antrian yang rawan event ganda
Pada sistem event-driven, duplicate event bisa muncul dari producer, broker, atau replay proses recovery. Dedup dibutuhkan terutama bila producer tidak bisa menjamin exactly-once.
Pendekatan umum:
- dedup berdasarkan
event_idunik - dedup berdasarkan kombinasi entitas + versi
- window-based dedup untuk event dengan horizon waktu tertentu
Jika event punya versi monotonic, worker bisa menolak event lama:
UPDATE customer_projection
SET loyalty_tier = :new_tier,
version = :event_version,
updated_at = NOW()
WHERE customer_id = :customer_id
AND version < :event_version;Teknik ini membantu mengatasi out-of-order event. Event versi 9 tidak boleh menimpa data yang sudah berada di versi 10.
7. Observability: tanpa jejak, runbook hanya tebakan
Queue yang sehat membutuhkan observability yang bisa menghubungkan satu job ke efek akhirnya. Metrik global saja tidak cukup. Anda perlu bisa menelusuri sebuah pesan dari enqueue sampai perubahan data.
Minimal yang sebaiknya ada:
- Correlation ID dari request awal ke producer, queue, worker, dan dependency downstream.
- Idempotency key di log dan event.
- Attempt count, waktu tunggu di queue, dan durasi proses.
- Outcome terstruktur: success, retryable failure, permanent failure, deduplicated, stale event ignored.
- Metrik queue: lag, age of oldest message, inflight count, retry rate, DLQ rate.
Dengan data ini, tim bisa membedakan tiga kondisi yang sering tertukar:
- job lambat diproses
- job diproses dua kali
- job sukses secara teknis tetapi hasil bisnisnya salah
Alur diagnosis langkah demi langkah saat data queue mulai inkonsisten
Bagian ini bisa langsung dipakai sebagai kerangka runbook queue yang sehat saat insiden terjadi.
Langkah 1: Bekukan asumsi, tulis prediksi failure mode yang paling mungkin
Sebelum menyentuh tombol replay atau restart worker, buat prediksi singkat:
- Apakah ini duplikasi karena visibility timeout?
- Apakah lock bocor membuat antrean menumpuk?
- Apakah event lama menimpa event baru?
- Apakah retry storm sedang menekan database atau API?
- Apakah cache tidak ter-invalidasi setelah write berhasil?
Tujuan langkah ini adalah menghindari respons instan yang salah, misalnya melakukan replay massal padahal masalah utamanya justru duplikasi.
Langkah 2: Identifikasi blast radius
Tentukan seberapa luas kerusakan:
- entitas apa yang terdampak: order, invoice, inventory, user profile
- rentang waktu kejadian
- queue atau topic mana yang terlibat
- apakah hanya satu consumer group atau semua worker
Jika perlu, hentikan sementara producer tertentu atau turunkan concurrency consumer untuk mencegah kerusakan meluas.
Langkah 3: Ambil satu contoh konkret, telusuri end-to-end
Pilih satu order atau satu entity yang jelas salah, lalu telusuri:
- kapan request awal masuk
- kapan pesan dipublish
- berapa kali worker menerima job
- apakah lock diambil dan dilepas
- perubahan apa yang terjadi di database
- apakah cache berubah
- event lanjutan apa yang terbit
Satu contoh yang dilacak dengan lengkap sering lebih berguna daripada melihat ribuan log secara acak.
Langkah 4: Bedakan masalah pemrosesan dari masalah observasi
Kadang sistem sebenarnya sudah benar, tetapi dashboard atau cache belum sinkron. Kadang kebalikannya: worker menulis log “success”, tetapi database tidak berubah. Verifikasi langsung ke sumber kebenaran:
- cek row database final
- cek uniqueness constraint atau tabel processed_jobs
- cek versi event terakhir yang diterapkan
- cek apakah cache memuat versi yang sama
Jangan mengandalkan status sukses dari worker sebagai bukti hasil akhir benar.
Langkah 5: Uji hipotesis utama
Beberapa pola uji cepat:
- Duplikasi job: cari idempotency key yang sama dengan attempt berbeda.
- Visibility timeout terlalu pendek: bandingkan durasi proses dengan timeout pesan.
- Lock bocor: cek key lock yang usianya jauh di atas durasi normal job.
- Retry storm: lihat lonjakan attempt, error serupa, dan pola waktu retry yang serentak.
- Out-of-order event: bandingkan versi event yang diterima dengan versi state saat ini.
- Cache stale: cek apakah write ke database sukses tetapi invalidasi cache gagal atau terlambat.
Langkah 6: Tentukan strategi recovery yang aman
Jangan langsung replay semua pesan. Pilih salah satu strategi sesuai failure mode:
- Jika job duplikat: hentikan replay massal, perbaiki idempotency, lalu replay hanya job yang belum menghasilkan efek sah.
- Jika lock bocor: hapus lock secara selektif setelah memastikan owner sudah mati dan tidak ada proses aktif.
- Jika retry storm: aktifkan backoff lebih agresif, throttle consumer, atau buka circuit breaker ke dependency yang sedang gagal.
- Jika out-of-order event: replay berdasarkan urutan versi, atau bangun ulang projection dari event log yang benar.
- Jika cache stale: invalidasi ulang berdasarkan daftar entity terdampak, bukan flush total bila tidak perlu.
Langkah 7: Validasi hasil recovery
Setelah tindakan recovery, pastikan invariant bisnis kembali benar:
- tidak ada transaksi sukses ganda untuk order yang sama
- status akhir di database sesuai urutan bisnis
- cache dan search index sudah sinkron
- DLQ tidak terus bertambah dengan pola error yang sama
- lag queue kembali turun
Recovery belum selesai sampai efek data tervalidasi.
Contoh runbook insiden: order terkirim dua kali dan status cache salah
Gejala
- Pelanggan menerima dua notifikasi pengiriman.
- Status order di API kadang processing, kadang shipped.
- Grafik retry worker naik tajam selama 15 menit terakhir.
Prediksi awal
- Worker memproses job pengiriman dua kali karena timeout ack.
- Idempotency untuk pengiriman tidak ada atau tidak stabil.
- Cache order tidak di-invalidasi setelah update status kedua.
Diagnosis
- Ambil satu
order_idyang terdampak. - Cari semua log dengan correlation ID atau
order_idtersebut. - Temukan dua attempt pada job
ship_orderdengan payload identik. - Bandingkan durasi proses dengan visibility timeout; ternyata durasi proses lebih panjang.
- Cek database: ada dua insert ke tabel shipment karena tidak ada unique constraint pada
order_iduntuk shipment aktif. - Cek cache: key order masih menyimpan snapshot lama karena invalidasi hanya terjadi pada path sukses pertama.
Tindakan recovery
- Turunkan concurrency consumer shipment sementara.
- Naikkan visibility timeout atau pecah job panjang menjadi beberapa langkah yang lebih kecil.
- Tambahkan idempotency key berbasis
order_id + action. - Tambahkan constraint unik untuk shipment aktif per order.
- Jalankan skrip koreksi untuk order terdampak dan invalidasi cache hanya untuk entity terkait.
- Replay job yang benar-benar belum selesai, bukan semua job dalam rentang waktu insiden.
Pelajaran teknis
Masalah utamanya bukan hanya retry. Akar masalahnya adalah sistem tidak memvalidasi invariant “satu order hanya punya satu shipment aktif” dan terlalu percaya bahwa sekali worker menulis log sukses, hasil akhirnya sudah benar.
Implementasi validasi hasil worker
Validasi hasil worker sebaiknya eksplisit, terutama untuk job yang punya dampak bisnis tinggi. Beberapa pendekatan yang umum dipakai:
Transactional write + outbox
Jika job mengubah database dan menerbitkan event, gunakan pola transactional outbox agar perubahan data dan event keluar tetap konsisten secara lokal. Worker menulis perubahan bisnis dan record outbox dalam satu transaksi, lalu publisher terpisah mengirim event dari outbox.
Ini mengurangi risiko situasi seperti:
- database berhasil berubah, tetapi event tidak pernah terkirim
- event terkirim, tetapi transaksi database rollback
Post-condition check
Setelah proses utama selesai, lakukan pemeriksaan sederhana terhadap kondisi akhir yang wajib benar. Misalnya:
- status order benar-benar berubah ke nilai yang diharapkan
- jumlah row yang terpengaruh sesuai ekspektasi
- versi record bertambah jika memang memakai versioning
- cache invalidation atau publish event sukses tercatat
Jika post-condition gagal, worker bisa menandai job sebagai gagal-tervalidasi dan mengirimkannya ke jalur recovery yang lebih aman daripada sekadar retry buta.
Checklist pencegahan yang bisa langsung dipakai
Desain job
- Apakah setiap job penting punya idempotency key yang stabil?
- Apakah side effect eksternal dilindungi dari eksekusi ganda?
- Apakah ada invariant bisnis yang ditulis jelas untuk job ini?
- Apakah job panjang bisa dipecah menjadi tahap yang lebih kecil?
Queue dan retry
- Apakah visibility timeout sesuai durasi proses nyata?
- Apakah retry hanya untuk error yang memang sementara?
- Apakah backoff memakai jitter untuk mencegah retry storm?
- Apakah ada batas maksimal attempt dan jalur ke dead-letter queue?
Lock dan konsistensi
- Apakah distributed lock benar-benar diperlukan, atau cukup dengan constraint database?
- Apakah lock punya TTL yang realistis?
- Apakah hanya owner lock yang bisa melepas lock?
- Apakah ada prosedur aman untuk membersihkan lock bocor?
Event dan data turunan
- Apakah consumer bisa menolak event lama berdasarkan versi?
- Apakah dedup event diterapkan untuk producer yang rawan mengirim ganda?
- Apakah cache invalidation menjadi bagian eksplisit dari alur sukses?
- Apakah projection, search index, atau read model punya mekanisme rebuild?
Observability dan operasi
- Apakah semua job punya correlation ID dan attempt count di log?
- Apakah dashboard menampilkan lag, inflight, retry rate, dan DLQ rate?
- Apakah ada query siap pakai untuk mencari duplikasi berdasarkan idempotency key?
- Apakah runbook insiden menyebut langkah throttle, pause, replay, dan verifikasi data?
Kapan memilih lock, kapan mengandalkan idempotency, kapan perlu keduanya?
Tidak semua masalah queue harus diselesaikan dengan lock.
- Pilih idempotency sebagai default bila risiko utamanya adalah duplikasi efek bisnis.
- Pilih lock bila ada kebutuhan serialisasi kuat pada entitas yang sama, misalnya perhitungan stok yang sensitif terhadap race.
- Gunakan keduanya bila sistem sangat rentan terhadap pemrosesan paralel sekaligus pengiriman ulang pesan.
Namun bahkan saat memakai keduanya, database tetap harus menjadi pagar terakhir melalui constraint, transaksi, atau version check. Jika tidak, satu bug kecil di lapisan queue bisa langsung berubah menjadi kerusakan data.
Penutup
Queue yang sehat tidak dibangun dengan optimisme bahwa worker akan selalu berjalan mulus. Ia dibangun dengan disiplin: prediksi dulu failure mode yang paling mungkin, lalu validasi hasil akhir worker terhadap invariant bisnis. Dari sana, pola seperti idempotency key, visibility timeout, distributed lock, backoff, dead-letter queue, dedup, dan observability menjadi alat yang saling melengkapi, bukan solusi tunggal.
Jika tim Anda ingin mengurangi “jawaban instan” yang salah dalam operasi queue, mulai dari dua kebiasaan sederhana: jangan menebak bahwa retry itu aman, dan jangan menganggap log sukses berarti data sudah benar. Runbook yang baik lahir dari kebiasaan itu.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!