Worker Queue Python yang stabil bukan sekadar soal menaruh job ke Redis atau database lalu menjalankan beberapa worker. Masalah sebenarnya muncul saat sistem mulai gagal secara parsial: job diproses dua kali, worker mati di tengah eksekusi, cache menyajikan data lama, atau satu pesan rusak membuat antrean tersendat.

Untuk backend engineer, desain yang aman biasanya berarti menerima kenyataan bahwa delivery umumnya bersifat at-least-once, lalu membangun sistem yang tetap benar saat job dijalankan ulang. Di artikel ini, kita fokus pada pola praktis: concurrency worker, distributed lock, idempotency key, retry dengan backoff, dead-letter queue, visibility timeout, dan cache invalidation yang tidak menipu. Konteksnya cocok untuk ekosistem Python backend yang umum dipakai bersama Redis, database relasional, dan service internal.

Mengapa worker queue sering gagal di produksi

Secara teori, queue membantu memisahkan request pengguna dari pekerjaan berat di belakang layar. Secara praktik, queue memperkenalkan masalah distribusi:

  • Duplicate job: producer mengirim ulang karena timeout, broker mengirim ulang karena ack hilang, atau worker crash setelah side effect terjadi tetapi sebelum status tersimpan.
  • Race condition: dua worker memproses resource yang sama secara paralel.
  • Lost update: hasil kerja worker kedua menimpa perubahan worker pertama tanpa sadar.
  • Stale cache: job selesai, database berubah, tetapi cache lama tetap hidup.
  • Poison message: satu payload buruk gagal terus dan menghabiskan retry.
  • Thundering herd: banyak worker atau request memukul resource yang sama secara bersamaan setelah cache expired atau lock lepas.

Masalah-masalah ini tidak bisa diselesaikan hanya dengan menambah jumlah worker. Yang dibutuhkan adalah kombinasi desain data, kontrol konkurensi, dan observabilitas.

Prinsip dasar: anggap delivery adalah at-least-once

Pola yang paling aman adalah berasumsi bahwa job bisa diproses lebih dari sekali. Ini berlaku bahkan jika broker mengklaim mekanisme delivery yang kuat, karena kegagalan sering terjadi di luar broker: koneksi putus, worker timeout, proses mati, atau transaksi database tidak selesai.

Konsekuensinya:

  • Handler job harus idempotent atau minimal memiliki proteksi idempotensi.
  • Operasi side effect perlu dipagari dengan lock, constraint database, atau status machine.
  • Retry harus aman dijalankan ulang tanpa menggandakan efek bisnis.

Jika satu job tidak aman dijalankan dua kali, maka sistem queue Anda belum siap menghadapi kegagalan normal.

Desain alur worker queue Python yang tahan gangguan

Alur minimum yang direkomendasikan

  1. Producer membuat payload job dengan job_id, resource_id, dan bila perlu idempotency_key.
  2. Job masuk ke queue.
  3. Worker mengambil job dan menandainya sedang diproses, sering kali dengan visibility timeout.
  4. Worker memeriksa apakah job ini sudah pernah sukses diproses.
  5. Worker mengambil lock atau row lock jika operasi menyentuh resource yang rentan race.
  6. Worker menjalankan logika bisnis dalam batas waktu yang jelas.
  7. Jika sukses, worker menyimpan hasil, memperbarui cache dengan strategi yang konsisten, lalu meng-ack job.
  8. Jika gagal sementara, job dijadwalkan ulang dengan backoff.
  9. Jika gagal permanen atau melewati batas retry, job dipindah ke dead-letter queue.

Contoh struktur payload job

{
  "job_id": "job_01J...",
  "type": "recalculate_invoice",
  "resource_id": "inv_12345",
  "idempotency_key": "invoice:inv_12345:recalc:v3",
  "attempt": 1,
  "queued_at": "2026-07-09T10:00:00Z"
}

job_id berguna untuk pelacakan operasional. idempotency_key berguna untuk menjamin efek bisnis tidak terduplikasi walau job dikirim ulang dengan job_id berbeda.

Idempotency key: pertahanan pertama terhadap duplicate job

Idempotensi berarti menjalankan operasi yang sama berkali-kali tetap menghasilkan keadaan akhir yang benar. Dalam worker queue, ini lebih penting daripada sekadar mendeteksi duplikasi pesan.

Kapan perlu idempotency key

  • Mengirim email atau notifikasi berbayar.
  • Membuat invoice, settlement, atau transaksi eksternal.
  • Mengubah status domain yang tidak boleh mundur atau dobel.
  • Sinkronisasi ke service pihak ketiga yang bisa menerima request ulang.

Pola implementasi

Simpan idempotency_key di storage yang dapat diandalkan, biasanya database dengan unique constraint. Jika request atau job yang sama datang lagi, sistem mengembalikan hasil sebelumnya atau menolak duplikasi dengan aman.

def process_job(db, payload):
    key = payload["idempotency_key"]

    # Tabel idempotency_records memiliki unique constraint pada key
    existing = db.fetch_one(
        "SELECT status, result_ref FROM idempotency_records WHERE key = %s",
        [key],
    )
    if existing and existing["status"] == "done":
        return existing["result_ref"]

    with db.transaction():
        inserted = db.execute(
            """
            INSERT INTO idempotency_records(key, status)
            VALUES (%s, 'processing')
            ON CONFLICT (key) DO NOTHING
            """,
            [key],
        )

        if inserted == 0:
            # Worker lain mungkin sedang memproses key yang sama
            raise RetryLater("duplicate in progress")

        result_ref = recalculate_invoice(db, payload["resource_id"])

        db.execute(
            "UPDATE idempotency_records SET status = 'done', result_ref = %s WHERE key = %s",
            [result_ref, key],
        )

    return result_ref

Kenapa pendekatan ini bekerja? Karena proteksi utama diletakkan di storage yang konsisten, bukan hanya di memori worker. Bahkan jika worker restart, riwayat idempotensi tetap ada.

Kesalahan umum

  • Menggunakan timestamp acak sebagai idempotency key, sehingga request yang sama tidak dikenali sebagai duplikat.
  • Menyimpan key hanya di cache volatile tanpa fallback persisten.
  • Menganggap job_id selalu cukup; padahal producer bisa membuat job baru untuk operasi bisnis yang sama.

Lock: kapan pakai Redis lock, kapan pakai DB row lock

Lock dipakai untuk mencegah dua worker memodifikasi resource yang sama secara bersamaan. Namun lock bukan pengganti idempotensi. Lock mencegah eksekusi paralel; idempotensi mencegah efek ganda saat job diulang.

Pilih DB row lock jika

  • Data yang dilindungi memang berada di database relasional.
  • Anda butuh konsistensi kuat dalam satu transaksi.
  • Operasi melibatkan pembacaan dan penulisan state yang harus atomik.

Contoh tipikal adalah SELECT ... FOR UPDATE pada baris order, invoice, atau saldo akun. Ini cocok untuk mencegah lost update dan race condition yang terkait langsung dengan state database.

Pilih Redis lock jika

  • Anda butuh koordinasi lintas proses yang tidak nyaman diikat ke satu transaksi database.
  • Resource yang dilindungi bukan hanya satu row DB, misalnya regenerasi cache, sinkronisasi ke API eksternal, atau job komputasi mahal per key.
  • Lock perlu cepat dan granular dengan biaya rendah.

Namun Redis lock membawa trade-off: ada TTL, kemungkinan lock kedaluwarsa saat pekerjaan belum selesai, dan kebutuhan untuk memastikan hanya pemilik lock yang boleh melepasnya.

Contoh Redis distributed lock sederhana

import os
import time
import uuid

class RedisLock:
    def __init__(self, redis, key, ttl_seconds=30):
        self.redis = redis
        self.key = key
        self.ttl_seconds = ttl_seconds
        self.token = str(uuid.uuid4())

    def acquire(self):
        return self.redis.set(self.key, self.token, nx=True, ex=self.ttl_seconds)

    def release(self):
        script = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """
        return self.redis.eval(script, 1, self.key, self.token)

Praktik penting pada Redis lock:

  • Simpan token unik per pemilik lock.
  • Lepaskan lock hanya jika token masih cocok.
  • Atur TTL agar lebih panjang dari durasi kerja normal, atau sediakan mekanisme perpanjangan lock bila pekerjaan valid memang lama.

Rule of thumb praktis

  • DB row lock: pilih jika kebenaran data bergantung pada transaksi database.
  • Redis lock: pilih jika tujuan utamanya koordinasi ringan antar worker untuk resource non-transaksional atau komputasi mahal.
  • Jika operasi menyentuh keduanya, jangan mengandalkan Redis lock saja untuk menjamin integritas data di database.

Retry dengan backoff: ulangi yang masuk akal, hentikan yang beracun

Tidak semua kegagalan layak di-retry. Retry berguna untuk error sementara seperti timeout jaringan, rate limit, atau service dependency yang sedang tidak sehat. Untuk payload invalid atau bug deterministik, retry hanya memperpanjang antrean.

Klasifikasi error

  • Transient: koneksi putus, timeout, dependency 5xx, lock contention. Layak retry.
  • Permanent: schema payload salah, resource tidak ada, validasi bisnis gagal permanen. Jangan retry tanpa perbaikan data atau kode.
  • Unknown: default konservatif, retry terbatas lalu kirim ke DLQ.

Gunakan exponential backoff + jitter

Backoff mengurangi tekanan pada dependency yang sedang bermasalah. Jitter mencegah banyak worker mencoba lagi pada detik yang sama dan memicu thundering herd.

import random

def next_delay_seconds(attempt, base=2, cap=300):
    raw = min(cap, base ** attempt)
    jitter = random.uniform(0, raw * 0.2)
    return raw + jitter

Contoh kebijakan:

  • Attempt 1-3: retry cepat untuk gangguan singkat.
  • Attempt 4-6: interval lebih longgar agar dependency pulih.
  • Setelah batas tercapai: kirim ke DLQ untuk inspeksi.

Dead-letter queue untuk poison message

Poison message adalah job yang akan gagal terus sampai payload atau kode diperbaiki. Tanpa DLQ, job seperti ini dapat berputar tanpa akhir dan menutupi masalah lain.

Simpan di DLQ bersama metadata penting:

  • job_id dan idempotency_key
  • jumlah attempt
  • error class dan pesan ringkas
  • stack trace atau reason code
  • waktu pertama gagal dan terakhir gagal

DLQ bukan tempat membuang masalah, melainkan tempat triage. Idealnya ada tooling untuk replay setelah perbaikan dilakukan.

Visibility timeout dan ack: mencegah job hilang diam-diam

Pada banyak sistem queue, worker mengambil job lalu broker menyembunyikannya sementara dari worker lain. Jika worker tidak meng-ack sebelum visibility timeout habis, job dianggap belum selesai dan bisa muncul lagi.

Kenapa visibility timeout penting

  • Mencegah job hilang permanen saat worker crash di tengah proses.
  • Memungkinkan re-delivery saat ada kegagalan.
  • Menjadi dasar perilaku at-least-once.

Masalah umum

  • Timeout terlalu pendek: job masih berjalan, tetapi broker mengirim ulang ke worker lain, memicu duplikasi.
  • Timeout terlalu panjang: job gagal cepat tetapi tertahan lama sebelum bisa diambil ulang.
  • Ack terlalu awal: job dianggap selesai sebelum side effect benar-benar commit.

Prinsip amannya: ack setelah efek utama benar-benar tersimpan atau terjamin. Jika pekerjaan bisa lebih lama dari visibility timeout normal, pertimbangkan heartbeat atau perpanjangan lease saat worker masih sehat.

Cache yang tidak menipu: invalidation, stampede, dan konsistensi

Cache sering membuat sistem terlihat cepat sekaligus salah. Pada sistem worker, cache rawan menipu karena update terjadi asinkron: database berubah di worker, tetapi pembaca lain masih melihat versi lama.

Masalah stale cache

Kasus klasik:

  1. Request membaca data A dari cache.
  2. Job worker memperbarui data A di database.
  3. Cache tidak dihapus atau tidak ditulis ulang.
  4. Sistem tetap menyajikan nilai lama sampai TTL habis.

Pilih strategi cache yang sesuai

  • Cache-aside: aplikasi membaca dari cache, fallback ke DB, lalu mengisi cache. Sederhana, tetapi invalidation harus disiplin.
  • Write-through: saat update terjadi, cache ikut ditulis. Cocok jika jalur update terkontrol.
  • Invalidate-on-write: saat DB berubah, hapus key terkait, lalu pembacaan berikutnya mengisi ulang.

Untuk banyak backend, invalidate-on-write lebih aman daripada mencoba selalu menulis nilai cache baru dari worker, terutama jika ada banyak representasi turunan dari data yang sama.

Mencegah thundering herd saat cache miss

Saat satu key populer expired, banyak request bisa serentak menghantam database. Beberapa mitigasi praktis:

  • Gunakan TTL dengan jitter agar key tidak kadaluarsa bersamaan.
  • Pakai single-flight lock per key untuk satu proses regenerasi cache.
  • Untuk data yang boleh sedikit lama, terapkan stale-while-revalidate.
def get_or_build_user_summary(redis, key, ttl, builder):
    value = redis.get(key)
    if value:
        return value

    lock = RedisLock(redis, f"lock:cache:{key}", ttl_seconds=10)
    if lock.acquire():
        try:
            value = redis.get(key)
            if value:
                return value
            fresh = builder()
            redis.set(key, fresh, ex=ttl)
            return fresh
        finally:
            lock.release()

    # Worker/request lain sedang membangun cache
    time.sleep(0.05)
    return redis.get(key) or builder()

Pendekatan ini tidak sempurna, tetapi cukup efektif untuk mengurangi stampede pada key panas.

Contoh alur end-to-end: update invoice tanpa duplicate effect

Skenario

Sebuah service menerima event bahwa item invoice berubah. Sistem perlu menghitung ulang total invoice, memperbarui database, menghapus cache ringkasan invoice, dan mungkin mengirim notifikasi internal.

Alur yang disarankan

  1. Producer membuat job recalculate_invoice dengan resource_id=invoice_id dan idempotency_key=invoice:{id}:recalc:{version}.
  2. Worker mengambil job.
  3. Worker memeriksa tabel idempotensi. Jika status done, job dianggap selesai.
  4. Worker membuka transaksi database dan mengambil row lock untuk invoice target.
  5. Worker menghitung ulang total dan memperbarui invoice.
  6. Worker menyimpan catatan bahwa idempotency key selesai.
  7. Transaksi commit.
  8. Setelah commit, worker menghapus cache invoice terkait dan cache agregat yang terpengaruh.
  9. Worker meng-ack job.

Kenapa urutan ini penting

  • Row lock mencegah dua worker mengubah invoice yang sama secara paralel.
  • Idempotency key mencegah efek bisnis dobel saat job terkirim ulang.
  • Cache invalidation setelah commit mencegah cache dihapus sebelum perubahan benar-benar persisten.
  • Ack terakhir memastikan job tidak hilang jika worker mati sebelum operasi utama tersimpan.

Observabilitas: metrik yang wajib dipantau

Worker queue yang tampak sehat bisa sebenarnya sedang menumpuk retry atau menghasilkan duplikasi. Karena itu, observabilitas harus dirancang dari awal.

Metrik inti

  • Queue depth: jumlah job menunggu.
  • Oldest job age: umur job tertua di queue, indikator backlog nyata.
  • In-flight jobs: job yang sedang diproses.
  • Success rate dan failure rate.
  • Retry rate: lonjakan sering menandakan dependency bermasalah.
  • DLQ count: jumlah pesan masuk dead-letter queue.
  • Processing latency: durasi eksekusi job.
  • Time-to-complete: waktu dari enqueue sampai sukses.
  • Duplicate suppression count: seberapa sering idempotensi menyelamatkan sistem.
  • Lock contention: frekuensi gagal mengambil lock.
  • Cache hit ratio dan cache rebuild rate.

Logging yang berguna

Minimal sertakan job_id, idempotency_key, resource_id, attempt, dan trace_id jika ada. Tanpa ini, duplicate job dan poison message sulit dilacak.

Sinyal alarm yang layak dibuat

  • Oldest job age melewati SLA.
  • Retry rate naik tajam dalam beberapa menit.
  • DLQ bertambah terus.
  • Visibility timeout expiry meningkat.
  • Lock contention tinggi pada resource tertentu.
  • Cache miss mendadak melonjak untuk key panas.

Debugging masalah yang paling sering terjadi

1. Job terproses dua kali

Periksa hal berikut:

  • Apakah worker meng-ack sebelum commit?
  • Apakah visibility timeout lebih pendek dari durasi kerja?
  • Apakah idempotency key benar-benar stabil untuk operasi bisnis yang sama?
  • Apakah producer mengirim ulang saat timeout tanpa deduplikasi?

2. Race condition pada resource yang sama

Tinjau apakah lock ditempatkan di boundary yang tepat. Jika integritas data bergantung pada DB, Redis lock saja biasanya tidak cukup. Gunakan transaksi dan row lock.

3. Cache tetap lama setelah job sukses

Pastikan invalidasi dilakukan setelah commit, bukan sebelum. Jika ada beberapa key turunan, petakan dependensinya secara eksplisit; jangan hanya menghapus satu key utama.

4. Queue macet karena pesan tertentu

Cari payload dengan attempt tinggi dan error deterministik. Pastikan ada batas retry dan DLQ. Tanpa itu, poison message akan terus memakan kapasitas worker.

5. Lonjakan trafik membuat DB tumbang setelah cache expired

Ini gejala thundering herd. Tambahkan TTL jitter, lock per key untuk regenerasi cache, atau strategi stale-while-revalidate untuk endpoint yang toleran sedikit data lama.

Checklist implementasi worker queue Python

  • Definisikan apakah job Anda at-least-once. Anggap iya kecuali terbukti sebaliknya.
  • Tambahkan job_id, resource_id, dan idempotency_key pada payload.
  • Simpan idempotency state di storage persisten dengan constraint yang tepat.
  • Pilih DB row lock untuk integritas data transaksional; gunakan Redis lock untuk koordinasi ringan antar worker.
  • Atur visibility timeout berdasarkan durasi kerja nyata, bukan tebakan optimistis.
  • Ack hanya setelah side effect utama benar-benar aman.
  • Bedakan error transient dan permanent.
  • Terapkan exponential backoff + jitter untuk retry.
  • Siapkan dead-letter queue dan proses replay yang terkendali.
  • Rancang invalidasi cache bersama alur update data, bukan belakangan.
  • Tambahkan metrik untuk backlog, retry, DLQ, lock contention, dan duplicate suppression.
  • Uji skenario crash: worker mati sebelum ack, setelah commit, saat memegang lock, dan saat cache belum terinvalidation.

Penutup

Worker Queue Python yang tahan gangguan operasional dibangun dengan menerima kenyataan bahwa duplikasi, retry, dan kegagalan parsial adalah hal normal. Karena itu, desain yang baik menggabungkan idempotency key, lock yang tepat, retry dengan backoff, DLQ, visibility timeout, dan cache invalidation yang disiplin.

Jika harus memilih prioritas implementasi, mulailah dari tiga hal: idempotensi, ack yang benar, dan observabilitas. Setelah itu, baru haluskan concurrency, lock, dan cache. Tiga fondasi ini biasanya yang paling menentukan apakah worker Anda sekadar berjalan, atau benar-benar bisa dipercaya saat sistem sedang tidak baik-baik saja.