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:
- 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. - 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 lokalRefactoring 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.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!