Pada sistem berbasis queue, asumsi exactly-once processing hampir selalu terlalu optimistis. Dalam praktik, job bisa terkirim ulang karena retry otomatis, koneksi putus lalu reconnect, worker crash setelah menulis ke database tetapi sebelum mengirim ack, atau timeout yang membuat publisher mengira job gagal padahal sedang diproses. Akibatnya, efek samping bisa terjadi lebih dari sekali: email terkirim ganda, stok berkurang dua kali, invoice dibayar ulang, atau callback partner diproses berulang.

Solusi yang biasanya lebih realistis adalah idempotensi worker: jika job yang sama masuk dua kali, sistem hanya menjalankan efek samping yang seharusnya sekali. Di artikel ini kita fokus pada implementasi praktis di ekosistem Go Fiber dengan Redis sebagai penyimpan state deduplikasi. Fiber di sini berperan sebagai aplikasi Go utama; pola ini berlaku untuk worker yang berjalan di proses terpisah maupun di service yang sama.

Mengapa worker perlu idempotensi

Queue modern umumnya memberi jaminan at-least-once delivery, bukan exactly once. Artinya pesan bisa diterima minimal satu kali, dan dalam kondisi tertentu bisa lebih dari satu kali. Ini bukan bug; ini konsekuensi desain agar pesan tidak hilang.

Beberapa gejala operasional yang sering muncul:

  • Email ganda karena worker retry setelah timeout ke SMTP/provider.
  • Stok terpotong dua kali karena consumer crash setelah update database tetapi sebelum ack ke queue.
  • Callback diproses berulang karena partner mengirim ulang request saat tidak menerima respons tepat waktu.
  • Status order lompat-lompat karena dua worker memproses event yang identik secara bersamaan.

Tanpa idempotensi, retry yang seharusnya membantu reliabilitas justru menciptakan efek samping yang sulit dibersihkan.

Prinsip desain idempotensi worker dengan Redis

Inti polanya sederhana: setiap job yang memiliki efek samping harus punya idempotency key yang stabil. Worker akan mengecek state key tersebut di Redis sebelum mengeksekusi logika bisnis. State minimal yang umum dipakai:

  • processing: job sedang dikerjakan oleh worker.
  • done: job sudah berhasil diproses.
  • failed: job selesai gagal, biasanya dipakai untuk observability atau kebijakan retry tertentu.

Redis dipilih karena operasi baca/tulis cepat, mendukung TTL, dan cocok untuk state deduplikasi yang sifatnya sementara. Namun Redis tidak otomatis membuat sistem idempoten; desain key dan transisinya harus benar.

Idempotency key yang baik

Jangan membuat key dari nilai yang berubah setiap retry, misalnya timestamp konsumsi atau UUID baru saat worker menerima job. Key harus mewakili identitas operasi bisnis.

Contoh yang lebih baik:

  • payment:{order_id}:capture
  • email:{template}:{user_id}:{event_id}
  • stock:{order_id}:reserve
  • callback:{source}:{external_event_id}

Jika payload memiliki event ID dari publisher, gunakan itu. Jika tidak ada, bentuk key dari kombinasi field bisnis yang memang unik untuk operasi tersebut.

Catatan: Jika desain key salah, idempotensi bisa gagal ke dua arah: duplikasi tidak tertahan, atau job yang sebenarnya berbeda malah dianggap sama.

Struktur key Redis

Struktur yang mudah di-debug biasanya lebih baik daripada yang terlalu padat. Misalnya:

idem:worker:email:evt_12345 -> JSON/status metadata
idem:worker:stock:order_9001 -> JSON/status metadata
idem:result:callback:abc123 -> cached response/result (opsional)

Untuk value, Anda bisa menyimpan string sederhana atau JSON. JSON lebih fleksibel untuk observability:

{
  "status": "processing",
  "started_at": "2026-06-18T10:00:00Z",
  "worker_id": "worker-3",
  "attempt": 2
}

Setelah sukses:

{
  "status": "done",
  "started_at": "2026-06-18T10:00:00Z",
  "finished_at": "2026-06-18T10:00:03Z",
  "worker_id": "worker-3"
}

Atomic check-set: cegah dua worker memproses job yang sama

Kesalahan paling umum adalah pola berikut:

  1. GET key dari Redis
  2. Jika tidak ada, lanjut proses
  3. SET key ke processing

Pola ini rentan race condition. Dua worker bisa sama-sama membaca key belum ada, lalu keduanya memproses job yang sama.

Yang dibutuhkan adalah operasi atomik: set status awal hanya jika key belum ada. Di Redis, pola dasarnya adalah SET key value NX EX ttl. Dengan begitu, hanya satu worker yang berhasil menandai job sebagai processing.

Alur worker yang aman secara dasar

  1. Ambil job dari queue.
  2. Bentuk idempotency key dari payload.
  3. Lakukan SET NX untuk membuat key status processing dengan TTL.
  4. Jika gagal karena key sudah ada, cek statusnya:
    • Jika done, anggap duplikat dan skip/ack.
    • Jika processing, kemungkinan sedang dikerjakan worker lain; skip, requeue, atau tunggu sesuai kebutuhan.
    • Jika failed, tentukan apakah ingin retry dengan kebijakan khusus.
  5. Jika berhasil mendapat lock/status processing, jalankan logika bisnis.
  6. Jika sukses, ubah status menjadi done dan atur TTL retensi.
  7. Jika gagal, ubah status menjadi failed atau hapus key agar retry berikutnya bisa mengambil ulang, tergantung jenis kegagalan.

Contoh implementasi sederhana di Go

Contoh berikut sengaja dibuat generik dan ringkas. Ia tidak bergantung pada driver queue tertentu, sehingga fokus tetap pada pola idempotensi.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

type Job struct {
    EventID string
    UserID  string
    Email   string
    Type    string
}

type IdemState struct {
    Status     string    `json:"status"`
    StartedAt  time.Time `json:"started_at,omitempty"`
    FinishedAt time.Time `json:"finished_at,omitempty"`
    WorkerID   string    `json:"worker_id,omitempty"`
    Attempt    int       `json:"attempt,omitempty"`
    Error      string    `json:"error,omitempty"`
}

var (
    ctx = context.Background()
    rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
)

func idemKey(job Job) string {
    return fmt.Sprintf("idem:worker:email:%s", job.EventID)
}

func setProcessing(key, workerID string, ttl time.Duration) (bool, error) {
    state := IdemState{
        Status:    "processing",
        StartedAt: time.Now().UTC(),
        WorkerID:  workerID,
        Attempt:   1,
    }
    b, err := json.Marshal(state)
    if err != nil {
        return false, err
    }
    return rdb.SetNX(ctx, key, b, ttl).Result()
}

func getState(key string) (*IdemState, error) {
    s, err := rdb.Get(ctx, key).Result()
    if err != nil {
        if errors.Is(err, redis.Nil) {
            return nil, nil
        }
        return nil, err
    }
    var st IdemState
    if err := json.Unmarshal([]byte(s), &st); err != nil {
        return nil, err
    }
    return &st, nil
}

func setFinalState(key string, st IdemState, ttl time.Duration) error {
    b, err := json.Marshal(st)
    if err != nil {
        return err
    }
    return rdb.Set(ctx, key, b, ttl).Err()
}

func sendEmail(job Job) error {
    // Ganti dengan pemanggilan provider email Anda.
    // Fungsi ini harus dianggap punya efek samping.
    fmt.Println("send email to", job.Email, "for event", job.EventID)
    return nil
}

func processJob(job Job, workerID string) error {
    key := idemKey(job)

    locked, err := setProcessing(key, workerID, 5*time.Minute)
    if err != nil {
        return err
    }

    if !locked {
        st, err := getState(key)
        if err != nil {
            return err
        }
        if st == nil {
            return fmt.Errorf("state disappeared, safe to retry")
        }
        switch st.Status {
        case "done":
            // Duplikat yang aman untuk diabaikan
            return nil
        case "processing":
            // Sedang diproses worker lain
            return fmt.Errorf("job already processing")
        case "failed":
            return fmt.Errorf("job marked failed, apply retry policy")
        default:
            return fmt.Errorf("unknown state: %s", st.Status)
        }
    }

    err = sendEmail(job)
    if err != nil {
        failState := IdemState{
            Status:     "failed",
            StartedAt:  time.Now().UTC(),
            FinishedAt: time.Now().UTC(),
            WorkerID:   workerID,
            Error:      err.Error(),
        }
        _ = setFinalState(key, failState, 30*time.Minute)
        return err
    }

    doneState := IdemState{
        Status:     "done",
        StartedAt:  time.Now().UTC(),
        FinishedAt: time.Now().UTC(),
        WorkerID:   workerID,
    }
    return setFinalState(key, doneState, 24*time.Hour)
}

func main() {
    job := Job{
        EventID: "evt_12345",
        UserID:  "u_9",
        Email:   "[email protected]",
        Type:    "welcome_email",
    }
    if err := processJob(job, "worker-1"); err != nil {
        fmt.Println("process error:", err)
    }
}

Poin penting dari contoh di atas:

  • SetNX dipakai untuk mencegah dua worker masuk bersamaan.
  • Status processing diberi TTL agar tidak menggantung selamanya jika worker crash.
  • Status akhir done disimpan lebih lama untuk menahan duplikasi retry yang datang belakangan.
  • Status failed tidak selalu final; kebijakan retry tetap harus Anda tentukan.

Kapan perlu menyimpan hasil, bukan hanya status

Untuk sebagian job, cukup menyimpan bahwa pekerjaan sudah done. Misalnya email welcome: jika event sama masuk lagi, cukup skip.

Tetapi ada kasus di mana retry duplikat sebaiknya menerima hasil yang sama, bukan sekadar diabaikan. Contoh:

  • Pembuatan invoice yang menghasilkan invoice_id
  • Permintaan callback yang menghasilkan payload respons tertentu
  • Pembuatan token atau link yang harus konsisten antar retry

Pada kasus seperti ini, simpan hasil ringkas di Redis atau database:

idem:result:payment:ord_1001 -> {
  "charge_id": "ch_789",
  "status": "captured"
}

Dengan begitu, jika job yang sama datang lagi, worker atau API layer bisa mengembalikan hasil lama secara konsisten.

Aturan praktis: simpan result jika caller atau alur berikutnya membutuhkan identitas hasil yang sama. Jika hanya perlu mencegah efek samping ganda, status done sering sudah cukup.

TTL, lock singkat, dan kebijakan transisi state

TTL untuk status processing

TTL processing harus lebih panjang dari durasi normal job, tetapi tidak terlalu panjang sehingga key menggantung lama saat worker mati. Jika rata-rata job selesai dalam hitungan detik, TTL beberapa menit biasanya lebih aman daripada terlalu ketat.

Jika job bisa berjalan lebih lama dari TTL, ada dua opsi:

  • Perpanjang TTL secara berkala selama job masih aktif.
  • Gunakan TTL konservatif yang cukup menutup durasi terburuk yang masih wajar.

Masalah jika TTL terlalu pendek: worker A masih memproses, TTL habis, worker B mengunci ulang dan efek samping dijalankan dua kali.

TTL untuk status done

Status done adalah jendela deduplikasi. Tentukan berdasarkan seberapa lama duplikasi bisa muncul:

  • Untuk retry queue internal, TTL beberapa jam sampai beberapa hari sering cukup.
  • Untuk callback dari sistem eksternal yang terkenal agresif mengulang, TTL bisa perlu lebih panjang.
  • Untuk operasi finansial penting, kadang deduplikasi perlu jejak yang lebih permanen di database.

Apa yang dilakukan saat gagal

Tidak semua kegagalan sebaiknya ditangani sama:

  • Gagal sementara seperti timeout jaringan: Anda bisa simpan failed sebentar untuk observability lalu izinkan retry berikutnya mengambil ulang.
  • Gagal permanen seperti payload invalid: lebih baik tandai final dan hentikan retry buta.
  • Tidak yakin hasil side effect sudah terjadi atau belum: ini kasus paling berbahaya. Misalnya timeout saat memanggil provider pembayaran. Jika provider mendukung idempotency key juga, teruskan key yang sama ke provider agar sistem hilir ikut aman.

Skenario race condition yang perlu dipahami

Dua worker menerima job yang sama bersamaan

Tanpa operasi atomik, keduanya bisa lolos. Dengan SET NX, hanya satu yang berhasil menjadi pemilik state processing.

Worker crash setelah side effect sukses, sebelum set done

Ini skenario klasik. Misalnya email sudah terkirim, tetapi proses mati sebelum menulis status done. Setelah TTL habis, job bisa diproses ulang dan email terkirim lagi.

Idempotensi di level worker mengurangi risiko, tetapi tidak selalu cukup untuk semua side effect. Solusi yang lebih kuat:

  • Gunakan idempotency key end-to-end ke layanan downstream jika didukung.
  • Untuk operasi penting, simpan fakta bisnis di database yang menjadi sumber kebenaran, lalu cek fakta itu sebelum mengeksekusi side effect lagi.
  • Gunakan pola outbox jika butuh sinkronisasi lebih andal antara perubahan database dan pengiriman event.

TTL processing habis padahal job masih berjalan

Jika tidak ada mekanisme perpanjangan lock, worker lain bisa masuk. Untuk job yang lama, pertimbangkan heartbeat sederhana yang memperpanjang TTL selama worker masih hidup.

Overwrite state antar worker

Jika semua worker bebas mengubah key final tanpa validasi, worker yang terlambat bisa menimpa state yang lebih baru. Untuk alur yang lebih ketat, gunakan script Lua atau transaksi Redis agar transisi state divalidasi, misalnya hanya pemilik lock yang boleh mengubah processing menjadi done.

Perlukah lock terpisah dari status?

Untuk banyak kasus, satu key status sudah cukup: key dibuat pertama kali sebagai processing, lalu diperbarui menjadi done atau failed. Namun pada sistem yang lebih kompleks, Anda mungkin memisahkan:

  • lock key berumur sangat pendek untuk mutual exclusion
  • status key berumur lebih panjang untuk jejak deduplikasi

Pemisahan ini berguna jika Anda ingin lock cepat kedaluwarsa tetapi tetap menyimpan hasil akhir lebih lama. Konsekuensinya, alur menjadi lebih rumit dan harus hati-hati agar status dan lock tidak saling bertentangan.

Integrasi dengan Go Fiber

Walau topik ini fokus ke worker, pola yang sama sering dimulai dari API yang menerima request lalu menerbitkan job. Di aplikasi Go Fiber, Anda bisa melakukan dua hal:

  • Meneruskan idempotency key dari header atau payload request ke message queue.
  • Menghasilkan key di API layer berdasarkan identitas bisnis, lalu menyertakannya dalam job.

Contoh sederhana di handler Fiber:

app.Post("/callbacks/payment", func(c *fiber.Ctx) error {
    type Payload struct {
        EventID string `json:"event_id"`
        OrderID string `json:"order_id"`
    }

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

    idemKey := fmt.Sprintf("callback:payment:%s", p.EventID)

    // publish ke queue bersama idemKey
    // payload queue sebaiknya memuat idemKey atau field yang bisa membentuknya ulang

    return c.JSON(fiber.Map{
        "queued": true,
        "idempotency_key": idemKey,
    })
})

Tujuannya bukan membuat Fiber memproses duplikasi di layer HTTP saja, tetapi menjaga agar identitas operasi tetap konsisten sampai worker.

Observability: metrik, log, dan alert yang berguna

Idempotensi yang baik harus mudah diobservasi. Jika tidak, Anda hanya memindahkan masalah dari duplikasi ke kebingungan operasional.

Metrik yang layak dikumpulkan

  • Jumlah job yang berhasil mengambil lock
  • Jumlah job yang terdeteksi duplikat done
  • Jumlah job yang bertabrakan dengan processing
  • Jumlah transisi ke failed
  • Lama proses per jenis job
  • Jumlah key processing yang kedaluwarsa tanpa final state

Lonjakan duplicate-hit sering menandakan retry storm, bug publisher, atau downstream lambat.

Structured logging

Pastikan setiap log memuat setidaknya:

  • idempotency_key
  • job_type
  • worker_id
  • attempt
  • state_before dan state_after jika relevan

Dengan ini, investigasi email ganda atau stok ganda jauh lebih cepat.

Alert yang berguna

  • Terlalu banyak key processing yang menua
  • Rasio duplicate-hit naik tajam
  • Retry meningkat untuk satu jenis job tertentu
  • Redis latency/error meningkat sehingga deduplikasi tidak andal

Cleanup dan retensi data Redis

TTL adalah mekanisme cleanup utama. Jika semua key diberi TTL yang tepat, Anda biasanya tidak butuh cron cleanup agresif. Namun ada beberapa hal yang tetap perlu dipikirkan:

  • Retensi done jangan terlalu lama hingga memakan memori tanpa manfaat.
  • Retensi failed cukup untuk investigasi, lalu biarkan expired.
  • Payload/result besar sebaiknya jangan disimpan penuh di Redis jika hanya sebagian kecil yang diperlukan untuk deduplikasi.

Jika Anda menyimpan hasil, pertimbangkan hanya menyimpan identifier dan metadata ringkas, bukan seluruh objek besar.

Kesalahan implementasi yang sering terjadi

  • Menggunakan key yang berubah setiap retry sehingga deduplikasi tidak pernah cocok.
  • Memakai GET lalu SET terpisah, bukan operasi atomik.
  • Tidak memberi TTL pada state processing, sehingga key menggantung selamanya saat worker crash.
  • TTL terlalu pendek untuk job panjang, sehingga worker kedua masuk saat worker pertama belum selesai.
  • Menganggap status done cukup untuk semua kasus, padahal beberapa alur perlu menyimpan hasil final.
  • Tidak meneruskan idempotency key ke downstream yang juga bisa melakukan side effect.
  • Menyimpan terlalu banyak data di Redis sehingga memori membengkak.

Redis vs database untuk deduplikasi

Kapan Redis cocok

  • Butuh lookup sangat cepat
  • State deduplikasi bersifat sementara dengan TTL alami
  • Trafik retry/duplikasi cukup sering
  • Tidak semua hasil perlu disimpan permanen

Kapan database lebih cocok

  • Operasi bisnis butuh jejak permanen dan audit kuat
  • Anda perlu konsistensi yang lebih dekat dengan data utama
  • Hasil deduplikasi harus bertahan lama
  • Efek samping sangat sensitif, misalnya pembayaran atau perubahan ledger

Trade-off utamanya:

  • Redis: cepat, sederhana untuk TTL, tetapi state umumnya lebih sementara dan perlu desain hati-hati agar aman saat crash dan expiry.
  • Database: lebih kuat untuk sumber kebenaran dan audit, tetapi bisa menambah latency, kontensi, dan kompleksitas skema/index.

Dalam banyak sistem produksi, kombinasi keduanya justru paling masuk akal: Redis untuk deduplikasi cepat di worker, database untuk fakta bisnis final.

Checklist implementasi produksi

  1. Tentukan idempotency key yang stabil dan berbasis identitas bisnis.
  2. Gunakan operasi Redis yang atomik untuk claim awal, jangan GET lalu SET terpisah.
  3. Simpan state minimal: processing, done, dan bila perlu failed.
  4. Atur TTL processing agar cukup panjang tetapi tidak menggantung lama.
  5. Atur TTL done sesuai jendela retry/duplikasi yang realistis.
  6. Untuk job berdurasi lama, siapkan heartbeat/perpanjangan TTL.
  7. Tentukan kapan hasil final perlu disimpan, bukan hanya status.
  8. Teruskan idempotency key ke downstream service jika mereka juga mendukung deduplikasi.
  9. Buat metrik, log terstruktur, dan alert untuk duplicate-hit, processing stale, serta failed transitions.
  10. Dokumentasikan kebijakan saat failed: retry, dead-letter, atau final reject.
  11. Uji skenario crash, timeout, retry paralel, dan expiry lock di lingkungan staging.

Jika Anda membangun worker di Go Fiber dan mengandalkan queue yang bisa mengirim ulang job, idempotensi bukan fitur tambahan, melainkan lapisan proteksi inti. Redis memberi cara yang praktis untuk menahan efek retry, selama Anda merancang key dengan benar, memakai operasi atomik, menetapkan TTL yang masuk akal, dan tetap sadar bahwa beberapa operasi penting mungkin tetap membutuhkan sumber kebenaran di database.