Hindari abstraksi berlebih saat mendesain queue dan worker bila masalah utama Anda bersifat operasional, bukan teoritis. Dalam sistem produksi, kegagalan biasanya tidak muncul karena model kurang umum, tetapi karena detail seperti visibility timeout terlalu pendek, idempotency key tidak konsisten, invalidasi cache tidak jelas, atau lock tidak punya masa berlaku yang aman.

Ada godaan untuk membangun satu lapisan abstraksi yang mengaku bisa menangani semua hal: queue, retry, dedup, locking, ordering, consistency, cache invalidation, bahkan orkestrasi workflow. Masalahnya, semakin umum modelnya, semakin mudah kita kehilangan sifat penting dari tiap masalah. Hasil akhirnya bukan sistem yang fleksibel, melainkan sistem yang sulit diaudit, sulit di-debug, dan rapuh saat insiden nyata terjadi.

Analogi yang berguna: kadang abstraksi yang sangat umum terlihat elegan di atas kertas, tetapi untuk pekerjaan operasional sehari-hari kita justru butuh model yang lebih sempit, eksplisit, dan bisa diperiksa langkah demi langkah. Untuk queue dan worker, itu berarti lebih baik mendesain mekanisme yang langsung menjawab gejala nyata: job hilang, duplikasi eksekusi, cache stale, lock macet, retry storm, dan ordering yang salah.

Mengapa abstraksi yang terlalu umum sering gagal di sistem queue

Abstraksi generik biasanya menyamakan banyak kegagalan menjadi satu konsep seperti “task processing state”. Ini nyaman di level API, tetapi berbahaya di operasi. Job yang diproses dua kali, job yang hilang, dan job yang terlambat diproses bukan masalah yang sama. Penyebab, metrik, mitigasi, dan konsekuensinya berbeda.

Contoh gejala yang sering tersamarkan oleh desain terlalu umum:

  • Job hilang: produser menganggap job terkirim, tetapi penyimpanan antrian gagal atau commit transaksi belum terjadi.
  • Duplikasi eksekusi: worker timeout, job dianggap gagal, lalu diambil worker lain padahal proses awal masih berjalan.
  • Cache stale: update data berhasil, tetapi invalidasi cache gagal atau tertunda.
  • Lock macet: lock dibuat tanpa expiry yang aman atau tanpa mekanisme fencing/ownership check.
  • Retry storm: kegagalan downstream memicu ribuan retry serentak sehingga sistem makin jatuh.
  • Ordering salah: dua event untuk entitas yang sama diproses tidak berurutan.

Jika semua ini dipaksa masuk ke abstraksi tunggal seperti “reliable background execution”, detail penting hilang. Yang Anda butuhkan bukan abstraksi paling luas, melainkan kontrak perilaku yang jelas per masalah.

Prinsip desain: pilih model eksplisit yang bisa diaudit

Desain eksplisit berarti setiap mekanisme punya tujuan sempit dan aturan yang mudah diverifikasi. Bukan berarti menulis kode berulang tanpa pola, tetapi menahan diri agar tidak menggabungkan masalah berbeda ke dalam satu lapisan “pintar”.

1. Bedakan delivery, execution, dan effect

Queue biasanya hanya menjamin delivery tertentu, bukan “efek bisnis tepat sekali”. Bahkan bila sebuah job di-deliver sekali, operasi bisnis di dalamnya bisa tetap terduplikasi karena timeout, retry, atau crash di titik yang salah.

Pisahkan tiga hal berikut:

  • Delivery: apakah job masuk dan bisa diambil worker.
  • Execution: apakah worker menjalankan handler.
  • Effect: apakah perubahan bisnis benar-benar terjadi satu kali sesuai niat.

Kebanyakan sistem yang sehat menerima bahwa delivery atau execution bisa berulang, lalu memastikan effect bersifat idempoten.

2. Gunakan state yang sempit dan bermakna

Daripada satu state machine besar untuk semua job, sering kali lebih aman memakai state yang sederhana dan spesifik. Misalnya untuk email invoice:

  • pending
  • processing
  • sent
  • failed_permanent

Ini lebih mudah diaudit daripada state generik seperti running, paused, resumable, compensated, yang terdengar fleksibel tetapi tidak membantu investigasi insiden harian.

3. Simpan keputusan penting sebagai data, bukan asumsi tersembunyi

Jika sistem mengandalkan dedup, simpan kunci dedup. Jika sistem mengandalkan ordering per entitas, simpan partisi atau kunci ordering. Jika sistem mengandalkan timeout, simpan deadline atau waktu lease. Semakin sedikit aturan tersembunyi di framework internal, semakin mudah operator memahami kondisi sistem.

Desain generik vs desain eksplisit untuk masalah inti

Idempotency key

Desain generik: semua job punya opsi “safe retry”, dan framework internal mencoba mendeteksi duplikasi berdasarkan payload penuh atau hash acak.

Masalahnya: payload bisa berubah urutan field-nya, memiliki timestamp, atau memuat metadata yang tidak relevan. Hash payload juga sering tidak mewakili niat bisnis.

Desain eksplisit: tentukan idempotency key berdasarkan operasi bisnis. Misalnya:

  • payment:{payment_id}:capture
  • invoice:{invoice_id}:send_email
  • user:{user_id}:rebuild_profile

Kunci ini harus stabil lintas retry dan cukup sempit agar tidak menahan operasi berbeda yang sah.

-- Tabel efek idempoten sederhana
CREATE TABLE processed_operations (
  operation_key VARCHAR(255) PRIMARY KEY,
  processed_at TIMESTAMP NOT NULL,
  result_ref VARCHAR(255) NULL
);

-- Pseudocode worker
BEGIN;

IF EXISTS (SELECT 1 FROM processed_operations WHERE operation_key = :key) THEN
  COMMIT;
  RETURN; -- efek sudah pernah terjadi
END IF;

-- lakukan efek bisnis, mis. update status pembayaran
UPDATE payments
SET status = 'captured'
WHERE id = :payment_id AND status = 'authorized';

INSERT INTO processed_operations(operation_key, processed_at, result_ref)
VALUES (:key, NOW(), :payment_id);

COMMIT;

Mengapa ini bekerja: duplikasi delivery tidak otomatis menjadi duplikasi efek. Worker boleh dipanggil dua kali, tetapi operasi bisnis tetap satu kali.

Trade-off: perlu storage tambahan dan definisi key yang benar. Jika key terlalu lebar, operasi sah bisa tertolak. Jika terlalu sempit, duplikasi lolos.

Visibility timeout

Desain generik: satu timeout global untuk semua job.

Masalahnya: job yang memanggil API lambat, job CPU-bound, dan job I/O pendek punya karakteristik berbeda. Timeout global memicu dua jenis kegagalan: terlalu pendek menyebabkan duplikasi, terlalu panjang membuat recovery lambat.

Desain eksplisit: klasifikasikan job berdasarkan durasi dan profil kegagalan. Tetapkan timeout per jenis job, lalu cocokkan dengan:

  • waktu proses normal
  • waktu maksimum yang masih dapat diterima
  • strategi perpanjangan lease bila job memang panjang

Gunakan aturan praktis: timeout harus lebih panjang dari durasi normal plus buffer realistis, tetapi tidak terlalu panjang hingga job macet tak terdeteksi lama.

Debugging tip: bila Anda melihat lonjakan eksekusi ganda tanpa error aplikasi yang jelas, periksa apakah worker melewati visibility timeout saat downstream melambat.

Dedup

Desain generik: semua pesan serupa dalam jendela waktu tertentu dianggap duplikat.

Masalahnya: dua event yang mirip belum tentu duplikat. Misalnya dua perubahan stok untuk produk yang sama pada menit yang sama bisa valid.

Desain eksplisit: dedup hanya dipakai saat sumber duplikasi memang diketahui, misalnya event upstream bisa dikirim ulang dengan event_id yang sama. Simpan event_id atau message_id sebagai bukti pemrosesan.

Dedup bukan pengganti idempotensi. Dedup membantu menahan replay identik; idempotensi melindungi efek bisnis walau replay tetap lolos.

Distributed lock

Desain generik: semua masalah konkurensi diselesaikan dengan satu utilitas lock terdistribusi.

Masalahnya: tidak semua konflik butuh lock, dan lock terdistribusi mudah salah. Kesalahan umum:

  • tidak ada expiry
  • expiry terlalu pendek
  • worker melepas lock yang bukan miliknya
  • mengandalkan lock untuk menjamin ordering global

Desain eksplisit: pilih mekanisme sesuai masalah:

  • Idempotent update bila operasi aman dijalankan berulang.
  • Unique constraint database bila konflik bisa dinyatakan sebagai aturan data.
  • Lease lock dengan ownership token bila benar-benar perlu eksklusivitas sementara.
  • Partitioning per entitas bila masalah utamanya ordering, bukan mutual exclusion umum.
// Pseudocode lease lock yang minimal aman
lock_key = "rebuild:user:123"
owner_token = randomUUID()
acquired = SET lock_key owner_token NX PX 30000

if (!acquired) return

try {
  doWork()
} finally {
  // hapus hanya jika masih milik sendiri
  if (GET lock_key == owner_token) {
    DEL lock_key
  }
}

Keterbatasan: lock dengan lease tetap tidak otomatis aman bila pekerjaan melampaui lease dan ada worker lain yang masuk. Untuk operasi kritis, pertimbangkan desain yang tidak bergantung pada lock tunggal sebagai satu-satunya pengaman.

Outbox

Desain generik: service menulis data lalu langsung publish event ke broker dalam abstraksi “unit of work” buatan sendiri.

Masalahnya: bila update database berhasil tetapi publish gagal, state lokal dan event keluar tidak sinkron. Abstraksi umum sering menyembunyikan titik gagal ini.

Desain eksplisit: pakai pola transactional outbox. Tulis perubahan domain dan catatan outbox dalam transaksi yang sama. Publisher terpisah membaca outbox dan mengirim ke broker.

BEGIN;

UPDATE orders
SET status = 'paid'
WHERE id = :order_id;

INSERT INTO outbox_events(event_id, topic, payload, created_at)
VALUES (:event_id, 'order.paid', :payload_json, NOW());

COMMIT;

Mengapa ini bekerja: Anda tidak lagi mengandalkan dua sistem berbeda sukses di saat yang sama tanpa koordinasi. Event mungkin terlambat terbit, tetapi tidak hilang diam-diam.

Trade-off: perlu worker publisher, tabel outbox, mekanisme retry, dan pembersihan data lama.

Cache invalidation

Desain generik: semua cache dibungkus helper “smart cache” yang otomatis menebak kapan harus invalidasi.

Masalahnya: invalidasi cache gagal justru karena aturan dependensi tidak eksplisit. Sistem akhirnya punya cache stale yang sulit dilacak.

Desain eksplisit: pilih satu dari beberapa model yang jelas:

  • Cache-aside dengan invalidasi eksplisit setelah write berhasil.
  • Write-through bila alur write sederhana dan sinkron.
  • Versioned key bila invalidasi banyak fan-out atau race sering terjadi.

Untuk data yang sering berubah dan sensitif terhadap stale read, versioned key sering lebih mudah diaudit daripada invalidasi wildcard yang tersembunyi.

// Contoh key berbasis versi
user_profile:{user_id}:v{profile_version}

Ketika profil berubah, naikkan profile_version. Reader selalu memakai versi terbaru dari sumber metadata yang tepercaya.

Gejala nyata dan desain yang biasanya lebih tepat

1. Job hilang

Gejala: aplikasi mencatat “job dispatched”, tetapi tidak pernah diproses.

Penyebab umum:

  • publish ke broker gagal setelah transaksi bisnis commit
  • dispatch dilakukan sebelum transaksi database selesai lalu rollback
  • retensi/log queue terlalu pendek

Desain yang lebih tepat: gunakan outbox untuk event penting, atau pastikan enqueue dilakukan setelah commit yang relevan.

2. Duplikasi eksekusi

Gejala: email terkirim dua kali, pembayaran diproses ganda, stok berkurang dua kali.

Penyebab umum:

  • worker crash setelah efek bisnis sukses tetapi sebelum ack
  • visibility timeout habis saat job masih berjalan
  • retry tanpa idempotensi

Desain yang lebih tepat: fokus pada idempotency key dan penyimpanan efek yang telah diproses, bukan berharap queue memberi jaminan “exactly once” di level bisnis.

3. Cache stale

Gejala: data database sudah benar, tetapi API masih mengembalikan data lama.

Penyebab umum:

  • invalidasi cache dilakukan asinkron dan gagal
  • beberapa key turunan tidak ikut terhapus
  • TTL panjang dijadikan pengganti consistency model

Desain yang lebih tepat: eksplisitkan ownership data cache, aturan invalidasi, dan kapan stale masih bisa diterima.

4. Lock macet

Gejala: satu jenis job berhenti lama karena semua worker menunggu lock yang tidak pernah lepas.

Penyebab umum:

  • proses mati tanpa cleanup
  • lease terlalu panjang
  • tidak ada ownership token

Desain yang lebih tepat: pakai lease dengan expiry, ownership token, dan dashboard untuk lock yang terlalu lama hidup.

5. Retry storm

Gejala: kegagalan satu dependency menyebabkan antrean melonjak dan service lain ikut tumbang.

Penyebab umum:

  • retry serentak tanpa jitter
  • retry tak dibatasi untuk error yang sebenarnya permanen
  • worker concurrency tidak diturunkan saat downstream tidak sehat

Desain yang lebih tepat: pisahkan retryable vs non-retryable, pakai backoff dengan jitter, batasi concurrency, dan sediakan dead-letter queue untuk inspeksi.

6. Ordering yang salah

Gejala: event “dibatalkan” diproses sebelum event “dibuat”, atau saldo akhir kalah oleh update lama.

Penyebab umum:

  • beberapa worker memproses event entitas yang sama secara paralel
  • replay dan retry mengubah urutan observasi
  • tidak ada sequence/version check

Desain yang lebih tepat: jangan mengejar ordering global. Biasanya yang dibutuhkan hanya ordering per entitas. Gunakan partisi berdasarkan entity_id, atau terapkan cek versi saat menulis.

Contoh alur insiden: abstraksi generik vs desain eksplisit

Insiden

Sebuah service e-commerce memproses job ShipOrder. Arsitektur awal memakai library internal yang menawarkan:

  • dedup otomatis berdasarkan hash payload
  • retry otomatis hingga 10 kali
  • lock generik per nama job
  • cache helper untuk status order

Suatu hari, gateway gudang melambat. Worker memanggil API gudang, butuh 45 detik, tetapi visibility timeout 30 detik. Job diambil worker lain. Keduanya lolos karena hash payload sedikit berbeda akibat metadata timestamp. Lock generik hanya berlaku per tipe job, bukan per order, sehingga tidak mencegah konflik yang relevan. Satu worker berhasil menandai order terkirim, worker kedua gagal di tengah jalan lalu memicu retry. Cache status order tidak ter-invalidasi konsisten. Pelanggan melihat status berbeda-beda.

Apa yang salah

  • Dedup didasarkan pada bentuk payload, bukan identitas operasi bisnis.
  • Visibility timeout tidak cocok dengan waktu proses riil.
  • Lock terlalu umum dan tidak merepresentasikan resource yang diperebutkan.
  • Retry diterapkan tanpa klasifikasi error.
  • Invalidasi cache tidak menjadi bagian eksplisit dari desain consistency.

Desain ulang yang lebih sederhana

  • Idempotency key: ship_order:{order_id}.
  • Partition/lock: serialisasi hanya per order_id, bukan per tipe job global.
  • Visibility timeout: diperpanjang sesuai profil job, atau pakai lease extension bila aman.
  • Retry policy: error timeout upstream boleh retry dengan backoff + jitter; error validasi tidak boleh retry.
  • Cache: status order memakai versioned key atau invalidasi eksplisit setelah commit status.
  • Audit trail: simpan attempt, durasi, outcome, dan external request id.

Desain ini tidak terdengar seanggun framework serba bisa, tetapi saat insiden terjadi operator bisa menjawab pertanyaan penting: order mana yang terpengaruh, apakah efek bisnis sudah terjadi, dan kenapa job diulang.

Checklist desain queue dan worker yang praktis

Sebelum implementasi

  • Apa efek bisnis yang harus tepat satu kali, dan apa yang boleh berulang?
  • Apakah job ini butuh idempotency key? Jika ya, apa kunci bisnisnya?
  • Apakah ordering dibutuhkan global, per tenant, atau hanya per entitas?
  • Apakah lock benar-benar diperlukan, atau unique constraint/idempotent write sudah cukup?
  • Berapa durasi normal dan maksimum job ini?
  • Error mana yang retryable, mana yang permanen?
  • Jika publish event dan update DB harus konsisten, apakah outbox diperlukan?
  • Apakah data hasil job masuk cache? Bagaimana invalidasinya?

Saat implementasi

  • Simpan attempt count, first seen, last attempt, dan alasan gagal.
  • Pastikan ack/commit dilakukan di titik yang sesuai dengan model failure.
  • Gunakan timeout yang eksplisit untuk panggilan ke dependency eksternal.
  • Tambahkan jitter pada retry untuk mencegah ledakan serentak.
  • Pisahkan dead-letter queue dari antrean utama.
  • Jangan menyamakan “job berhasil dijalankan” dengan “efek bisnis berhasil dan final”.

Saat operasional

  • Pantau backlog, umur pesan tertua, laju retry, dan rasio sukses per jenis job.
  • Amati distribusi durasi, bukan hanya rata-rata.
  • Audit lock yang hidup terlalu lama.
  • Periksa jumlah dedup hit dan idempotency conflict.
  • Lacak perbedaan antara event yang dibuat, dikirim, dan dikonsumsi.

Metrik observability yang benar-benar berguna

Jika Anda ingin menghindari abstraksi berlebih, maka observability juga harus mengikuti batas masalah yang nyata. Jangan cukup puas dengan satu metrik “queue health”.

Metrik inti

  • Queue depth: jumlah job menunggu per jenis antrean.
  • Oldest message age: umur job tertua, penting untuk melihat starvation.
  • Processing duration: p50, p95, p99 per jenis job.
  • Attempt count distribution: berapa banyak job yang sukses di percobaan pertama vs retry.
  • Success/failure rate: dipisahkan menurut jenis error.
  • Visibility timeout re-delivery: indikasi duplikasi akibat timeout.
  • DLQ inflow: laju masuk dead-letter queue.
  • Idempotency conflict count: berapa kali efek ganda tertahan.
  • Lock wait time dan expired lock count.
  • Outbox lag: selisih waktu antara event ditulis ke outbox dan dipublish.
  • Cache staleness indicator: misalnya mismatch versi cache terhadap versi data sumber.

Log dan trace yang penting

  • job_id, message_id, correlation_id
  • entity_id atau kunci bisnis terkait
  • idempotency_key
  • attempt_number
  • lock_key dan owner_token bila relevan
  • external_request_id ke downstream API
  • durasi tiap langkah penting: dequeue, acquire lock, call API, commit, ack

Dengan data ini, Anda bisa membedakan antara job lambat, job berulang, dan job yang efek bisnisnya sudah terjadi namun gagal di tahap akhir.

Anti-pattern yang sebaiknya dihindari

  • Mengejar “exactly once” sebagai slogan tanpa mendefinisikan di level mana jaminan itu berlaku.
  • Satu retry policy untuk semua error.
  • Dedup berbasis hash seluruh payload untuk operasi bisnis penting.
  • Lock sebagai solusi universal untuk semua race condition.
  • TTL cache panjang untuk menutupi desain invalidasi yang kabur.
  • Ordering global padahal kebutuhan nyata hanya ordering per entitas.
  • Abstraksi internal yang menyembunyikan titik commit, ack, dan publish.
  • Menganggap queue yang “reliable” menghapus kebutuhan idempotensi.

Kapan kompleksitas tambahan memang layak

Menghindari abstraksi berlebih bukan berarti menolak semua kompleksitas. Ada kondisi ketika mekanisme tambahan memang diperlukan.

Layak menambah kompleksitas bila:

  • efek bisnis bernilai tinggi, seperti pembayaran, settlement, atau provisioning sumber daya mahal
  • beberapa penyimpanan harus konsisten secara operasional, sehingga outbox atau saga terbatas menjadi perlu
  • throughput tinggi membuat retry storm atau hot key menjadi risiko nyata
  • ordering per entitas adalah syarat domain, bukan preferensi implementasi
  • auditability dan jejak insiden adalah kebutuhan kepatuhan, bukan sekadar kenyamanan

Tetap jaga batasnya

Tambahkan kompleksitas secara lokal, bukan global. Misalnya:

  • pakai outbox hanya untuk event yang benar-benar penting
  • pakai lock hanya untuk resource yang memang perlu eksklusivitas
  • pakai versioned cache key hanya pada data yang fan-out invalidasinya rumit
  • pakai serial processing hanya pada partisi entitas yang membutuhkan ordering

Ini lebih sehat daripada membangun platform internal besar yang memaksa semua job melalui abstraksi yang sama.

Penutup

Masalah queue dan worker jarang selesai dengan abstraksi yang makin umum. Justru sebaliknya, semakin dekat desain Anda ke gejala operasional yang nyata, semakin mudah sistem itu diaudit dan dipelihara. Untuk banyak kasus, solusi yang lebih baik bukan framework serbaguna, melainkan kombinasi teknik yang jelas: idempotency key yang berbasis operasi bisnis, visibility timeout yang realistis, dedup yang sempit, lock yang hati-hati, outbox untuk konsistensi publish, dan invalidasi cache yang eksplisit.

Jika Anda sedang mendesain atau merapikan sistem worker, tanyakan satu hal sederhana: apakah abstraksi ini membantu saya menjelaskan insiden jam 3 pagi dengan cepat? Jika jawabannya tidak, kemungkinan abstraksinya terlalu umum untuk masalah yang sebenarnya sangat konkret.