Akar Masalah: Sinkronisasi Worker dan Dirac Delta Spike

Pada arsitektur distributed worker queue, kegagalan parsial downstream (seperti database timeout atau degradasi API eksternal) sering kali berubah menjadi insiden total outage akibat mekanisme retri yang salah. Fenomena ini bermula dari worker pool yang memproses ribuan pesan secara paralel. Ketika downstream mengalami transient blip selama beberapa detik, seluruh worker yang sedang mengeksekusi tugas akan menerima respon gagal secara bersamaan pada waktu t0.

Jika sistem menggunakan strategi retri deterministik (misalnya fixed interval atau pure exponential backoff tanpa variasi acak), ribuan worker tersebut akan dijadwalkan ulang untuk mengeksekusi ulang tugas pada titik waktu yang persis sama. Secara matematis, konsentrasi beban ini menyerupai fungsi delta Dirac (δ(t)), di mana energi permintaan terpusat dalam interval waktu yang mendekati nol. Fenomena ini menciptakan Dirac delta spike pada grafik throughput downstream.

Ketika downstream mencoba memulihkan diri, ia langsung dihantam oleh lonjakan volume permintaan yang masif (thundering herd). Downstream kembali kolaps, memicu retri putaran kedua yang kembali tersinkronisasi, dan siklus retry storm terus berulang hingga seluruh klaster kehabisan sumber daya koneksi, thread, atau memori.

Analisis Pola Backoff: Deterministik vs Noise Injection

Untuk memutus sinkronisasi eksekusi antar-worker, jeda waktu (sleep window) harus didistribusikan secara statistik. Terdapat beberapa pendekatan komputasi interval retri:

1. Fixed Backoff

t = base_interval

Retri terjadi secara berkala pada interval konstan. Pola ini mempertahankan sinkronisasi worker secara sempurna. Seluruh worker yang gagal bersama-sama akan selalu mengeksekusi ulang secara bersama-sama di setiap putaran retri.

2. Pure Exponential Backoff

t = min(max_interval, base_interval * (2 ** attempt))

Pola ini memperpanjang jarak antar retri secara eksponensial. Meskipun mengurangi frekuensi beban secara keseluruhan, pola ini tidak menyelesaikan masalah sinkronisasi. Worker yang gagal pada batch yang sama di t0 tetap akan memicu lonjakan serentak pada t0 + t1, t0 + t1 + t2, dan seterusnya.

3. Full Jitter

sleep = min(max_interval, base_interval * (2 ** attempt))
t = uniform_random(0, sleep)

Setiap worker memilih angka acak seragam antara 0 dan batas atas eksponensial saat ini. Full Jitter menyebarkan eksekusi worker di sepanjang garis waktu dan meratakan Dirac delta spike menjadi distribusi seragam. Pendekatan ini meminimalkan total waktu tunggu kluster sekaligus menekan kepadatan beban puncak (peak load) downstream.

4. Decorrelated Jitter

t = min(max_interval, uniform_random(base_interval, previous_sleep * 3))

Dikembangkan untuk menghindari korelasi antar-percobaan tanpa bergantung langsung pada indeks percobaan (attempt counter). Interval retri dihitung berdasarkan nilai tidur sebelumnya. Pola ini sangat efektif mendispersikan retry storm pada klaster worker berskala sangat besar dengan latency tinggi.

Implementasi Worker Retry Scheduler dengan Jitter

Berikut implementasi backoff calculator dalam Go yang menyediakan algoritma Full Jitter dan Decorrelated Jitter dengan batas maksimum (ceiling):

package backoff

import (
	"math"
	"math/rand"
	"sync"
	"time"
)

type JitterType int

const (
	FullJitter JitterType = iota
	DecorrelatedJitter
)

type Backoff struct {
	BaseInterval time.Duration
	MaxInterval  time.Duration
	Type         JitterType

	mu   sync.Mutex
	rng  *rand.Rand
	prev time.Duration
}

func NewBackoff(base, max time.Duration, jType JitterType) *Backoff {
	return &Backoff{
		BaseInterval: base,
		MaxInterval:  max,
		Type:         jType,
		rng:          rand.New(rand.NewSource(time.Now().UnixNano())),
		prev:         base,
	}
}

func (b *Backoff) Duration(attempt int) time.Duration {
	b.mu.Lock()
	defer b.mu.Unlock()

	switch b.Type {
	case FullJitter:
		// ceiling = min(MaxInterval, BaseInterval * 2^attempt)
		multiplier := math.Pow(2, float64(attempt))
		temp := float64(b.BaseInterval) * multiplier
		ceiling := math.Min(float64(b.MaxInterval), temp)
		
		if ceiling <= 0 {
			return b.BaseInterval
		}
		// random interval antara 0 dan ceiling
		sleep := time.Duration(b.rng.Float64() * ceiling)
		b.prev = sleep
		return sleep

	case DecorrelatedJitter:
		// sleep = min(MaxInterval, rand(BaseInterval, prev * 3))
		low := float64(b.BaseInterval)
		high := float64(b.prev * 3)
		if high < low {
			high = low
		}
		
		var sleepVal float64
		if high == low {
			sleepVal = low
		} else {
			sleepVal = low + b.rng.Float64()*(high-low)
		}
		
		sleep := time.Duration(math.Min(float64(b.MaxInterval), sleepVal))
		b.prev = sleep
		return sleep

	default:
		return b.BaseInterval
	}
}

Pola di atas mengembalikan durasi yang digunakan worker sebelum memanggil fungsi re-enqueue (seperti Redis ZADD pada antrian delayed) atau sebelum menjalankan eksekusi ulang langsung.

Isolasi Beban: Circuit Breaker, Max-Retry, dan Dead-Letter Queue (DLQ)

Jitter mendistribusikan waktu retri, namun tidak mencegah sistem terus membuang siklus CPU jika downstream benar-benar tidak merespons (misal mengalami permanent crash). Kombinasikan Jitter dengan tiga pola isolasi berikut:

1. Circuit Breaker pada Level Worker

Jika persentase kegagalan eksekusi tugas melampaui ambang batas tertentu (misalnya 40% dari total eksekusi selama 30 detik), Circuit Breaker beralih ke status OPEN. Dalam status ini, worker menunda konsumsi pesan dari antrian utama tanpa memanggil downstream sama sekali, mencegah worker membanjiri target yang sakit.

2. Max-Retry Limits

Setiap payload tugas harus memiliki atribut retry_count. Batasi retri maksimal (umumnya 3 hingga 5 kali). Percobaan tanpa batas (infinite retries) adalah penyebab utama akumulasi antrian dan kehabisan memori worker.

3. Dead-Letter Queue (DLQ) untuk Poison Messages

Poison message adalah pesan antrian yang selalu gagal diproses terlepas dari berapa kali dicoba (misal akibat error validasi skema atau data korup). Setelah batas retry_count tercapai, worker harus memindahkan pesan langsung ke DLQ dan mencatat log failure.

Penting: Jangan biarkan poison message kembali ke antrian utama (re-enqueue). Kegagalan memisahkan pesan korup ke DLQ akan mengonsumsi kapasitas worker pool dan menggeser tugas-tugas yang valid.

Observabilitas: Memantau Dispersi Beban pada Prometheus & Grafana

Untuk memastikan implementasi Jitter bekerja dan mendispersikan beban secara optimal, instrumen metrik Prometheus berikut wajib dicatat:

1. Instrumentasi Metrik (Prometheus)

var (
	JobExecutionTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "worker_job_execution_total",
			Help: "Total job processing attempts per queue and status.",
		},
		[]string{"queue", "status"}, // status: success, retry, failed_dlq
	)

	RetrySleepDuration = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "worker_retry_sleep_duration_seconds",
			Help:    "Distribution of sleep durations applied before retries.",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"queue"},
	)
)

2. Deteksi Dirac Delta Spike via PromQL

Evaluasi laju retri worker menggunakan query rate per detik dalam rentang waktu singkat:

sum(rate(worker_job_execution_total{status="retry"}[10s])) by (queue)
  • Indikator Gagal (Dirac Delta): Terlihat pola lonjakan vertikal tajam berkala diikuti periode kosong (pola gigi gergaji sinkron atau impulse).
  • Indikator Sukses (Jitter Aktif): Grafik menampilkan kurva landai yang menyebar rata setelah kegagalan downstream terdeteksi, tanpa spike ekstrem yang melampaui batas kapasitas downstream.

Terapkan formula dispersi Jitter, batasi retri, dan amankan downstream dengan Circuit Breaker untuk menjaga stabilitas arsitektur worker terdistribusi Anda.