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:

  1. Penentuan Tanggal Mengandalkan Waktu Server: Endpoint POST /api/scores mengeksekusi CURRENT_DATE pada query SQL atau membaca new Date() di backend instance. Server tidak peduli kapan ronde dimulai atau board konfigurasi apa yang dimainkan pengguna.
  2. 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.
  3. 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:

  1. HMAC-Signed Session Token: Backend menerbitkan token terenkripsi saat pemain memuat tantangan harian. Token memuat user_id dan challenge_date resmi.
  2. 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.
  3. 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 hey atau 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.