Pada game tantangan harian (seperti Wordle atau putt.day), pergantian tantangan terjadi serentak di seluruh dunia pada pukul 00:00 UTC. Pemain yang memulai sesi pada 23:55 UTC dan menyelesaikan permainan pada 00:02 UTC kerap mendapati skor mereka tercatat pada hari berikutnya, menimpa jatah main hari baru, atau gagal total dengan error HTTP 409 Conflict.
Gejala Masalah pada Jendela Pergantian Hari
Saat trafik memuncak di batas pergantian hari (00:00 UTC), sistem pelaporan skor menunjukkan dua anomali utama:
- Error HTTP 409 Conflict: Klien mengulang (retry) pengiriman skor akibat latensi jaringan. Server menolak request kedua karena menganggap pemain sudah menyelesaikan tantangan hari tersebut.
- Skor Tertukar / Misattribution: Pemain menyelesaikan tantangan tanggal D, namun skor dicatat di database untuk tanggal D+1. Akibatnya, tantangan tanggal D+1 terkunci sebelum pemain memainkannya.
Analisis Root Cause
Inspeksi pada alur backend lama mengungkap tiga penyebab teknis yang saling terikat:
- Penentuan Tanggal Mengandalkan Waktu Server: Endpoint
POST /api/scoresmengeksekusiCURRENT_DATEpada query SQL atau membacanew Date()di backend instance. Server tidak peduli kapan ronde dimulai atau board konfigurasi apa yang dimainkan pengguna. - Clock Skew Multi-Instance: Backend berjalan di beberapa container/node. Perbedaan waktu antar-node sebesar 1-3 detik (akibat drift NTP) menyebabkan satu node sudah menganggap 00:00:00 UTC sementara node lainnya masih 23:59:58 UTC. Request ganda pemain bisa mendarat di tanggal yang berbeda.
- Double Submission Tanpa Idempotensi State: Gangguan koneksi seluler memicu retry dari sisi klien. Backend memproses payload tanpa validasi referensi state tantangan awal.
Arsitektur Perbaikan
Solusi arsitektur ini memisahkan identitas tantangan dari waktu eksekusi submission dengan tiga komponen:
- HMAC-Signed Session Token: Backend menerbitkan token terenkripsi saat pemain memuat tantangan harian. Token memuat
user_iddanchallenge_dateresmi. - Grace Period Window: Server mengizinkan penyerahan skor untuk tanggal D hingga beberapa menit melewati 00:00 UTC, selama token sesi valid dan durasi permainan logis.
- PostgreSQL Atomic Upsert: Menegakkan constraint level database agar submission identik bersifat idempoten dan bebas race condition.
Skema Database DDL PostgreSQL
Gunakan constraint unik gabungan (user_id, challenge_date) untuk mengunci satu jatah skor per pemain per hari secara atomik.
CREATE TABLE daily_scores (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
challenge_date DATE NOT NULL,
strokes INT NOT NULL CHECK (strokes > 0),
client_duration_ms INT NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_user_challenge_date UNIQUE (user_id, challenge_date)
);
CREATE INDEX idx_daily_scores_date ON daily_scores (challenge_date);
Implementasi Backend Handler
Contoh implementasi Node.js menggunakan modul bawaan crypto dan driver PostgreSQL. Token sesi diverifikasi tanpa state di memori (stateless).
import { createHmac, timingSafeEqual } from "node:crypto";
import { Request, Response } from "express";
import { pool } from "./db";
const SECRET = process.env.SESSION_SECRET || "fallback-secret-key-min-32-chars";
const GRACE_PERIOD_MS = 15 * 60 * 1000; // 15 menit toleransi koneksi
interface SessionPayload {
userId: string;
challengeDate: string; // Format: YYYY-MM-DD
issuedAt: number;
}
export function generateSessionToken(payload: SessionPayload): string {
const data = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", SECRET).update(data).digest("base64url");
return `${data}.${signature}`;
}
export async function submitDailyScore(req: Request, res: Response): Promise<void> {
const { sessionToken, strokes, durationMs } = req.body;
if (!sessionToken || typeof strokes !== "number") {
res.status(400).json({ error: "Payload tidak valid" });
return;
}
// 1. Verifikasi HMAC signature
const [dataPart, sigPart] = sessionToken.split(".");
if (!dataPart || !sigPart) {
res.status(401).json({ error: "Token malformed" });
return;
}
const expectedSig = createHmac("sha256", SECRET).update(dataPart).digest("base64url");
const valid = timingSafeEqual(Buffer.from(sigPart), Buffer.from(expectedSig));
if (!valid) {
res.status(401).json({ error: "Signature token tidak valid" });
return;
}
const session: SessionPayload = JSON.parse(Buffer.from(dataPart, "base64url").toString("utf8"));
// 2. Evaluasi Grace Period
const challengeMidnightUtc = new Date(`${session.challengeDate}T00:00:00Z`).getTime();
const rolloverExpiryUtc = challengeMidnightUtc + 86400000 + GRACE_PERIOD_MS;
const now = Date.now();
if (now > rolloverExpiryUtc) {
res.status(422).json({ error: "Sesi permainan telah kedaluwarsa melewati batas grace period" });
return;
}
// 3. Eksekusi Idempotent Upsert
// ponytail: DO NOTHING digunakan untuk idempotensi retry klien. Ubah ke DO UPDATE jika skor terbaik diizinkan.
const query = `
INSERT INTO daily_scores (user_id, challenge_date, strokes, client_duration_ms)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, challenge_date)
DO UPDATE SET submitted_at = daily_scores.submitted_at
RETURNING id, challenge_date, strokes, submitted_at;
`;
try {
const result = await pool.query(query, [
session.userId,
session.challengeDate,
strokes,
durationMs,
]);
res.status(200).json({
success: true,
score: result.rows[0],
});
} catch (err) {
res.status(500).json({ error: "Gagal menyimpan skor" });
}
}
Pengujian dan Validasi Edge Cases
Untuk memverifikasi keandalan sistem terhadap edge case rollover:
- Simulasi Submisi Menit 00:05 UTC: Jalankan unit test dengan tanggal sesi D yang dikirimkan pada D+1 pukul 00:05 UTC. Verifikasi bahwa database mengatribusikan data ke tanggal D, bukan D+1.
- Concurrent Duplicate Request: Kirim 10 request HTTP identik secara paralel menggunakan tool seperti
heyatau k6. Verifikasi database hanya menyimpan satu baris dan semua request menerima respon status 200 tanpa 409. - Submisi Melewati Grace Period: Kirim payload tanggal D pada pukul 00:16 UTC (melewati 15 menit). Pastikan API menolak request dengan status 422 Unprocessable Entity.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!