Mengatasi queue stuck dan cache stale saat upgrade worker bukan sekadar restart proses lalu deploy versi baru. Di sistem terdistribusi, perubahan runtime, image container, library, atau cara worker memproses sinyal shutdown dapat memunculkan gejala yang tampak acak: job dobel, antrean tidak bergerak, lock tidak dilepas, cache tidak konsisten, atau job lama tiba-tiba diproses ulang.

Konteks ini mirip pelajaran dari laporan progres Asahi Linux 7.1: perubahan platform rendah level dapat memunculkan bug operasional yang sebelumnya tidak terlihat. Walau kasusnya berbeda, prinsipnya sama untuk backend production: perubahan kecil di lapisan bawah dapat mengubah timing, perilaku I/O, penanganan sinyal, atau kompatibilitas dependency, lalu berdampak ke queue worker, cache, dan koordinasi antar node.

Artikel ini fokus pada langkah praktis untuk mencegah queue macet, job dobel, cache stale, dan konflik locking saat upgrade worker atau runtime. Bahasannya sengaja diarahkan ke sistem produksi dengan pola at-least-once delivery, lock terdistribusi, cache invalidation, health check, canary rollout, observability, dan rollback.

Kenapa upgrade worker bisa memicu insiden operasional

Worker queue berada di titik sensitif: ia menerima job dari broker, mengambil lock, memanggil API atau database, lalu menulis hasil dan melepaskan status. Saat worker di-upgrade, ada beberapa hal yang bisa berubah:

  • Penanganan sinyal shutdown: worker lama mungkin menyelesaikan job saat menerima SIGTERM, worker baru mungkin berhenti lebih cepat atau sebaliknya.
  • Perubahan timing: runtime baru bisa mengubah latensi I/O, konsumsi memori, jadwal thread, atau waktu startup.
  • Perubahan serialisasi/deserialisasi: payload job lama belum tentu aman dibaca worker baru jika format berubah tanpa kompatibilitas mundur.
  • Perubahan library cache/lock: cara menghitung TTL, retry, atau refresh lock bisa berbeda.
  • Perubahan health check: orchestrator dapat menganggap worker sehat padahal belum siap memproses job, atau sebaliknya.

Masalah ini makin terlihat pada arsitektur yang menggunakan at-least-once delivery. Pada model ini, broker berhak mengirim ulang job bila ack tidak diterima tepat waktu. Artinya, duplikasi eksekusi bukan bug broker; itu konsekuensi desain yang harus ditangani di sisi worker dan penyimpanan data.

Gejala umum saat upgrade: queue stuck, job dobel, cache stale, lock konflik

1. Queue stuck

Queue terlihat menumpuk tetapi worker aktif. Penyebab umum:

  • Worker lulus health check tetapi gagal mengambil job.
  • Worker mengambil job lalu hang sebelum ack.
  • Visibility timeout terlalu panjang, sehingga job yang gagal tampak "menghilang" lama.
  • Lock global atau lock per-entity tertinggal dan menghalangi pemrosesan baru.

2. Job diproses dobel

Ini sering terjadi ketika:

  • Worker lama sedang memproses job saat menerima sinyal stop.
  • Ack tidak terkirim karena proses mati sesaat setelah side effect terjadi.
  • Visibility timeout habis sebelum job selesai, lalu worker lain mengambil job yang sama.

3. Cache stale

Cache stale muncul ketika worker versi baru dan lama memiliki aturan invalidasi yang berbeda, namespace key berubah tanpa migrasi, atau event invalidasi gagal diterbitkan saat deploy. Hasilnya, data database sudah benar tetapi response API masih memuat nilai lama.

4. Konflik locking

Distributed lock berguna untuk mencegah dua worker memproses entitas yang sama, tetapi lock yang salah desain justru memicu deadlock ringan atau throughput turun drastis. Masalah umum:

  • TTL lock terlalu panjang, sehingga lock yatim menghalangi sistem.
  • Worker melepaskan lock yang sebenarnya dimiliki proses lain.
  • Lock diambil terlalu dini dan ditahan saat operasi jaringan lambat.

Arsitektur contoh yang lebih tahan saat upgrade

Berikut pola arsitektur sederhana yang umum dipakai:

Producer/API ---> Queue Broker ---> Worker Pool ---> Database
                      |                 |              |
                      |                 |              +-- Outbox / audit table
                      |                 +-- Distributed lock store
                      |
                      +-- Dead Letter Queue

API / Read path ---> Cache
                 ^
                 |
        Invalidation event / cache versioning

Prinsip penting dari arsitektur ini:

  • Queue broker bertanggung jawab pada delivery, bukan idempotensi bisnis.
  • Worker harus aman terhadap retry dan duplikasi.
  • Database menjadi sumber kebenaran untuk status akhir.
  • Cache diperlakukan sebagai turunan dari state utama, bukan sumber kebenaran.
  • DLQ menyimpan job yang gagal berulang agar tidak meracuni antrean utama.

Prinsip inti untuk mencegah queue macet saat upgrade worker

Graceful shutdown wajib ada

Saat orchestrator atau supervisor menghentikan worker, proses harus berhenti menerima job baru, tetapi tetap diberi kesempatan menyelesaikan job aktif sampai batas waktu aman. Ini mencegah setengah transaksi atau side effect yang sudah terjadi tetapi ack belum sempat dikirim.

Alurnya idealnya seperti ini:

  1. Terima sinyal shutdown.
  2. Tandai worker sebagai draining.
  3. Health check/readiness berubah menjadi tidak siap untuk job baru.
  4. Selesaikan job aktif atau hentikan secara aman jika desain job mendukung checkpoint.
  5. Kirim ack bila sukses, atau biarkan job kembali terlihat bila belum aman.
  6. Lepaskan lock dan resource lalu exit.

Hal yang perlu diperhatikan:

  • Termination grace period harus lebih panjang dari durasi job normal atau checkpoint interval.
  • Jangan menerima job baru setelah sinyal stop.
  • Pastikan driver queue, library async, dan framework benar-benar menangani sinyal proses.

Visibility timeout harus lebih besar dari durasi pemrosesan realistis

Visibility timeout menentukan berapa lama job tidak terlihat setelah diambil worker. Jika terlalu pendek, job yang masih berjalan akan diambil ulang worker lain. Jika terlalu panjang, job gagal akan lama kembali ke antrean sehingga tampak seperti queue stuck.

Panduan praktis:

  • Set timeout di atas durasi p95 atau p99 job, bukan rata-rata.
  • Jika job sangat lama, pertimbangkan heartbeat atau perpanjangan visibility selama proses berjalan.
  • Bedakan timeout antar jenis job; jangan samakan job 2 detik dan 20 menit.

Kesalahan umum adalah menganggap timeout queue sama dengan timeout HTTP atau timeout database. Masing-masing melindungi lapisan berbeda dan harus diatur selaras.

Idempotensi lebih penting daripada berharap job hanya sekali jalan

Pada sistem at-least-once delivery, asumsikan job bisa dijalankan lebih dari sekali. Karena itu, side effect harus aman terhadap retry. Contohnya:

  • Gunakan idempotency key untuk pembayaran, pengiriman email penting, atau mutasi status.
  • Simpan status job berdasarkan business key, bukan hanya ID pesan broker.
  • Gunakan unique constraint atau tabel deduplikasi untuk mencegah insert ganda.

Idempotensi bekerja karena sistem tidak lagi bergantung pada “sekali proses pasti berhasil”. Sebaliknya, setiap eksekusi memeriksa apakah efek bisnis yang diinginkan sudah pernah diterapkan.

Contoh pseudocode worker yang aman

Pseudocode berikut menunjukkan pola dasar worker yang memperhatikan drain mode, lock, idempotensi, dan ack:

global draining = false

onSignalTerminate():
  draining = true
  stopReceivingNewJobs()

while not processExiting:
  if draining:
    if noActiveJob():
      exitGracefully()
    sleep(short_interval)
    continue

  job = queue.receive(visibility_timeout)
  if job is null:
    continue

  activeJob = job
  lockToken = null

  try:
    if isAlreadyProcessed(job.idempotencyKey):
      queue.ack(job)
      activeJob = null
      continue

    lockToken = lock.acquire("resource:" + job.resourceId, ttl=lock_ttl)
    if lockToken is null:
      queue.retryLater(job)
      activeJob = null
      continue

    beginTransaction()

    if isAlreadyProcessed(job.idempotencyKey):
      commitTransaction()
      queue.ack(job)
      activeJob = null
      continue

    result = processBusinessLogic(job)
    recordProcessed(job.idempotencyKey, result)
    updatePrimaryState(result)
    commitTransaction()

    publishInvalidationEvent(job.resourceId, result.version)
    queue.ack(job)

  catch transientError:
    rollbackIfNeeded()
    queue.retryLater(job)

  catch fatalError:
    rollbackIfNeeded()
    queue.moveToDLQ(job)

  finally:
    if lockToken is not null:
      lock.releaseIfOwner("resource:" + job.resourceId, lockToken)
    activeJob = null

Catatan penting dari pola ini:

  • Ack dilakukan setelah side effect utama berhasil dicatat, bukan di awal.
  • Lock dilepas dengan token kepemilikan, agar proses tidak melepas lock milik worker lain.
  • Pengecekan idempotensi dilakukan lebih dari sekali bila perlu, terutama sebelum commit.
  • Error transient dan fatal dipisahkan agar retry tidak membanjiri antrean.

Mengelola distributed lock tanpa menciptakan bottleneck

Kapan lock diperlukan

Lock diperlukan bila dua job yang menyentuh entitas sama dapat merusak konsistensi, misalnya dua proses pembaruan saldo, dua sinkronisasi akun, atau dua regenerasi cache untuk key yang sama.

Praktik aman

  • Lock sedetail mungkin, misalnya per resource ID, bukan satu lock global.
  • Gunakan TTL agar lock yatim dapat pulih otomatis.
  • Simpan token kepemilikan lock dan validasi saat release.
  • Jangan menahan lock saat operasi yang tidak perlu, terutama panggilan jaringan panjang jika bisa dipecah.

Trade-off lock vs serial queue

Jika ordering per entitas sangat penting, terkadang lebih baik menggunakan partisi queue berdasarkan kunci tertentu daripada lock di banyak worker. Keuntungannya adalah konflik lebih sedikit dan debugging lebih mudah. Kekurangannya, throughput bisa turun bila satu partisi memiliki beban berat.

Mencegah cache stale saat upgrade

Gunakan cache-aside dengan invalidasi yang jelas

Pola cache-aside umum dipakai: baca dari cache, jika miss ambil dari database, lalu simpan ke cache. Saat write terjadi, cache untuk entitas terkait harus dihapus atau diberi versi baru. Masalah saat upgrade muncul bila worker lama dan baru punya aturan invalidasi berbeda.

Untuk mengurangi risiko:

  • Versioned cache key: contoh `user:{id}:v{schemaVersion}` agar perubahan format tidak berbenturan dengan data lama.
  • Event invalidation: publish event setelah commit berhasil, bukan sebelum transaksi selesai.
  • Fallback toleran: pembaca cache harus mampu menangani miss atau payload lama tanpa crash.

Hindari menaruh state kebenaran di cache

Jika sistem mengandalkan cache sebagai satu-satunya tempat menyimpan status progres job, restart worker atau rotasi node dapat membuat state hilang atau basi. Simpan status penting di database atau store yang memang dirancang untuk durabilitas.

Waspadai cache stampede setelah deploy

Saat namespace cache berubah, banyak key akan miss serentak. Jika tidak dikendalikan, database bisa melonjak bebannya. Mitigasinya:

  • TTL dengan jitter agar expiry tidak serempak.
  • Single-flight per key atau lock pendek saat regenerasi.
  • Warm-up key penting selama canary, bukan setelah full rollout.

At-least-once delivery, ordering, dan DLQ

Jangan mengasumsikan urutan global

Banyak sistem queue tidak menjamin urutan global di seluruh worker. Bahkan jika broker menyediakan ordering di level tertentu, retry dan re-delivery dapat mengubah urutan efektif. Karena itu:

  • Jika urutan penting, nyatakan eksplisit: per user, per account, per aggregate, atau per stream.
  • Gunakan partisi berdasarkan kunci domain bila perlu.
  • Simpan versi atau sequence number di database untuk menolak update usang.

DLQ bukan tempat sampah permanen

Dead Letter Queue berguna untuk mengisolasi job gagal berulang, tetapi harus punya proses operasional:

  • Klasifikasikan error: data rusak, dependency down, bug kode, atau timeout.
  • Tentukan apakah job aman untuk diputar ulang.
  • Berikan tool replay yang memeriksa idempotensi dan versi payload.

Tanpa proses ini, DLQ hanya menyembunyikan masalah sampai antreannya menumpuk.

Health check dan readiness saat rollout

Health check worker tidak boleh sekadar memeriksa proses masih hidup. Untuk upgrade yang aman, bedakan:

  • Liveness: proses tidak deadlock atau crash.
  • Readiness: worker siap menerima job baru, dependency utama dapat dijangkau, migrasi yang diperlukan sudah selesai, dan node tidak sedang draining.

Readiness yang baik membantu orchestrator mengurangi risiko worker setengah siap. Misalnya, worker yang baru hidup tetapi belum terkoneksi ke lock store atau belum memuat konfigurasi terbaru seharusnya belum dinyatakan siap.

Canary rollout dan rollback yang realistis

Canary rollout

Jangan upgrade semua worker sekaligus, terutama jika ada perubahan runtime atau library dasar. Mulailah dari sebagian kecil worker:

  1. Deploy 1 atau beberapa worker canary.
  2. Batasi jenis queue atau persentase trafik yang masuk ke canary.
  3. Pantau metrik error, latency, retry, duplicate rate, lock contention, dan cache miss.
  4. Naikkan bertahap hanya jika indikator tetap normal.

Canary efektif karena bug operasional sering muncul dari interaksi nyata di production, bukan dari test unit saja.

Rollback

Rollback harus dipikirkan sebelum deploy, bukan saat insiden sudah terjadi. Pastikan:

  • Payload job kompatibel dengan worker lama selama jendela rollout.
  • Perubahan schema bersifat kompatibel maju dan mundur bila memungkinkan.
  • Cache key baru tidak membuat rollback mustahil membaca data penting.
  • Worker lama dan baru dapat hidup berdampingan sementara.

Rollback kode tanpa rollback asumsi operasional sering gagal. Contohnya, kode lama hidup lagi tetapi job di queue sudah berformat baru yang tidak dapat dibaca.

Observability: metrik dan log yang wajib ada

Tanpa observability, queue stuck dan cache stale sering tampak seperti gejala acak. Minimal, kumpulkan:

Metrik queue

  • Panjang antrean per queue.
  • Usia job tertua.
  • Receive rate, ack rate, retry rate, DLQ rate.
  • Durasi pemrosesan per tipe job.
  • Jumlah job in-flight.

Metrik lock

  • Tingkat keberhasilan acquire lock.
  • Waktu tunggu lock.
  • Jumlah timeout lock.
  • Jumlah release gagal karena token tidak cocok.

Metrik cache

  • Hit/miss ratio.
  • Latency baca/tulis cache.
  • Jumlah invalidation event.
  • Mismatch rate antara cache dan database pada sampling tertentu.

Tracing dan logging

  • Trace ID yang menghubungkan API request, enqueue, worker processing, DB write, dan invalidation event.
  • Log terstruktur berisi job ID, idempotency key, lock key, attempt number, worker version, dan hasil akhir.

Data ini penting saat membedakan apakah masalah berasal dari broker, worker, cache, lock, atau dependency lain.

Checklist investigasi insiden

Saat terjadi gangguan setelah upgrade, gunakan urutan investigasi yang konsisten:

  1. Apakah backlog naik? Lihat panjang queue dan usia job tertua.
  2. Apakah worker benar-benar memproses? Bandingkan receive rate vs ack rate.
  3. Apakah visibility timeout habis sebelum job selesai? Cari pola re-delivery dan duplicate processing.
  4. Apakah worker gagal shutdown dengan benar? Cek log sinyal terminate, drain mode, dan durasi exit.
  5. Apakah ada lock yatim atau contention tinggi? Lihat metrik acquire timeout dan TTL lock.
  6. Apakah cache invalidation berjalan? Cocokkan commit sukses dengan event invalidasi.
  7. Apakah payload job kompatibel lintas versi? Cari error deserialisasi atau field yang hilang.
  8. Apakah dependency berubah perilaku? Misalnya DNS, TLS, filesystem, thread scheduling, atau timeout runtime baru.
  9. Apakah hanya worker versi baru yang gagal? Bandingkan metrik per versi untuk mempercepat keputusan rollback.
  10. Apakah DLQ meningkat? Ambil sampel error untuk menentukan bug kode vs data buruk.

Anti-pattern yang sering menyebabkan queue stuck dan cache stale

  • Ack sebelum commit. Jika proses mati sesudah ack tapi sebelum state tersimpan, data hilang tanpa retry.
  • Mengandalkan exactly-once tanpa bukti desain. Kebanyakan sistem praktis beroperasi dengan at-least-once.
  • Lock global untuk semua job. Aman di awal, menghancurkan throughput saat beban naik.
  • TTL lock sangat panjang tanpa ownership token. Lock yatim sulit dibedakan dari lock aktif.
  • Cache invalidation sebelum transaksi commit. Pembaca bisa mengambil data lama lagi dan menulis ulang cache stale.
  • Deploy semua worker sekaligus. Sulit mengisolasi regresi dan rollback jadi lebih berisiko.
  • Mengubah format payload job tanpa kompatibilitas. Worker campuran versi akan gagal acak.
  • Health check terlalu dangkal. Proses hidup dianggap sehat walau tidak bisa bekerja.

Rekomendasi implementasi yang paling praktis

Jika Anda ingin langkah yang paling berdampak tanpa merombak arsitektur penuh, prioritaskan urutan berikut:

  1. Terapkan graceful shutdown dan mode draining yang benar.
  2. Pastikan semua job penting idempotent.
  3. Tinjau visibility timeout berdasarkan durasi nyata, bukan asumsi.
  4. Perbaiki distributed lock dengan TTL dan owner token.
  5. Gunakan versioned cache key atau invalidasi yang kompatibel lintas versi.
  6. Aktifkan DLQ dengan prosedur replay yang jelas.
  7. Lakukan canary rollout dan simpan jalur rollback yang mudah dieksekusi.
  8. Lengkapi observability per worker version agar regresi cepat terlihat.

Penutup

Upgrade worker atau runtime sering dianggap perubahan rutin, padahal efek sampingnya bisa muncul di tempat yang tidak terduga: queue stuck, job dobel, cache stale, atau konflik lock. Pelajaran umumnya sama dengan perubahan platform rendah level di dunia sistem: perubahan kecil dapat menggeser perilaku operasional secara nyata.

Untuk production backend, pertahanan terbaik bukan berharap queue berjalan sekali tanpa gangguan, melainkan mendesain worker yang tahan terhadap retry, shutdown, duplikasi, dan ketidakteraturan urutan. Dengan graceful shutdown, visibility timeout yang tepat, idempotensi, lock yang aman, invalidasi cache yang disiplin, observability yang cukup, serta canary dan rollback yang realistis, upgrade bisa dilakukan jauh lebih aman.