Akar Masalah: Race Condition dan State Parsial

Insiden bot Sloan di Dev.to mengilustrasikan risiko otomasi moderasi berbasis event-driven naif: artikel valid mendadak di-suspend karena lonjakan interaksi spam/reaksi dalam hitungan detik. Ketika setiap event (upvote, downvote, komentar, atau flag pengguna) langsung memicu job worker individual, puluhan worker berjalan konkuren mengevaluasi entitas yang sama.

Kondisi ini memicu dua kegagalan fatal:

  • Partial State Evaluation: Worker A membaca metrik saat data baru terkomit sebagian, menghasilkan rasio anomali semu (misal: 10 flag terbaca sebelum counter 500 upvote tersinkronisasi dari cache).
  • Action Multiplier: Dua worker konkuren sama-sama memutuskan memberi penalti karena melihat skor di ambang batas, mengakibatkan penalti ganda (double-action) yang langsung membekukan akun.

Solusi Desain: Debounce Queue Berbasis Redis ZSET

Solusi deterministik untuk burst event adalah menunda evaluasi hingga kondisi stabil (quiescent state) tercapai. Alih-alih mengeksekusi rule engine per event, seluruh event untuk entitas yang sama di-debounce dalam time window tertentu (misalnya 15-30 detik).

Gunakan Redis Sorted Set (ZSET) sebagai debounce queue scheduler:

  • Key: moderation:debounce:queue
  • Score: Unix timestamp waktu eksekusi (waktu sekarang + window debounce).
  • Member: ID Entitas (misal: post:9842).

Jika event baru masuk saat entitas masih menunggu di ZSET, eksekusi ZADD ulang dengan score baru (sliding window) atau pertahankan score awal (fixed window) tergantung SLA batas tunda sistem.

Implementasi Worker dan Per-Entity Lock (Go)

Worker memproses item yang score-nya sudah melewati waktu saat ini (score <= now), mengambil atomic lock per entitas untuk mencegah eksekusi ganda, membaca aggregate state terbaru dari database, lalu menjalankan rule engine.

package main

import (
	"context"
	"fmt"
	"time"

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

type ModerationWorker struct {
	rdb      *redis.Client
	workerID string
}

func (w *ModerationWorker) EnqueueDebounce(ctx context.Context, entityID string, debounceWindow time.Duration) error {
	fireAt := float64(time.Now().Add(debounceWindow).Unix())
	// Update score: tunda evaluasi jika ada lonjakan event baru
	return w.rdb.ZAdd(ctx, "moderation:debounce:queue", redis.Z{
		Score:  fireAt,
		Member: entityID,
	}).Err()
}

func (w *ModerationWorker) ProcessNext(ctx context.Context) error {
	now := float64(time.Now().Unix())

	// 1. Ambil 1 entitas yang sudah jatuh tempo
	items, err := w.rdb.ZRangeByScore(ctx, "moderation:debounce:queue", &redis.ZRangeBy{
		Min:   "-inf",
		Max:   fmt.Sprintf("%f", now),
		Count: 1,
	}).Result()

	if err != nil || len(items) == 0 {
		return err // Queue kosong atau error
	}

	entityID := items[0]

	// 2. Hapus dari queue secara atomik via ZREM
	removed, err := w.rdb.ZRem(ctx, "moderation:debounce:queue", entityID).Result()
	if err != nil || removed == 0 {
		return nil // Sudah diambil oleh worker kompetitor
	}

	// 3. Keyed Distributed Lock per entitas (mencegah evaluasi paralel)
	lockKey := fmt.Sprintf("lock:moderation:%s", entityID)
	acquired, err := w.rdb.SetNX(ctx, lockKey, w.workerID, 30*time.Second).Result()
	if err != nil || !acquired {
		// Gagal lock, jadwalkan ulang untuk dicoba kembali 5 detik lagi
		w.rdb.ZAdd(ctx, "moderation:debounce:queue", redis.Z{
			Score:  float64(time.Now().Add(5 * time.Second).Unix()),
			Member: entityID,
		})
		return nil
	}
	defer w.releaseLock(ctx, lockKey)

	// 4. Evaluasi State Utuh dari Source of Truth
	w.evaluateEntityRules(ctx, entityID)
	return nil
}

func (w *ModerationWorker) evaluateEntityRules(ctx context.Context, entityID string) {
	// ponytail: ambil aggregated counter langsung dari primary DB/read-replica
	// Eksekusi rule engine hanya dengan snapshot utuh, bukan payload event parsial
}

func (w *ModerationWorker) releaseLock(ctx context.Context, lockKey string) {
	// Release lock dengan safe Lua script untuk memastikan token kepemilikan sesuai
	luaRelease := `
		if redis.call("get", KEYS[1]) == ARGV[1] then
			return redis.call("del", KEYS[1])
		else
			return 0
		end`
	w.rdb.Eval(ctx, luaRelease, []string{lockKey}, w.workerID)
}
Catatan: ZREM race condition dapat dioptimasi lebih lanjut menggunakan script Lua atau Redis 6.2+ ZPOPMIN. Pola di atas memisahkan polling dan deduplikasi tanpa dependensi rumit.

Prinsip Idempotensi pada Rule Engine

Debounce meredam frekuensi evaluasi, namun idempotensi menjamin kebenaran tindakan jika worker mengalami timeout atau retry:

  • Versioning State: Simpan kolom moderation_version pada tabel entitas. Jika version di database lebih baru dari version saat evaluasi dimulai, batalkan write tindakan.
  • Tindakan Non-Inkremental: Terapkan status akhir eksplisit (SET status = 'flagged') daripada memanggil mutasi berulang (CALL increment_strike()).
  • Dedup Key Aksi: Catat hashing kombinasi hash(entity_id + violation_type + hour) pada Redis dengan SET NX EX 86400 sebelum mengeksekusi ban atau notifikasi webhook.

Observabilitas: Metrik Kunci

Sistem debounce moderasi memerlukan tiga instrumen metrik untuk mendeteksi anomali:

  1. Queue Lag (Detik): Nilai now - oldest_score_in_zset. Jika nilai ini bertambah jauh melampaui window debounce, kapasitas worker tidak mencukupi laju agregasi.
  2. Lock Contention Rate: Rasio kegagalan SetNX per menit. Kenaikan tajam menandakan durasi komputasi rule engine melebihi batas perkiraan atau ada duplikasi entity ID di queue.
  3. False-Alarm Rate (Reversal Rate): Metrik bisnis teknis yang melacak persentase aksi otomatis yang di-rollback oleh moderator manusia dalam 24 jam. Kenaikan metrik ini mengindikasikan window debounce terlalu sempit sehingga worker mengevaluasi snapshot yang belum konvergen.

Trade-offs

Debounce queue menukar real-time immediacy dengan data consistency. Konten beracun tetap tampil selama durasi window (misal: 15 detik) sebelum penalti dieksekusi. Untuk konten kritis (misal: materi ilegal eksplisit), bypass alur debounce dan gunakan direct-blocking pipeline terpisah tanpa mengorbankan proteksi race condition pada metrik interaksi massal.