Pada sistem worker berbasis queue, masalah yang paling sering muncul di produksi bukan sekadar job gagal, tetapi job diproses lebih dari sekali, lock Redis tertinggal, dan status job menjadi tidak konsisten. Jika aplikasi Anda memakai Go Fiber untuk API dan Redis untuk koordinasi worker, akar masalahnya biasanya ada pada desain locking, retry, dan status tracking yang terlalu optimistis.

Solusi yang umumnya efektif bukan hanya menambahkan Redis lock, tetapi memakai lease lock dengan TTL, heartbeat untuk renew lock, fencing token agar owner lama tidak bisa menimpa state, idempotency key untuk operasi yang harus aman diulang, dedup job di level enqueue, serta visibility timeout agar job bisa dipulihkan saat worker crash. Artikel ini membahas pola tersebut secara praktis dan bisa langsung diterapkan.

Gejala di Produksi: Tanda Worker Anda Bermasalah

Beberapa gejala berikut hampir selalu mengarah ke masalah lock stale, job duplikat, atau retry yang tidak terkendali:

  • Status job bolak-balik: dari processing ke queued, lalu tiba-tiba done, lalu tertimpa lagi oleh worker lain.
  • Operasi side effect terjadi dua kali: email terkirim ganda, stok berkurang dua kali, invoice dibuat lebih dari satu.
  • Queue terlihat macet: job lama tetap dianggap sedang diproses padahal worker yang memegang lock sudah mati.
  • Retry meledak: job yang gagal diulang terus tanpa backoff, membuat Redis dan downstream service makin terbebani.
  • Cache/status tidak sinkron: metadata job di Redis menyatakan selesai, tetapi database atau sistem eksternal belum berubah.

Kalau gejala di atas muncul sesekali, biasanya masalahnya ada di race condition. Kalau muncul saat traffic tinggi, deploy, restart pod, atau gangguan jaringan, biasanya desain lease dan recovery belum matang.

Root Cause Umum Redis Lock Stale dan Job Duplikat

1. Lock hanya pakai SETNX tanpa TTL

Jika worker crash setelah berhasil mengambil lock, key lock tidak pernah dibersihkan. Akibatnya job dianggap tetap dikunci selamanya.

2. TTL ada, tetapi lebih pendek dari durasi kerja nyata

Worker A mengambil lock dengan TTL 30 detik, tetapi job membutuhkan 2 menit. Setelah 30 detik lock habis, worker B mengambil lock yang sama dan memproses job yang belum selesai. Inilah salah satu penyebab klasik job duplikat.

3. Unlock tidak memverifikasi owner

Jika worker menghapus lock hanya berdasarkan nama key, worker lama bisa menghapus lock milik worker baru. Ini menimbulkan race condition yang sulit dideteksi.

4. Tidak ada fencing token

Walau lock sudah memakai TTL, owner lama yang terlambat atau tersuspend bisa tetap menulis hasil ke database atau cache setelah lock berpindah. Tanpa fencing token, sistem tidak bisa membedakan write dari owner lama dan owner baru.

5. Queue tidak punya visibility timeout

Jika worker mengambil job lalu crash sebelum ack, job bisa hilang atau tetap dianggap diproses. Sistem perlu cara untuk mengembalikan job yang tidak selesai dalam waktu tertentu.

6. Retry tanpa batas dan tanpa backoff

Job yang gagal karena dependency down akan terus diproses ulang secara agresif. Efeknya bukan recovery, tetapi amplifikasi kegagalan.

7. Operasi tidak idempotent

Walau duplicate delivery sulit dihindari total, dampaknya harus bisa dikendalikan. Jika handler tidak idempotent, satu job yang sama dapat menghasilkan side effect berkali-kali.

Arsitektur yang Disarankan untuk Worker Go Fiber + Redis

Go Fiber di sini biasanya berperan sebagai API untuk enqueue job, memeriksa status, atau memicu proses tertentu. Worker bisa berjalan dalam proses terpisah atau goroutine terisolasi, tetapi sebaiknya dipisahkan dari lifecycle request HTTP agar kegagalan worker tidak mengganggu API utama.

Alur yang aman secara operasional

  1. API menerima request dan membuat job_id serta idempotency key bila diperlukan.
  2. API menulis metadata job ke Redis atau database: status queued, attempt=0, payload, timestamps.
  3. API melakukan dedup enqueue berdasarkan key unik, misalnya resource atau kombinasi tenant+tipe+objek.
  4. Worker mengambil job dari queue dan membuat lease lock dengan TTL.
  5. Worker juga memperoleh fencing token yang meningkat monoton.
  6. Selama memproses job, worker mengirim heartbeat untuk memperpanjang TTL lock dan visibility timeout.
  7. Saat menulis status/hasil, worker memverifikasi bahwa fencing token masih valid.
  8. Jika sukses, worker menandai job done, menghapus lock bila masih owner yang sah, lalu ack job.
  9. Jika worker crash, lease akan habis dan job bisa direclaim oleh worker lain setelah visibility timeout lewat.

Struktur key Redis yang praktis

Struktur berikut cukup umum dan mudah diobservasi:

queue:jobs                    # list/stream/zset antrian utama, tergantung implementasi queue Anda
queue:processing             # job yang sedang diproses / in-flight
job:{job_id}:meta            # hash: status, attempt, updated_at, worker_id, token
job:{job_id}:result          # hasil proses bila perlu disimpan singkat
lock:job:{job_id}            # string owner/lease token, punya TTL
lock:job:{job_id}:fence      # counter INCR untuk fencing token
idem:{idempotency_key}       # penanda request/operasi idempotent
dedup:{dedup_key}            # penanda agar job serupa tidak dienqueue berulang
retry:zset                   # schedule retry berdasarkan timestamp

Anda tidak harus memakai semua key di atas, tetapi memisahkan fungsi lock, metadata, dan dedup akan memudahkan debugging.

Pola Locking yang Aman: Lease Lock, Heartbeat, dan Fencing Token

Mengapa lease lock lebih aman daripada lock statis

Lease lock adalah lock dengan TTL yang harus diperpanjang secara periodik oleh owner aktif. Jika worker hang, crash, atau terputus terlalu lama, lease akan habis secara otomatis. Ini jauh lebih aman daripada lock tanpa TTL.

Namun TTL saja belum cukup. Ada dua masalah yang masih tersisa:

  • Owner lama bisa tetap mencoba menulis hasil setelah lease berpindah.
  • Unlock bisa salah sasaran jika tidak memverifikasi siapa owner lock.

Di sinilah owner token dan fencing token diperlukan.

Owner token untuk unlock yang aman

Saat lock diambil, simpan nilai unik sebagai owner, misalnya UUID. Saat release, hapus lock hanya jika nilainya masih sama. Cara ini mencegah worker lama menghapus lock yang sudah dimiliki worker baru.

Fencing token untuk mencegah write dari owner lama

Setiap kali lock berhasil diambil, sistem menghasilkan angka token yang selalu meningkat, misalnya dari Redis INCR. Semua write ke status job, cache, atau resource lain menyertakan token ini. Consumer data atau layer write harus menolak write dengan token lebih kecil dari token terakhir yang diterima.

Intinya, lock menentukan siapa yang boleh bekerja sekarang, sedangkan fencing token menentukan siapa yang boleh menulis hasil paling baru.

Catatan: fencing token sangat berguna saat ada pause panjang karena GC, preemption, network partition, atau worker tersuspend. Tanpanya, owner lama bisa menyelesaikan proses terlambat lalu menimpa state yang sudah lebih baru.

Contoh Implementasi Go Singkat

Berikut contoh sederhana menggunakan client Redis generik di Go. Fokusnya pada pola, bukan package tertentu.

Mengambil lease lock dan fencing token

type Lease struct {
    JobID        string
    OwnerID      string
    FencingToken int64
    TTL          time.Duration
}

func AcquireLease(ctx context.Context, rdb RedisCmdable, jobID, ownerID string, ttl time.Duration) (*Lease, error) {
    lockKey := "lock:job:" + jobID
    fenceKey := lockKey + ":fence"

    ok, err := rdb.SetNX(ctx, lockKey, ownerID, ttl).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, ErrLeaseNotAcquired
    }

    token, err := rdb.Incr(ctx, fenceKey).Result()
    if err != nil {
        // best effort cleanup bila token gagal dibuat
        _ = releaseLease(ctx, rdb, jobID, ownerID)
        return nil, err
    }

    return &Lease{
        JobID:        jobID,
        OwnerID:      ownerID,
        FencingToken: token,
        TTL:          ttl,
    }, nil
}

Memperpanjang lease dengan verifikasi owner

Pembaruan TTL sebaiknya tidak dilakukan buta. Pastikan key lock masih dimiliki owner yang sama.

var renewScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
`)

func RenewLease(ctx context.Context, rdb RedisCmdable, jobID, ownerID string, ttl time.Duration) (bool, error) {
    lockKey := "lock:job:" + jobID
    ms := ttl.Milliseconds()
    n, err := renewScript.Run(ctx, rdb, []string{lockKey}, ownerID, ms).Int()
    if err != nil {
        return false, err
    }
    return n == 1, nil
}

Melepas lease dengan aman

var releaseScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0
`)

func releaseLease(ctx context.Context, rdb RedisCmdable, jobID, ownerID string) error {
    lockKey := "lock:job:" + jobID
    _, err := releaseScript.Run(ctx, rdb, []string{lockKey}, ownerID).Result()
    return err
}

Heartbeat goroutine

Heartbeat memperpanjang lease sebelum TTL habis. Interval umum adalah sebagian kecil dari TTL, misalnya sepertiga TTL, agar masih ada ruang jika terjadi jeda singkat.

func StartHeartbeat(ctx context.Context, rdb RedisCmdable, lease *Lease, onLost func()) {
    ticker := time.NewTicker(lease.TTL / 3)
    go func() {
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                ok, err := RenewLease(ctx, rdb, lease.JobID, lease.OwnerID, lease.TTL)
                if err != nil || !ok {
                    onLost()
                    return
                }
            }
        }
    }()
}

Menulis status job dengan fencing token

Salah satu pendekatan adalah menyimpan token terakhir di metadata job dan menolak update dengan token lebih kecil.

func UpdateJobStatus(ctx context.Context, rdb RedisCmdable, jobID string, token int64, status string) error {
    key := "job:" + jobID + ":meta"

    // Pseudocode: update hanya jika token baru >= token lama.
    // Implementasi riil biasanya memakai Lua script agar atomik.
    current, err := rdb.HGet(ctx, key, "fencing_token").Int64()
    if err != nil && err != redis.Nil {
        return err
    }
    if current > token {
        return ErrStaleWriter
    }

    _, err = rdb.HSet(ctx, key,
        "status", status,
        "fencing_token", token,
        "updated_at", time.Now().UTC().Format(time.RFC3339),
    ).Result()
    return err
}

Untuk produksi, update berbasis token sebaiknya dibuat atomik dengan Lua script atau dipindahkan ke database yang mendukung compare-and-set/optimistic locking.

Idempotency Key dan Dedup Job: Bukan Hal yang Sama

Idempotency key

Idempotency key melindungi operasi dari request atau eksekusi ulang yang semestinya menghasilkan efek sama. Misalnya endpoint membuat invoice atau menjalankan sinkronisasi. Jika request yang sama datang lagi, sistem mengembalikan hasil sebelumnya atau menolak duplikasi secara aman.

Contoh penggunaan:

  • HTTP API menerima header atau field idempotency key.
  • Simpan key ke Redis dengan TTL yang sesuai siklus bisnis.
  • Jika key sudah ada dan hasil sudah tersedia, kembalikan hasil lama.

Dedup job

Dedup job mencegah enqueue berulang untuk pekerjaan yang semestinya cukup satu dalam jangka waktu tertentu. Ini berguna untuk event yang sering datang beruntun, misalnya rebuild cache untuk resource yang sama.

Contoh dedup key:

dedup:{tenant_id}:{job_type}:{resource_id}

Trade-off penting:

  • Dedup agresif mengurangi duplikasi, tetapi bisa membuang job yang sebenarnya membawa payload terbaru.
  • Idempotency melindungi side effect, tetapi tidak otomatis mengurangi antrean.

Praktiknya, banyak sistem butuh keduanya: dedup di saat enqueue, idempotency di saat eksekusi.

Visibility Timeout dan Recovery Saat Worker Crash

Jika queue Anda mendukung model reserve/ack, gunakan visibility timeout. Saat worker mengambil job, job dipindah ke status in-flight untuk periode tertentu. Bila worker gagal melakukan ack sebelum periode itu habis, job kembali terlihat dan bisa diambil worker lain.

Kapan visibility timeout dipakai bersama lock?

Keduanya menangani lapisan berbeda:

  • Visibility timeout mengatur lifecycle job di queue.
  • Lease lock mengatur eksklusivitas pemrosesan resource atau job tertentu.

Jika hanya mengandalkan queue tanpa lock, dua worker tetap bisa berbenturan pada resource yang sama dari job berbeda. Jika hanya mengandalkan lock tanpa visibility timeout, job yang diambil worker crash bisa sulit dipulihkan secara rapi.

Pola recovery yang umum

  1. Worker reserve job dan membuat metadata processing.
  2. Worker mengambil lease lock.
  3. Jika worker crash, heartbeat berhenti, lease habis.
  4. Visibility timeout job habis, job kembali ke antrean atau dipindahkan ke retry scheduler.
  5. Worker baru mengambil job, memperoleh fencing token baru, lalu melanjutkan proses dengan aman.

Jika job memiliki langkah parsial, simpan checkpoint yang aman dan idempotent. Jangan mengandalkan asumsi bahwa job selalu dimulai dari nol.

Retry yang Terkendali: Backoff, Limit, dan Dead Letter

Retry seharusnya membantu recovery, bukan memperparah insiden. Gunakan aturan berikut:

  • Batas attempt: misalnya simpan attempt pada metadata job.
  • Exponential backoff: jeda retry makin lama untuk kegagalan berulang.
  • Jitter: tambahkan variasi acak kecil agar tidak terjadi retry serentak.
  • Klasifikasi error: bedakan error permanen dan sementara.
  • Dead letter queue: job yang melewati batas retry dipindahkan untuk inspeksi manual atau proses khusus.

Contoh strategi sederhana:

nextDelay = baseDelay * 2^attempt + jitter

Untuk error seperti validasi payload rusak atau referensi data tidak ada, jangan retry tanpa batas. Untuk error seperti timeout jaringan atau service downstream 503, retry dengan backoff masuk akal.

Konsistensi Status Job dan Cache

Status job sering disimpan di Redis karena cepat, tetapi masalah muncul ketika Redis hanya menjadi cache sementara sumber kebenaran ada di database atau sistem lain. Jika update status tidak atomik atau tidak memakai fencing token, state mudah saling menimpa.

Prinsip yang aman

  • Tentukan sumber kebenaran: Redis, database, atau keduanya dengan peran berbeda.
  • Jika Redis hanya cache status, jangan anggap ia selalu final.
  • Jika status penting untuk audit atau billing, simpan juga di database persisten.
  • Gunakan transisi status yang valid: queued -> processing -> done/failed, hindari loncatan liar.
  • Sertakan worker_id, attempt, dan fencing_token dalam metadata untuk forensik.

Kesalahan umum adalah memperbarui status done sebelum side effect benar-benar selesai. Jika setelah itu call ke downstream gagal, job terlihat sukses padahal hasil riil tidak lengkap.

Contoh Integrasi Ringkas dengan Go Fiber

Di sisi API, Go Fiber dapat digunakan untuk membuat endpoint enqueue yang sudah mendukung dedup dan idempotency. Logika worker sebaiknya tetap terpisah, tetapi endpoint berikut menunjukkan pola dasarnya.

app.Post("/jobs/rebuild-cache", func(c *fiber.Ctx) error {
    type req struct {
        TenantID   string `json:"tenant_id"`
        ResourceID string `json:"resource_id"`
    }

    var body req
    if err := c.BodyParser(&body); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload"})
    }

    idemKey := c.Get("Idempotency-Key")
    dedupKey := "dedup:" + body.TenantID + ":rebuild-cache:" + body.ResourceID

    // Pseudocode: cek idempotency dan dedup lebih dulu.
    // Jika dedup masih aktif, kembalikan job existing atau status accepted.

    jobID := uuid.NewString()
    // Simpan metadata job: queued, attempt=0, payload, created_at.
    // Push ke queue.

    return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
        "job_id": jobID,
        "status": "queued",
    })
})

Poin utamanya: endpoint tidak perlu memproses kerja berat secara sinkron, tetapi harus membuat data job dengan key yang dapat diobservasi dan dilacak.

Trade-off Pendekatan Locking

SETNX + TTL sederhana

  • Kelebihan: mudah diterapkan, cukup untuk kasus ringan.
  • Kekurangan: rawan race jika tanpa owner verification dan fencing.

Lease lock + heartbeat

  • Kelebihan: aman untuk job yang bisa lama dan worker yang bisa crash.
  • Kekurangan: lebih kompleks, perlu observability dan penanganan lost lease.

Fencing token

  • Kelebihan: melindungi dari stale writer, sangat penting pada sistem terdistribusi.
  • Kekurangan: write path harus mendukung validasi token, tidak cukup hanya di sisi lock.

Dedup dan idempotency

  • Kelebihan: mengurangi dampak duplicate delivery dan duplicate enqueue.
  • Kekurangan: butuh desain key yang benar dan kebijakan TTL yang sesuai konteks bisnis.

Tidak ada satu mekanisme yang menyelesaikan semua masalah. Sistem yang stabil biasanya memakai kombinasi beberapa lapis proteksi.

Checklist Observability yang Wajib Ada

Tanpa observability, masalah duplicate processing sering terlihat seperti bug acak. Minimal, pantau hal berikut:

Metric

  • Jumlah job queued, processing, done, failed.
  • Durasi pemrosesan job per tipe.
  • Jumlah lease acquired, renew failed, dan lost lease.
  • Jumlah retry per tipe error.
  • Jumlah job masuk dead letter queue.
  • Jumlah duplicate enqueue yang ditolak oleh dedup.
  • Jumlah stale writer yang ditolak oleh fencing token.

Log terstruktur

  • job_id
  • worker_id
  • attempt
  • lease_owner
  • fencing_token
  • event: acquired, renewed, lost, retry_scheduled, done, failed

Tracing

Jika memungkinkan, hubungkan trace dari request API enqueue ke worker execution. Ini memudahkan analisis apakah duplikasi berasal dari client retry, API retry, atau worker reclaim.

Langkah Debugging Saat Insiden Terjadi

Saat ada laporan job ganda atau status kacau, lakukan langkah berikut secara berurutan:

  1. Identifikasi job_id yang bermasalah dan tarik seluruh log berdasarkan job itu.
  2. Cek metadata job: status terakhir, attempt, worker_id, updated_at, fencing_token.
  3. Periksa key lock Redis: apakah masih ada, TTL tersisa berapa, owner siapa.
  4. Lihat histori worker: apakah ada restart pod, OOM, deploy, atau lonjakan latency.
  5. Cek renew heartbeat: apakah ada jeda lebih panjang dari TTL.
  6. Bandingkan token: apakah worker dengan token lama masih menulis setelah token baru aktif.
  7. Periksa retry schedule: apakah job gagal dianggap sementara padahal permanen.
  8. Verifikasi idempotency/dedup: apakah request sama masuk berkali-kali dari upstream.

Jika Anda menemukan lock hilang lebih cepat dari durasi proses normal, solusi pertama biasanya bukan memperbesar TTL secara ekstrem, tetapi menambahkan heartbeat dan memperbaiki ukuran visibility timeout. TTL yang terlalu panjang hanya memperlambat recovery saat worker benar-benar mati.

Tip debugging: simpan timestamp heartbeat terakhir pada metadata job. Ini sangat membantu membedakan apakah worker benar-benar aktif atau hanya meninggalkan status processing.

Kesalahan Umum yang Perlu Dihindari

  • Menghapus lock tanpa memeriksa owner.
  • Mengandalkan TTL lock tanpa heartbeat untuk job yang durasinya variatif.
  • Menganggap Redis lock saja cukup tanpa idempotency di handler.
  • Menyimpan status done sebelum side effect final berhasil.
  • Retry semua error tanpa klasifikasi dan tanpa batas attempt.
  • Tidak punya jalur recovery untuk job in-flight saat worker crash.
  • Tidak mencatat worker_id dan fencing_token di metadata.

Rekomendasi Implementasi yang Bisa Langsung Dipakai

Jika Anda ingin pendekatan praktis yang cukup aman untuk banyak sistem backend Go berbasis queue, gunakan baseline berikut:

  1. Enqueue dengan dedup key untuk mencegah job identik masuk berulang.
  2. Gunakan idempotency key untuk operasi yang memicu side effect penting.
  3. Reserve job dengan visibility timeout.
  4. Acquire lease lock memakai TTL dan owner token unik.
  5. Start heartbeat untuk renew lease selama job diproses.
  6. Ambil fencing token yang meningkat monoton saat lock berhasil diambil.
  7. Terapkan validasi token saat update status atau write ke state bersama.
  8. Simpan attempt dan gunakan backoff retry dengan dead letter queue.
  9. Pastikan recovery untuk worker crash dan in-flight timeout.
  10. Lengkapi observability dengan metric, log terstruktur, dan tracing.

Dengan kombinasi ini, Anda tidak menghilangkan duplicate delivery sepenuhnya, tetapi Anda mengendalikan dampaknya, mencegah stale writer menimpa state baru, dan membuat worker lebih mudah dipulihkan saat insiden produksi terjadi.

Penutup

Masalah Redis lock stale dan job duplikat pada worker Go Fiber umumnya bukan bug tunggal, melainkan hasil dari beberapa celah kecil: lock tanpa owner verification, TTL tanpa heartbeat, tidak ada fencing token, handler tidak idempotent, dan retry tanpa kendali. Menambal satu bagian saja sering tidak cukup.

Pendekatan yang lebih stabil adalah memakai lease lock dengan TTL, renew heartbeat, fencing token, idempotency key, dedup job, serta visibility timeout dan backoff retry. Jika semua lapisan ini diterapkan dengan observability yang baik, worker berbasis queue akan jauh lebih tahan terhadap crash, restart, race condition, dan lonjakan beban.