Menjaga worker Go Fiber konsisten dengan cache dan lock Redis

Untuk queue worker berbasis Go Fiber yang bergantung pada Redis, konsistensi berarti tidak ada job tertangani dua kali dan tidak ada pemblokiran terus-menerus akibat lock yang tidak dilepas. Pendekatan yang tepat mengombinasikan cache read-through/write-through dan lock dengan lease memastikan cache akurat serta worker tidak saling bersenggolan saat mengambil job.

Pada bagian ini langsung dibahas bagaimana arsitektur cache dan teknik lock mendukung sistem queue worker yang handal.

Arsitektur cache read-through/write-through

Gunakan read-through agar worker tidak langsung mengakses sumber utama ketika cache kosong dan write-through agar setiap perubahan data terpropagasi ke cache. Arsitektur praktisnya:

  • Cache read-through: Worker mencoba baca job metadata dari Redis. Kalau tidak ada, job dimuat dari sumber utama lalu disimpan ke Redis secara atomic.
  • Cache write-through: Saat job berubah status (misalnya dari pending ke processing), update ditulis bersamaan ke Redis dan ke persistent store agar dua sumber tetap sinkron.

Penggunaan per-key TTL membantu memastikan cache tidak menyimpan state usang; TTL harus sedikit lebih panjang dari durasi job biasa agar tidak menyebabkan cache miss terlalu sering.

Contoh operasi read-through:

func getJob(ctx context.Context, id string) (*Job, error) {
    if data, err := redisClient.Get(ctx, jobKey(id)).Result(); err == nil {
        return decodeJob(data)
    }
    job, err := loadJobFromStore(id)
    if err != nil {
        return nil, err
    }
    redisClient.Set(ctx, jobKey(id), encodeJob(job), jobTTL)
    return job, nil
}

Penting menjaga bahwa write-through memastikan perubahan status job langsung konsisten di Redis sehingga worker berikutnya melihat status terbaru.

Strategi lock Redis: ringan vs lease

Ketika worker mengambil job, ia perlu mengunci job tersebut agar hanya satu worker mengerjakan. Ada dua strategi utama:

Lock ringan (single SETNX)

Lock ringan cukup untuk job pendek. Worker mencoba SETNX(lockKey, workerId) dan menggunakan TTL pendek. Kalau gagal berarti job sedang diproses. Kelemahan: jika worker mati sebelum memperbarui TTL, job tertahan sampai TTL usai.

Lock lease (renewal/heartbeat)

Untuk job durasi lama, buat lease yang dapat diperpanjang. Worker memegang lock dengan TTL yang relatif singkat dan secara berkala memperbarui TTL saat masih aktif. Jika worker crash, TTL habis dan worker lain dapat mengambil alih. Implementasi lepas lease:

  • Worker menyimpan workerId dan expiry saat mengunci.
  • Setiap interval lebih pendek dari TTL, worker memperpanjang TTL (misal menggunakan Lua script untuk validasi bahwa workerId masih pemegang lock).
  • Jika gagal memperpanjang, worker harus berhenti mengerjakan job tersebut dan membiarkan job diambil worker lain.

Trade-off: lock ringan lebih sederhana tapi rentan terhadap stale lock; lease lebih kompleks namun lebih toleran terhadap job lama dan worker restart.

Pola retry dengan deduplikasi job

Untuk menghindari job duplikat saat retry karena timeout atau crash, gunakan deduplikasi berdasarkan ID job:

  1. Simpan status job di Redis (misalnya status:{id}).
  2. Sebelum memasukkan job ke queue, cek apakah status sudah completed atau locked.
  3. Ketika worker gagal menyelesaikan job, update status menjadi failed lalu schedule retry, tapi hanya jika job belum melewati batas percobaan.
  4. Pada retry, pastikan job hanya masuk queue sekali walau ada beberapa event trigger.

Gunakan dedupe set di Redis (misal SADD in-progress) atau keyed queue yang menolak duplikat agar queue tidak penuh oleh job yang sama.

Contoh handler Go Fiber dengan lock dan deduplikasi

Berikut pseudo kode handler Go Fiber yang menunjukkan proses mengambil job, mengunci, memproses, lalu memperbarui cache:

func workerHandler(c *fiber.Ctx) error {
    ctx := c.Context()
    jobID := c.Query("job_id")

    job, err := getJob(ctx, jobID)
    if err != nil {
        return fiber.ErrNotFound
    }

    locked, err := acquireLock(ctx, jobID)
    if err != nil || !locked {
        return c.Status(fiber.StatusConflict).SendString("job locked")
    }
    defer releaseLock(ctx, jobID)

    if job.Status == "completed" {
        return c.SendStatus(fiber.StatusOK)
    }

    job.Status = "processing"
    writeThroughUpdate(ctx, job)

    if err := processJob(job); err != nil {
        job.Status = "failed"
        job.Attempt++
        writeThroughUpdate(ctx, job)
        scheduleRetry(job)
        return fiber.ErrInternalServerError
    }

    job.Status = "completed"
    writeThroughUpdate(ctx, job)
    return c.SendStatus(fiber.StatusOK)
}

Fungsi acquireLock harus mengimplementasikan lease atau TTL agar worker lain tidak menggandakan pemrosesan, dan writeThroughUpdate memastikan cache dan persistent storage selaras.

Monitoring, observability, dan recovery

Metric yang penting untuk deteksi masalah:

  • Queue depth: menandakan backlog dan men-trigger scale up worker.
  • Lock TTL dan renewal failures: alert ketika renewal gagal agar segera periksa worker yang hilang.
  • Job duplication rate: menghitung seberapa sering job selesai lebih dari sekali.
  • Retry count per job: mencegah looping retry tak berujung.

Gunakan tracer untuk melacak latensi job dan correlasikan dengan log lock acquisition/release. Jika lock stale tercatat (misalnya TTL dibikin habis tapi tidak dilepas), recovery bisa berupa:

  1. Script otomatis memeriksa lock TTL dan melepas jika proses pemilik tidak aktif.
  2. Manual override lewat dashboard untuk job kritikal.

Untuk job duplikat, pantau status job, periksa ID job dalam message queue, dan tambahkan deduplikasi di produser serta konsumer.

Kesimpulan

Dengan arsitektur cache read-through/write-through, lock dengan lease, pola deduplikasi job, serta observability yang tepat, tim backend Go Fiber dapat menjaga queue worker tetap konsisten walau terjadi gangguan. Terapkan retry terkontrol, monitor lock dan job metrics, serta sediakan recovery path untuk lock stale agar sistem selalu responsif.