Replay Strategy #

Di distributed system, kegagalan bukan kemungkinan — melainkan kepastian yang tinggal menunggu waktu. Network akan timeout, service akan down, consumer akan crash di tengah proses, dan database sesekali akan kelebihan beban. Pertanyaan yang relevan bukan “apakah sistem kita akan gagal?” tapi “ketika gagal, apa yang terjadi pada data dan proses yang sedang berjalan?” Replay Strategy adalah jawaban atas pertanyaan itu: kemampuan sistem untuk memproses ulang event, message, atau request yang gagal — dengan aman, terkontrol, dan tanpa menimbulkan efek samping yang tidak diinginkan. Panduan ini membahas mengapa replay penting, lima jenis replay yang berbeda kegunaannya, tiga tantangan utama yang harus diantisipasi, serta best practice implementasi lengkap dengan kode konkret.

Apa Itu Replay Strategy? #

Replay Strategy adalah pendekatan desain sistem untuk memproses ulang event, message, atau request yang gagal, belum selesai, atau perlu diverifikasi ulang, dengan jaminan bahwa pengulangan tersebut tidak menyebabkan state yang rusak atau efek samping yang tidak dikehendaki.

Replay bisa terjadi di berbagai layer sistem:

Layer               Contoh Replay
──────────────────  ─────────────────────────────────────────
Message Queue       SQS, RabbitMQ retry saat consumer gagal
Event Streaming     Kafka consumer reset offset ke posisi lama
HTTP / API          Retry request yang timeout ke downstream
Background Job      Rerun scheduled job yang crash di tengah
CQRS Command        Replay command yang gagal dieksekusi
Event Sourcing      Rebuild seluruh state dari awal event log

Satu hal yang membedakan Replay Strategy dari sekadar “retry”: replay adalah keputusan arsitektur, bukan hanya beberapa baris kode for i < 3 { retry() }. Ia mencakup bagaimana event disimpan, bagaimana urutan dipertahankan, bagaimana duplikasi ditangani, dan bagaimana operator mengontrol proses replay di production.


Mengapa Replay Strategy Itu Kritis #

Ada tiga alasan fundamental yang membuat replay bukan fitur opsional di sistem modern.

Pertama, kegagalan transient adalah norma, bukan pengecualian. Downstream service yang sedang overload, koneksi database yang sesaat terputus, rate limit dari third-party API — semua ini adalah kegagalan sementara yang seharusnya bisa di-recover secara otomatis. Tanpa replay, setiap kegagalan transient berpotensi menjadi kehilangan data permanen.

Kedua, replay adalah fondasi dari at-least-once delivery. Hampir semua message broker modern — Kafka, SQS, RabbitMQ, Pub/Sub — menjamin at-least-once delivery, bukan exactly-once. Artinya sistem harus siap menerima event yang sama lebih dari sekali, dan harus bisa “mengulang” proses setelah kegagalan. Tanpa replay strategy yang benar, sistem kamu hanya bisa bekerja di kondisi ideal.

Ketiga, replay adalah alat operasional yang sangat berharga. Ini yang sering diabaikan: replay bukan hanya untuk error recovery. Engineer menggunakan replay untuk memproses ulang event setelah bug fix di-deploy, untuk rebuild read model setelah migrasi schema, untuk re-sync data setelah insiden, dan untuk audit ulang transaksi yang dicurigai. Sistem yang tidak mendukung replay membuat semua skenario ini jauh lebih berbahaya dan manual.

Perbedaan mendasar antara sistem yang mature dan yang tidak: sistem yang mature mengasumsikan kegagalan akan terjadi dan mendesain replay sejak awal. Sistem yang belum mature baru menambahkan replay setelah insiden pertama — dan sering kali terlambat karena event-nya sudah tidak tersimpan.

Jenis-Jenis Replay Strategy #

Tidak semua replay sama. Setiap jenis punya use case, kelebihan, dan risikonya sendiri. Memilih jenis yang tepat tergantung pada konteks kegagalan dan karakteristik sistem.

1. Immediate Retry #

Replay langsung dilakukan setelah kegagalan, tanpa delay. Ini adalah jenis yang paling sederhana dan cocok untuk kegagalan yang benar-benar transient dan singkat.

// ANTI-PATTERN: retry tanpa batas dan tanpa delay
func processEvent(event Event) error {
    for {
        err := handler.Process(event)
        if err == nil {
            return nil
        }
        // ← jika downstream down, ini akan membombardir terus-menerus
    }
}

// BENAR: retry dengan batas maksimum yang jelas
func processWithRetry(event Event, maxAttempts int) error {
    var lastErr error
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        lastErr = handler.Process(event)
        if lastErr == nil {
            return nil
        }
        log.Warnf("attempt %d/%d failed for event %s: %v",
            attempt, maxAttempts, event.ID, lastErr)
    }
    return fmt.Errorf("all %d attempts failed: %w", maxAttempts, lastErr)
}

Immediate retry hanya cocok untuk kegagalan yang sembuh dalam milidetik — koneksi TCP yang sesaat putus, lock contention yang segera terlepas. Untuk kegagalan yang membutuhkan waktu untuk pulih, dibutuhkan strategi yang lebih sabar.

2. Delayed Retry dengan Exponential Backoff #

Replay dilakukan setelah delay yang semakin lama setiap kali gagal. Ini memberikan downstream service waktu untuk recover tanpa dibombardir oleh retry storm.

// BENAR: exponential backoff dengan jitter
func processWithBackoff(ctx context.Context, event Event) error {
    baseDelay := 1 * time.Second
    maxDelay := 5 * time.Minute
    maxAttempts := 8

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err := handler.Process(event)
        if err == nil {
            return nil
        }

        if attempt == maxAttempts {
            return fmt.Errorf("exhausted %d attempts: %w", maxAttempts, err)
        }

        // Hitung delay: 1s, 2s, 4s, 8s, 16s, 32s, 60s...
        delay := baseDelay * time.Duration(1<<(attempt-1))
        if delay > maxDelay {
            delay = maxDelay
        }

        // Tambahkan jitter: ±25% dari delay agar tidak semua consumer
        // retry pada waktu yang persis sama (thundering herd)
        jitter := time.Duration(rand.Int63n(int64(delay / 4)))
        if rand.Intn(2) == 0 {
            delay += jitter
        } else {
            delay -= jitter
        }

        log.Infof("retry attempt %d in %v for event %s", attempt+1, delay, event.ID)

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return nil
}

3. Dead Letter Queue (DLQ) #

Ketika event sudah melebihi batas retry dan tetap gagal, ia dipindahkan ke Dead Letter Queue — antrian terpisah untuk event-event bermasalah. DLQ adalah safety net yang memastikan event tidak hilang meski tidak bisa diproses saat ini.

Flow normal dengan DLQ:

Event masuk Queue
    │
    ▼
Consumer proses ─── berhasil ──→ selesai ✓
    │
    └─ gagal → retry 1 → retry 2 → retry 3
                                        │
                                        └─ masih gagal
                                               │
                                               ▼
                                         Dead Letter Queue
                                               │
                                    ┌──────────┴──────────┐
                                    │                     │
                                    ▼                     ▼
                              Alert/monitor         Manual review
                                    │                     │
                                    └──────────┬──────────┘
                                               │
                                               ▼
                                    Replay setelah root cause fix
// BENAR: kirim ke DLQ setelah retry exhausted, jangan buang event
func (c *Consumer) handleWithDLQ(ctx context.Context, msg Message) error {
    err := c.processWithBackoff(ctx, msg)
    if err == nil {
        return nil
    }

    // Enrich dengan metadata untuk debugging
    dlqMsg := DLQMessage{
        OriginalMessage: msg,
        FailureReason:   err.Error(),
        AttemptCount:    msg.ApproximateReceiveCount,
        FailedAt:        time.Now(),
        ConsumerVersion: c.version,
    }

    if dlqErr := c.dlqQueue.Send(ctx, dlqMsg); dlqErr != nil {
        // DLQ send gagal — log dengan level CRITICAL, butuh perhatian segera
        log.Criticalf("FAILED to send to DLQ, event %s will be lost: %v", msg.ID, dlqErr)
        return dlqErr
    }

    log.Warnf("event %s sent to DLQ after %d attempts: %v",
        msg.ID, msg.ApproximateReceiveCount, err)
    return nil // ✓ ack message utama agar tidak looping
}

4. Manual Replay dari Event Store #

Event disimpan secara durable dan bisa diputar ulang secara selektif kapan saja — berdasarkan timestamp, event ID, topic/partition, atau kriteria bisnis tertentu.

// BENAR: replay events dari DLQ atau event store dengan throttling
func (r *ReplayService) ReplayFromDLQ(ctx context.Context, opts ReplayOptions) error {
    log.Infof("starting replay: from=%v to=%v filter=%s",
        opts.From, opts.To, opts.Filter)

    events, err := r.dlqStore.Query(ctx, opts.From, opts.To, opts.Filter)
    if err != nil {
        return fmt.Errorf("query DLQ: %w", err)
    }

    log.Infof("found %d events to replay", len(events))

    // Throttle: jangan flood sistem dengan semua event sekaligus
    limiter := rate.NewLimiter(rate.Limit(opts.RatePerSecond), 1)
    var succeeded, failed int

    for _, event := range events {
        if err := limiter.Wait(ctx); err != nil {
            return err // context cancelled
        }

        if err := r.processor.Process(ctx, event); err != nil {
            log.Errorf("replay failed for event %s: %v", event.ID, err)
            failed++
            continue
        }
        succeeded++
    }

    log.Infof("replay complete: succeeded=%d failed=%d", succeeded, failed)
    return nil
}

5. Event Sourcing Replay #

Dalam arsitektur event sourcing, event log adalah source of truth — state aplikasi bukan disimpan langsung, melainkan direkonstruksi ulang dari urutan event sejak awal. Replay di sini berarti membangun ulang projection atau read model dari awal.

// BENAR: rebuild projection dari event log
func (p *ProjectionRebuilder) RebuildOrderProjection(ctx context.Context) error {
    log.Info("truncating existing order projection...")
    if err := p.orderReadDB.Truncate(ctx); err != nil {
        return fmt.Errorf("truncate: %w", err)
    }

    // Stream semua event dari awal, urut berdasarkan sequence number
    events, err := p.eventLog.StreamAll(ctx, "orders", StreamOptions{
        FromOffset: 0,
        BatchSize:  500,
    })
    if err != nil {
        return fmt.Errorf("stream events: %w", err)
    }

    var count int
    for event := range events {
        if err := p.applyEvent(ctx, event); err != nil {
            return fmt.Errorf("apply event %d: %w", event.Sequence, err)
        }
        count++
        if count%1000 == 0 {
            log.Infof("rebuilt %d events...", count)
        }
    }

    log.Infof("projection rebuild complete: %d events applied", count)
    return nil
}

Event sourcing replay sangat powerful untuk skenario seperti migrasi schema, bug fix pada logika kalkulasi historis, atau membangun projection baru untuk fitur analytics tanpa menyentuh data asli.


Tantangan Utama dalam Replay #

Tiga tantangan ini harus diantisipasi saat merancang replay strategy — mengabaikannya adalah sumber bug yang sulit dilacak.

Duplicate Processing #

Replay hampir selalu menghasilkan duplikasi — event yang sama diproses lebih dari sekali. Ini bukan bug dari replay-nya, tapi konsekuensi yang harus ditangani dengan benar.

// ANTI-PATTERN: consumer yang tidak idempotent — replay = bencana
func (c *Consumer) handlePayment(event PaymentRequestedEvent) error {
    // Jika event ini di-replay, payment akan diproses dua kali
    return c.paymentGateway.Charge(event.UserID, event.Amount)
}

// BENAR: consumer idempotent — replay aman dilakukan kapan saja
func (c *Consumer) handlePayment(ctx context.Context, event PaymentRequestedEvent) error {
    // Cek apakah event ini sudah pernah diproses
    processed, err := c.processedEvents.Exists(ctx, event.EventID)
    if err != nil {
        return fmt.Errorf("check processed: %w", err)
    }
    if processed {
        log.Infof("event %s already processed, skipping", event.EventID)
        return nil // ✓ aman di-skip
    }

    // Proses dalam transaction: charge + tandai sebagai processed
    return c.db.Transaction(func(tx *gorm.DB) error {
        if err := c.paymentGateway.Charge(event.UserID, event.Amount); err != nil {
            return err
        }
        return tx.Create(&ProcessedEvent{
            EventID:     event.EventID,
            ProcessedAt: time.Now(),
        }).Error
    })
}

Ordering dan Urutan Event #

Replay bisa mengubah urutan pemrosesan event. Ini kritis untuk event yang saling bergantung — misalnya event OrderCreated harus selalu diproses sebelum OrderCancelled untuk order yang sama.

// BENAR: gunakan partition key agar event untuk entity yang sama
// selalu diproses secara berurutan
type KafkaProducer struct{ client sarama.SyncProducer }

func (p *KafkaProducer) PublishOrderEvent(event OrderEvent) error {
    msg := &sarama.ProducerMessage{
        Topic: "order-events",
        // Partition key = order ID: semua event untuk order yang sama
        // dijamin masuk ke partition yang sama, urutan terjaga
        Key:   sarama.StringEncoder(event.OrderID),
        Value: sarama.ByteEncoder(mustMarshal(event)),
    }
    _, _, err := p.client.SendMessage(msg)
    return err
}

Side Effects yang Tidak Bisa Di-undo #

Ini tantangan paling licin: beberapa side effect tidak idempotent secara alami — mengirim email, memicu webhook, memotong saldo. Replay yang tidak didesain dengan baik akan memicu semua ini berulang kali.

// ANTI-PATTERN: side effect langsung di dalam event handler
func (h *OrderHandler) handleOrderConfirmed(event OrderConfirmedEvent) error {
    h.updateDatabase(event)
    h.emailService.SendConfirmation(event.UserEmail) // ← akan dikirim ulang saat replay
    h.webhookService.Notify(event)                   // ← akan dipanggil ulang saat replay
    return nil
}

// BENAR: pisahkan state mutation dari side effects
// Side effect hanya dipanggil jika state belum pernah diubah
func (h *OrderHandler) handleOrderConfirmed(ctx context.Context, event OrderConfirmedEvent) error {
    // Cek dulu apakah state sudah diupdate
    order, _ := h.orderRepo.FindByID(event.OrderID)
    if order.Status == "CONFIRMED" {
        // State sudah benar — skip semua side effects
        return nil
    }

    // Update state terlebih dahulu
    if err := h.orderRepo.UpdateStatus(event.OrderID, "CONFIRMED"); err != nil {
        return err
    }

    // Side effects hanya dipanggil sekali karena state sudah berubah
    // Replay berikutnya akan masuk ke early return di atas
    h.emailService.SendConfirmation(event.UserEmail)
    h.webhookService.Notify(event)
    return nil
}

Best Practice Implementasi #

Simpan Event Secara Durable #

Replay hanya mungkin dilakukan jika event masih ada. Ini terdengar obvious, tapi banyak sistem yang tidak menyimpan event dengan benar.

Persyaratan minimal untuk event store yang mendukung replay:
  □ Event disimpan di persistent storage (bukan hanya in-memory)
  □ Event tidak dihapus setelah diproses — hanya ditandai sebagai processed
  □ Event memiliki metadata lengkap: ID, timestamp, versi schema, source
  □ Event bisa di-query berdasarkan timestamp, ID, atau kriteria bisnis
  □ Retention policy jelas: berapa lama event disimpan sebelum di-archive

Throttle Replay Massal #

Replay massal tanpa kontrol adalah cara tercepat untuk menjatuhkan production system sendiri.

// ANTI-PATTERN: replay semua DLQ sekaligus tanpa throttle
func replayAll() {
    events := dlq.GetAll() // bisa jutaan event
    for _, e := range events {
        process(e) // membanjiri downstream sekaligus
    }
}

// BENAR: replay dengan rate limiter dan circuit breaker
type ReplayConfig struct {
    RatePerSecond  float64       // berapa event per detik
    BatchSize      int           // berapa event per batch
    PauseBetween   time.Duration // jeda antar batch
    DryRun         bool          // simulasi tanpa eksekusi nyata
}

func replayWithThrottle(ctx context.Context, cfg ReplayConfig) error {
    if cfg.DryRun {
        log.Info("DRY RUN mode — no events will be processed")
    }

    limiter := rate.NewLimiter(rate.Limit(cfg.RatePerSecond), cfg.BatchSize)
    // ... implementasi dengan monitoring progress
}

Observability adalah Wajib #

Replay tanpa visibility adalah operasi buta — kamu tidak tahu apakah berhasil, berapa yang sudah diproses, atau apakah ada yang gagal lagi.

// BENAR: log dan metrics yang lengkap untuk setiap replay operation
type ReplayMetrics struct {
    TotalEvents     int
    Succeeded       int
    Failed          int
    Skipped         int       // sudah diproses sebelumnya (idempotency)
    Duration        time.Duration
    StartedAt       time.Time
    TriggeredBy     string    // siapa yang memicu replay (user, system, alert)
    ReplayReason    string    // mengapa replay dilakukan
}

func (r *ReplayService) logCompletion(m ReplayMetrics) {
    log.Infof("[REPLAY COMPLETE] reason=%s triggered_by=%s "+
        "total=%d succeeded=%d failed=%d skipped=%d duration=%v",
        m.ReplayReason, m.TriggeredBy,
        m.TotalEvents, m.Succeeded, m.Failed, m.Skipped, m.Duration)

    // Kirim ke metrics system
    metrics.Gauge("replay.success_rate",
        float64(m.Succeeded)/float64(m.TotalEvents)*100)
    metrics.Counter("replay.total_events", float64(m.TotalEvents))
}

Kapan Menggunakan Jenis Replay yang Mana #

Situasi                                     Replay yang Tepat
──────────────────────────────────────────  ─────────────────────────────
Kegagalan transient (timeout, brief down)   Immediate retry (≤ 3x)
Downstream perlu waktu recover              Exponential backoff + jitter
Event gagal terus setelah max retry         Dead Letter Queue
Deploy bug fix, perlu proses ulang          Manual replay dari event store
Migrasi schema, rebuild read model          Event Sourcing replay
Rate limit dari third-party API             Delayed retry dengan backoff
Insiden besar, ribuan event gagal           Throttled batch replay dari DLQ
Jangan gunakan immediate retry untuk kegagalan yang butuh waktu lebih dari beberapa detik untuk recover. Jika downstream service sedang down dan semua consumer melakukan immediate retry secara bersamaan, kamu menciptakan retry storm yang memperparah kondisi service yang sudah struggling. Exponential backoff dengan jitter justru memberi waktu recovery.

Anti-Pattern yang Harus Dihindari #

// ✗ Replay tanpa idempotency — menghasilkan double processing
func replayPayments(events []Event) {
    for _, e := range events {
        processPayment(e) // tidak ada pengecekan apakah sudah diproses
    }
}
// ✓ Selalu cek event ID sebelum memproses ulang

// ✗ Tidak menyimpan reason di DLQ — susah debugging
dlq.Send(Message{Body: originalMsg.Body})
// ✓ Sertakan failure reason, attempt count, dan timestamp
dlq.Send(DLQMessage{
    Body:          originalMsg.Body,
    FailureReason: err.Error(),
    AttemptCount:  originalMsg.ReceiveCount,
    FailedAt:      time.Now(),
})

// ✗ Replay massal tanpa feature flag — tidak bisa dihentikan di tengah jalan
func replayAllDLQ() { /* tidak ada kill switch */ }
// ✓ Tambahkan flag untuk pause/stop replay kapan saja
func replayAllDLQ(ctx context.Context) {
    for _, event := range events {
        if ctx.Err() != nil { return } // ← bisa dibatalkan
        // ...
    }
}

// ✗ Alert saat replay selesai saja — buta selama proses berjalan
// ✓ Progress log setiap N events + alert jika failure rate > threshold

Ringkasan #

  • Replay Strategy adalah kemampuan sistem memproses ulang event yang gagal secara aman — ini adalah keputusan arsitektur, bukan sekadar loop retry.
  • Lima jenis replay: immediate retry (kegagalan singkat), exponential backoff (perlu waktu recovery), DLQ (gagal permanen), manual replay (bug fix/audit), event sourcing replay (rebuild state).
  • Replay wajib dikombinasikan dengan idempotency — tanpa idempotency, replay adalah resep untuk double processing dan data rusak.
  • Dead Letter Queue adalah safety net wajib — event tidak boleh hilang hanya karena belum bisa diproses saat ini.
  • Tiga tantangan utama: duplicate processing (tangani dengan idempotency key), ordering (tangani dengan partition key), dan side effects (pisahkan state mutation dari side effects).
  • Exponential backoff + jitter mencegah retry storm — jangan retry langsung saat downstream butuh waktu untuk recover.
  • Throttle replay massal — replay ribuan event sekaligus tanpa rate limiting bisa menjatuhkan production system.
  • Simpan event secara durable dengan metadata lengkap — replay tidak mungkin dilakukan jika event-nya sudah tidak ada.
  • Observability wajib: log reason, triggered by, success/failure count, dan duration untuk setiap replay operation.

← Sebelumnya: Race Condition   Berikutnya: Reactive Programming →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact