Desinkronisasi interval pada implementasi algoritma SuperMemo 2 (SM-2) memicu penumpukan antrean flashcard secara mendadak bagi sejumlah pengguna. Gejala ini umumnya tidak bersumber dari kegagalan matematika SM-2 itu sendiri, melainkan dari representasi floating-point non-deterministik dan ketidakcocokan zona waktu antara worker latar belakang (UTC) dan waktu lokal pengguna.

Gejala Bug: Penumpukan Kartu Review (Drift Penjadwalan)

Layanan spaced repetition melaporkan anomali pada pengguna di zona waktu non-UTC (misal UTC+7 dan UTC+9). Kartu yang dijadwalkan untuk review bertambah drastis di luar ekspektasi harian. Ketika user menyelesaikan kartu, kartu tersebut sering kali muncul kembali beberapa menit kemudian atau justru melonjak berhari-hari melampaui interval SM-2 normal.

Pemeriksaan awal pada basis data memperlihatkan adanya pergeseran nilai pecahan hari yang terakumulasi. Kolom due_date bergeser beberapa detik hingga menit setiap kali interaksi dicatat, memicu status overdue prematur.

Diagnosis: Root Cause Analysis

Investigasi melalui kueri basis data dan log worker mengungkap dua masalah mendasar:

  1. Floating Point Truncation & Drift: Interval SM-2 dinyatakan dalam bentuk desimal (misalnya interval * ease_factor). Penyimpanan langsung tipe data floating point ke kolom timestamp via manipulasi tanggal pecahan menyebabkan pembulatan ke bawah (floor) atau ke atas secara acak tergantung precision driver DB.
  2. Timezone Mismatch: Server worker berjalan di zona waktu UTC sistemik. Input tanggal dari klien menggunakan waktu lokal tanpa normalisasi batas hari (cutoff point) yang eksplisit. Evaluasi kueri WHERE due_at <= NOW() menarik kartu lebih cepat dari awal hari pengguna sebenarnya.
-- Kueri diagnosis state database yang bermasalah
SELECT 
    id,
    user_id,
    repetition_count,
    ease_factor,
    interval_days,
    due_at,
    updated_at
FROM card_schedules
WHERE due_at <= NOW() AND repetition_count > 1
ORDER BY due_at ASC;
-- Ditemukan: due_at memiliki komponen mikrodetik ganjil dan deviasi interval hingga -18 jam dari cutoff lokal

Refactoring Algoritma: Interval Deterministik & UTC Cutoff

Solusi teknis mengharuskan dua perubahan arsitektural: mengisolasi kalkulasi hari menjadi bilangan bulat deterministik (integer math) dan mengunci waktu cutoff rollover hari review berdasarkan offset klien dalam format epoch UTC.

// sm2.go - Implementasi kalkulasi deterministik SM-2
package scheduler

import (
	"math"
	"time"
)

type ReviewState struct {
	Repetitions int
	EaseFactor  float64
	IntervalDays int
}

// CalculateNextReview menghitung status SM-2 dengan pembulatan integer deterministik
func CalculateNextReview(grade int, state ReviewState) ReviewState {
	if grade < 3 {
		// Review gagal: reset repetisi, pertahankan ease factor
		return ReviewState{
			Repetitions:  0,
			IntervalDays: 1,
			EaseFactor:   state.EaseFactor,
		}
	}

	// Formula SM-2: EF' = EF + (0.1 - (5 - grade) * (0.08 + (5 - grade) * 0.02))
	newEF := state.EaseFactor + (0.1 - float64(5-grade)*(0.08+float64(5-grade)*0.02))
	if newEF < 1.3 {
		newEF = 1.3
	}

	var nextInterval int
	switch state.Repetitions {
	case 0:
		nextInterval = 1
	case 1:
		nextInterval = 6
	default:
		// Gunakan math.Round untuk mencegah drift desimal
		nextInterval = int(math.Round(float64(state.IntervalDays) * newEF))
	}

	return ReviewState{
		Repetitions:  state.Repetitions + 1,
		EaseFactor:   newEF,
		IntervalDays: nextInterval,
	}
}

// ComputeDueTimestamp menghitung epoch UTC tepat pada waktu rollover lokal pengguna
func ComputeDueTimestamp(nowUTC time.Time, intervalDays int, userOffsetSeconds int) int64 {
	userLocation := time.FixedZone("UserTZ", userOffsetSeconds)
	localTime := nowUTC.In(userLocation)

	// Setel deterministik ke awal hari review berikutnya (pukul 04:00 cutoff waktu lokal)
	reviewDate := time.Date(
		localTime.Year(),
		localTime.Month(),
		localTime.Day()+intervalDays,
		4, 0, 0, 0, // Cutoff pukul 04:00 lokal
		userLocation,
	)

	return reviewDate.UTC().Unix()
}

Verifikasi dan Unit Testing

Validasi dilakukan menggunakan skenario deterministik untuk memastikan bahwa interval selalu berupa bilangan bulat dan timestamp yang dihasilkan konsisten terlepas dari mesin yang mengeksekusi worker.

// sm2_test.go
package scheduler

import (
	"testing"
	"time"
)

func TestDeterministicScheduling(t *testing.T) {
	state := ReviewState{Repetitions: 2, EaseFactor: 2.5, IntervalDays: 6}
	
	// Simulasikan grade 5 (sempurna)
	newState := CalculateNextReview(5, state)
	if newState.IntervalDays != 16 { // Round(6 * 2.6) = 16
		t.Fatalf("Expected 16 days, got %d", newState.IntervalDays)
	}

	// Pengujian rollover zona waktu Asia/Jakarta (UTC+7 = 25200 detik)
	baseTimeUTC := time.Date(2026, 3, 30, 22, 0, 0, 0, time.UTC) // 05:00 WIB (31 Maret)
	dueEpoch := ComputeDueTimestamp(baseTimeUTC, newState.IntervalDays, 25200)
	
	resultTime := time.Unix(dueEpoch, 0).UTC()
	expectedResultUTC := time.Date(2026, 4, 15, 21, 0, 0, 0, time.UTC) // 04:00 WIB 16 April

	if resultTime != expectedResultUTC {
		t.Errorf("Expected %v UTC, got %v UTC", expectedResultUTC, resultTime)
	}
}

Mitigasi & Praktik Terbaik

  • Simpan Seluruh Timestamp sebagai Epoch Integer atau UTC Tanpa Sub-Detik: Hindari kalkulasi interval berbasis manipulasi tanggal floating point di layer aplikasi maupun SQL.
  • Tetapkan Batas Rollover Harian yang Eksplisit: Terapkan cutoff seragam (misalnya pukul 04:00 waktu lokal) untuk mencegah kartu berpindah hari secara tidak disengaja hanya karena perbedaan beberapa detik saat review diselesaikan menjelang tengah malam.
  • Audit Indeks Database: Kueri antrean review harus menggunakan komparasi integer pada index: due_timestamp <= ? untuk menghindari kalkulasi runtime timezone konversi per baris data.