Queue visibility timeout adalah mekanisme yang menyembunyikan sementara sebuah pesan atau job setelah diambil oleh worker, agar tidak langsung diproses ulang oleh worker lain. Nilai ini penting karena menjadi batas waktu antara "job masih sah dikerjakan" dan "job mungkin gagal, jadi boleh dicoba lagi".

Masalah utamanya sederhana tetapi dampaknya besar: timeout terlalu pendek membuat job muncul kembali saat worker pertama belum selesai, sehingga terjadi duplicate processing. Sebaliknya, timeout terlalu panjang membuat job gagal cepat terdeteksi saat worker crash, sehingga antrean tampak macet karena pesan tertahan terlalu lama. Artikel ini membahas cara kerja visibility timeout, relasinya dengan retry, ack, idempotensi, heartbeat, dead-letter queue, observability, serta panduan tuning yang praktis untuk sistem queue berbasis cloud maupun worker ala Redis.

Apa itu visibility timeout dan bagaimana cara kerjanya

Pada banyak sistem queue, alur dasarnya kurang lebih seperti ini:

  1. Producer mengirim job ke queue.
  2. Worker mengambil job dari queue.
  3. Setelah diambil, job menjadi invisible selama durasi visibility timeout.
  4. Jika worker berhasil memproses job, worker mengirim ack atau delete.
  5. Jika worker tidak meng-ack sebelum timeout habis, job dianggap belum selesai dan boleh diantrekan ulang untuk dicoba lagi.

Secara operasional, visibility timeout adalah bentuk lease sementara atas job. Worker tidak benar-benar “memiliki” job secara permanen; ia hanya mendapat hak eksklusif terbatas untuk memprosesnya dalam jangka waktu tertentu.

Inti desainnya: queue mengutamakan recoverability. Jika worker mati di tengah jalan, job tidak hilang; ia akan muncul lagi setelah lease habis.

Di sistem managed queue berbasis cloud, konsep ini biasanya eksplisit. Pada sistem berbasis Redis-like worker, istilah dan implementasinya bisa berbeda, tetapi pola dasarnya tetap sama: job yang sedang diproses dipindah ke struktur processing, lalu akan dikembalikan jika tidak selesai dalam batas waktu tertentu.

Mengapa timeout terlalu pendek menyebabkan job ganda

Skenario: job lambat tetapi normal

Misalkan ada job untuk membuat laporan PDF dan mengunggah hasilnya ke object storage. Biasanya selesai dalam 20 detik, tetapi pada data tertentu bisa memakan waktu 90 detik. Jika visibility timeout disetel 30 detik, maka urutannya bisa menjadi seperti ini:

  1. Worker A mengambil job pada detik 0.
  2. Job masih berjalan pada detik 30.
  3. Visibility timeout habis, job kembali terlihat di queue.
  4. Worker B mengambil job yang sama pada detik 31.
  5. Dua worker memproses satu job yang sama secara paralel.

Akibatnya bergantung pada sifat job:

  • Email bisa terkirim dua kali.
  • Pembayaran bisa tercatat ganda jika integrasi tidak aman.
  • Sinkronisasi data bisa menghasilkan update yang saling menimpa.
  • Ekspor file mungkin menimpa output yang sama atau membuat artefak duplikat.

Masalah ini bukan bug kecil. Di sistem yang menerapkan semantik at-least-once delivery, duplicate delivery adalah perilaku yang memang harus diasumsikan mungkin terjadi. Visibility timeout yang terlalu pendek hanya membuat kondisi itu jauh lebih sering muncul.

Kenapa ack terlambat tidak cukup menyelamatkan

Sering ada asumsi bahwa selama worker akhirnya mengirim ack, semuanya aman. Tidak selalu. Jika worker kedua sudah mulai memproses job yang sama, maka ack dari worker pertama mungkin datang terlambat untuk mencegah efek samping yang sudah terjadi. Karena itu, ack penting tetapi tidak cukup. Anda tetap perlu mendesain handler job agar idempotent.

Mengapa timeout terlalu panjang membuat worker tampak macet

Skenario: worker crash

Bayangkan worker mengambil job lalu prosesnya crash karena kehabisan memori. Jika visibility timeout disetel 1 jam, job tersebut tidak akan bisa dikerjakan ulang sampai lease habis. Dari sudut pandang operator:

  • job tidak selesai,
  • job tidak kembali ke antrean,
  • backlog bertambah,
  • tetapi queue terlihat seolah-olah tidak memproses apa-apa.

Inilah alasan timeout yang terlalu panjang sering membuat tim merasa worker “stuck”, padahal sebenarnya job sedang tertahan dalam status in-flight terlalu lama.

Skenario: network partition singkat

Kasus lain yang sering membingungkan adalah gangguan koneksi sesaat antara worker dan broker queue. Worker mungkin sudah menyelesaikan pekerjaan, tetapi gagal mengirim ack karena koneksi putus beberapa detik. Jika koneksi pulih setelah timeout habis, job bisa diambil ulang. Jika timeout terlalu panjang, job yang benar-benar gagal akibat putus koneksi bisa lama sekali kembali. Jadi ada dua sisi:

  • terlalu pendek meningkatkan duplikasi,
  • terlalu panjang memperlambat pemulihan.

Visibility timeout selalu merupakan kompromi antara kecepatan recovery dan risiko duplicate execution.

Relasi visibility timeout dengan retry, ack, dan idempotensi

Ack menandai selesai, timeout menandai batas lease

Ack berarti worker selesai dan job boleh dihapus dari siklus pemrosesan. Visibility timeout bukan pengganti ack; ia adalah batas waktu jika ack tidak pernah datang.

Kesalahan desain yang umum adalah menganggap timeout sebagai mekanisme sukses-gagal utama. Padahal:

  • sukses ditentukan oleh ack/delete,
  • gagal sementara sering ditangani dengan retry,
  • kehilangan worker dipulihkan lewat habisnya visibility timeout.

Retry harus sinkron dengan timeout

Retry policy yang baik harus mempertimbangkan durasi eksekusi job. Jika timeout 30 detik tetapi job lazimnya butuh 2 menit, maka retry akan terjadi bukan karena error bisnis, melainkan karena lease habis. Ini menciptakan retry palsu.

Prinsip praktisnya:

  • retry dipakai untuk kegagalan nyata: dependency down, rate limit, error transient, timeout API eksternal, dan sejenisnya;
  • visibility timeout dipakai untuk memberi waktu worker menyelesaikan kerja dengan aman.

Idempotensi adalah lapisan perlindungan terakhir

Karena duplicate delivery tetap mungkin terjadi walaupun konfigurasi bagus, handler job sebaiknya idempotent. Artinya, menjalankan job yang sama dua kali tidak menghasilkan efek samping ganda.

Contoh pola idempotensi yang aman:

  • gunakan idempotency key saat memanggil API eksternal, jika didukung;
  • simpan status eksekusi berdasarkan job ID atau business key;
  • gunakan upsert atau operasi yang aman diulang;
  • bedakan antara compute dan commit agar commit hanya terjadi sekali.
// Pseudocode handler idempotent
function processInvoiceJob(job) {
  const key = `invoice:${job.invoiceId}:charged`;

  if (store.exists(key)) {
    return; // sudah pernah sukses
  }

  const result = paymentGateway.charge({
    invoiceId: job.invoiceId,
    amount: job.amount,
    idempotencyKey: job.idempotencyKey,
  });

  db.savePayment(result);
  store.put(key, true);
  queue.ack(job.receiptHandle);
}

Pola di atas tidak menghilangkan kebutuhan timeout yang benar, tetapi mengurangi risiko dampak saat duplicate delivery memang terjadi.

Heartbeat dan lease extension untuk job durasi panjang

Jika Anda punya job yang durasinya sangat bervariasi, memilih satu visibility timeout statis sering tidak ideal. Solusi yang lebih aman adalah heartbeat atau lease extension: worker memperpanjang lease secara berkala selama job masih hidup.

Kapan heartbeat dibutuhkan

  • job batch besar dengan waktu proses sulit diprediksi,
  • transcoding, kompresi, ML inference, atau ekspor data besar,
  • sinkronisasi ke sistem eksternal yang latensinya berubah-ubah.

Cara kerjanya

  1. Worker mengambil job dengan timeout awal yang cukup konservatif.
  2. Selama proses masih sehat, worker mengirim heartbeat atau memperpanjang visibility timeout sebelum habis.
  3. Jika worker mati, heartbeat berhenti dan lease akhirnya habis secara alami.

Pendekatan ini memberi dua keuntungan:

  • job panjang tidak cepat diduplikasi,
  • job yang benar-benar gagal tetap bisa pulih dalam waktu yang wajar.
// Pseudocode lease extension
const lease = 60; // detik
const renewEvery = 20;

const job = queue.receive({ visibilityTimeout: lease });

const timer = setInterval(() => {
  queue.extendVisibility(job.receiptHandle, lease);
}, renewEvery * 1000);

try {
  doLongRunningWork(job.payload);
  queue.ack(job.receiptHandle);
} finally {
  clearInterval(timer);
}

Hal yang perlu diperhatikan: heartbeat hanya aman jika benar-benar merepresentasikan proses yang masih sehat. Jangan memperpanjang lease dari proses yang sebenarnya sudah deadlock, menunggu resource selamanya, atau berhenti membuat progres.

Peran dead-letter queue dalam kegagalan berulang

Dead-letter queue (DLQ) dipakai untuk memindahkan job yang gagal berulang kali agar tidak terus berputar di antrean utama. Visibility timeout dan retry bisa membuat satu job muncul lagi berkali-kali; tanpa DLQ, job bermasalah dapat menghabiskan kapasitas worker dan menunda job sehat.

Gunakan DLQ ketika:

  • jumlah percobaan sudah melewati batas,
  • error bersifat non-transient, misalnya payload rusak atau referensi data hilang permanen,
  • job menimbulkan efek samping berulang yang tidak aman jika terus dicoba.

Yang penting, jangan menjadikan DLQ sebagai tempat pembuangan tanpa observasi. Job yang masuk DLQ harus memiliki metadata cukup untuk diinvestigasi: waktu percobaan, error terakhir, jumlah retry, worker yang memproses, dan business key terkait.

Skenario operasional nyata yang sering terjadi

1. Job lambat karena dependency eksternal

Sebuah job sinkronisasi inventori memanggil API vendor yang kadang lambat. Biasanya selesai 10 detik, tetapi saat vendor mengalami degradasi bisa menjadi 2 menit. Jika timeout Anda 30 detik tanpa heartbeat, job akan sering diproses ganda. Solusinya:

  • naikkan timeout mendekati durasi eksekusi realistis,
  • atau gunakan heartbeat/lease extension,
  • sertakan idempotency key saat update ke sistem eksternal.

2. Worker crash setelah commit, sebelum ack

Ini kasus klasik. Worker berhasil menyimpan perubahan ke database, lalu proses mati sebelum mengirim ack. Queue menganggap job belum selesai dan mengirim ulang. Tanpa idempotensi, efek samping terjadi dua kali. Solusinya:

  • desain operasi commit agar dapat dideteksi sudah pernah berhasil,
  • simpan penanda sukses berdasarkan business key,
  • minimalkan jarak antara commit akhir dan ack.

3. Network partition singkat

Worker sehat, tetapi koneksi ke queue sempat putus. Ack gagal terkirim. Setelah timeout habis, job diproses ulang. Dalam observability, gejalanya bisa tampak seperti lonjakan retry padahal akar masalahnya adalah gangguan jaringan. Solusinya:

  • pantau error jaringan ke broker queue,
  • korelasikan retry spike dengan metrik konektivitas,
  • pastikan handler aman terhadap duplicate execution.

4. Backlog menumpuk dan timeout terlalu besar

Saat beban naik, beberapa worker crash karena kehabisan resource. Karena visibility timeout terlalu panjang, banyak job tertahan dalam status in-flight. Queue depth mungkin tidak turun, tetapi throughput anjlok. Solusinya:

  • kurangi timeout bila job sebenarnya pendek,
  • bedakan queue untuk job cepat dan job lambat,
  • skalakan worker berdasarkan tipe job, bukan hanya total backlog.

Panduan tuning visibility timeout yang praktis

Tidak ada angka universal yang benar. Nilai ideal bergantung pada distribusi durasi job, pola kegagalan, dan kemampuan sistem memperpanjang lease. Namun ada pendekatan yang bisa diikuti.

1. Kelompokkan job berdasarkan karakteristik durasi

Jangan campur job 2 detik dengan job 10 menit dalam satu profil timeout yang sama. Pisahkan queue atau setidaknya pisahkan worker pool untuk:

  • job cepat dan seragam,
  • job menengah dengan variasi moderat,
  • job panjang atau tidak terprediksi.

2. Mulai dari durasi eksekusi p95 atau p99, bukan rata-rata

Rata-rata sering menipu. Jika sebagian besar job 5 detik tetapi ada ekor panjang 90 detik, timeout 10 detik akan buruk. Ambil patokan dari persentil tinggi durasi eksekusi normal, lalu tambahkan ruang untuk jitter jaringan dan overhead ack.

3. Gunakan heartbeat untuk ekor panjang

Jika distribusi durasi sangat lebar, memilih timeout berdasarkan kasus terburuk akan membuat recovery terlalu lambat. Dalam kondisi ini, timeout awal yang moderat plus lease extension biasanya lebih baik daripada timeout statis yang sangat besar.

4. Selaraskan dengan retry policy

Pastikan satu percobaan punya cukup waktu untuk benar-benar gagal atau sukses. Jangan sampai retry dipicu hanya karena lease habis sebelum pekerjaan realistis selesai.

5. Tinjau ulang saat pola beban berubah

Timeout yang cocok saat traffic normal bisa tidak cocok saat backlog besar, dependency lambat, atau payload makin besar. Tuning queue adalah proses berkelanjutan, bukan konfigurasi sekali selesai.

Metrik yang wajib dipantau

Tanpa observability, visibility timeout sulit dituning karena gejalanya mirip satu sama lain. Pantau metrik berikut:

  • Queue depth / backlog: berapa banyak job menunggu.
  • In-flight / processing count: berapa banyak job sedang disembunyikan oleh visibility timeout.
  • Job execution time: idealnya p50, p95, p99 per tipe job.
  • Ack latency: waktu dari receive sampai ack.
  • Retry count: total dan per job type.
  • Redelivery rate: seberapa sering job muncul kembali.
  • DLQ rate: jumlah job masuk dead-letter queue.
  • Worker crash / restart count: indikator kegagalan proses.
  • Heartbeat/lease extension success rate: jika fitur ini digunakan.
  • External dependency latency/error rate: database, API vendor, object storage, broker.

Korelasikan metrik tersebut. Misalnya:

  • retry naik + durasi eksekusi stabil + error jaringan naik → kemungkinan masalah konektivitas, bukan timeout terlalu pendek;
  • redelivery naik + p99 durasi melewati timeout → timeout kemungkinan terlalu pendek;
  • backlog naik + in-flight tinggi + worker restart meningkat → worker crash dan timeout terlalu panjang dapat memperlambat recovery.

Checklist debugging saat terjadi job ganda atau worker terlihat stuck

Jika job diproses ganda

  1. Periksa apakah durasi eksekusi aktual sering melebihi visibility timeout.
  2. Lihat apakah ack gagal atau terlambat dikirim.
  3. Periksa log worker crash setelah efek samping terjadi tetapi sebelum ack.
  4. Audit apakah handler benar-benar idempotent.
  5. Periksa gangguan jaringan singkat antara worker dan queue.
  6. Pastikan tidak ada dua mekanisme retry yang saling menumpuk secara tidak sengaja.

Jika worker tampak macet

  1. Bandingkan backlog dengan jumlah job in-flight.
  2. Cek apakah banyak job tertahan lama dalam status processing.
  3. Periksa apakah timeout terlalu besar untuk job yang sebenarnya singkat.
  4. Lihat crash loop, OOM, atau proses yang menggantung pada dependency eksternal.
  5. Jika memakai heartbeat, pastikan mekanisme perpanjangan lease tidak terus hidup saat handler deadlock.
  6. Periksa apakah queue perlu dipisah antara job cepat dan lambat.

Pola implementasi aman

1. Ack hanya setelah efek samping penting selesai

Jangan meng-ack sebelum operasi inti benar-benar committed. Jika ack dikirim terlalu awal, kegagalan setelah itu bisa menyebabkan kehilangan kerja tanpa peluang retry.

2. Jadikan job idempotent by design

Asumsikan duplicate delivery selalu mungkin. Simpan jejak eksekusi, gunakan business key, dan pilih operasi yang aman diulang.

3. Pisahkan queue berdasarkan profil kerja

Job cepat tidak boleh mewarisi timeout besar hanya karena ada beberapa job lambat. Pemisahan queue membantu tuning, scaling, dan observability.

4. Gunakan heartbeat untuk job panjang, bukan timeout raksasa

Timeout yang sangat besar memperlambat recovery dari crash. Jika durasi kerja tidak pasti, lease extension biasanya lebih sehat.

5. Batasi retry dan arahkan kegagalan berulang ke DLQ

Tanpa batas retry, job rusak bisa memonopoli kapasitas. DLQ membantu isolasi dan investigasi.

6. Simpan konteks diagnostik di setiap percobaan

Log minimal yang berguna: job ID, business key, attempt number, worker ID, receive timestamp, ack timestamp, durasi kerja, error class, dan dependency yang gagal.

Contoh alur yang lebih aman secara end-to-end

// Pseudocode alur worker yang lebih aman
job = queue.receive(timeout=60)
startHeartbeat(job, every=20, extendTo=60)

try:
  if executionStore.alreadySucceeded(job.idempotencyKey):
    queue.ack(job.receiptHandle)
    return

  result = performBusinessOperation(job.payload, idempotencyKey=job.idempotencyKey)
  executionStore.markSucceeded(job.idempotencyKey, result.reference)
  queue.ack(job.receiptHandle)

catch transientError:
  // biarkan retry policy dan visibility timeout bekerja
  logFailure(job, transientError)

catch permanentError:
  sendToDLQ(job, permanentError)
  queue.ack(job.receiptHandle)

finally:
  stopHeartbeat(job)

Poin penting dari alur ini:

  • idempotensi dicek sebelum efek samping dijalankan,
  • heartbeat menjaga lease selama proses sehat,
  • ack dilakukan setelah keberhasilan benar-benar tercatat,
  • error permanen tidak dibiarkan berulang tanpa akhir.

Penutup

Queue visibility timeout bukan sekadar angka konfigurasi. Ia menentukan keseimbangan antara risiko job ganda dan kecepatan pemulihan saat worker gagal. Timeout terlalu pendek memicu redelivery sebelum kerja selesai; timeout terlalu panjang menyembunyikan kegagalan terlalu lama dan membuat sistem tampak macet.

Pendekatan yang paling aman biasanya bukan hanya menaikkan atau menurunkan timeout, melainkan menggabungkan beberapa lapisan: ack yang benar, retry yang terukur, idempotensi, heartbeat/lease extension untuk job panjang, DLQ untuk kegagalan berulang, dan observability yang memadai. Dengan kombinasi ini, antrean tetap tahan terhadap crash, gangguan jaringan, dan variasi durasi kerja tanpa terlalu sering memproses job yang sama dua kali.