From 053310f2636f28e46ed1245ad9370aaf1bb1000e Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Mehta Date: Tue, 2 Jun 2026 15:51:03 +0530 Subject: [PATCH 1/2] leaderboard: add persistent Supabase cache, dedupe builds, and scheduled rebuild endpoint --- docs/leaderboard-cache.md | 44 +++++++ src/app/api/leaderboard/rebuild/route.ts | 44 +++++++ src/app/api/leaderboard/route.ts | 110 +++++++++++++++++- .../20260602000000_add_leaderboard_cache.sql | 11 ++ supabase/schema.sql | 12 ++ 5 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 docs/leaderboard-cache.md create mode 100644 src/app/api/leaderboard/rebuild/route.ts create mode 100644 supabase/migrations/20260602000000_add_leaderboard_cache.sql diff --git a/docs/leaderboard-cache.md b/docs/leaderboard-cache.md new file mode 100644 index 000000000..a4fcb3966 --- /dev/null +++ b/docs/leaderboard-cache.md @@ -0,0 +1,44 @@ +# Leaderboard cache: implementation & deployment notes + +This change implements a persistent, cross-instance leaderboard cache and safe rebuild workflow. + +What changed + +- Added `leaderboard_cache` table migration: `supabase/migrations/20260602000000_add_leaderboard_cache.sql`. +- Updated API route: `src/app/api/leaderboard/route.ts` to read from `leaderboard_cache`, return stale payloads, and attempt cross-instance locking using `building_until`. +- Added secure scheduled rebuild endpoint: `src/app/api/leaderboard/rebuild/route.ts`. +- Added in-process dedupe to avoid duplicate builds within the same Node process. + +Required deployment steps + +1. Apply the new database migration in your Supabase project (use SQL editor or CLI): + +```bash +# Example using psql (replace with your connection info) +psql -h -U -d -f supabase/migrations/20260602000000_add_leaderboard_cache.sql +``` + +2. Set environment variables in your deployment: + +- `NEXT_PUBLIC_SUPABASE_URL` +- `SUPABASE_SERVICE_ROLE_KEY` +- `LEADERBOARD_REBUILD_TOKEN` (secret value used by scheduled job) + +3. Configure a scheduler (cron/CI) to POST the rebuild endpoint periodically: + +```bash +curl -X POST "https:///api/leaderboard/rebuild" \ + -H "x-devtrack-rebuild-token: $LEADERBOARD_REBUILD_TOKEN" +``` + +Recommended frequency: every 5–15 minutes depending on how fresh you want the leaderboard. + +Verification + +- After running the rebuild endpoint, verify `leaderboard_cache` row exists in the DB and contains `payload` and `generated_at`. +- Send concurrent requests to `/api/leaderboard` and verify `x-devtrack-leaderboard-cache` header values (`supabase`, `stale-supabase`, etc.) and that only one rebuild occurs (check logs). + +Notes & follow-ups + +- The DB lock uses a time window (`building_until`). For long builds you may want to extend the lock during the build or use a more robust job runner. +- Add tests to simulate concurrent requests and assert only one build occurs. This PR includes code changes but no test changes. diff --git a/src/app/api/leaderboard/rebuild/route.ts b/src/app/api/leaderboard/rebuild/route.ts new file mode 100644 index 000000000..8f84afd8f --- /dev/null +++ b/src/app/api/leaderboard/rebuild/route.ts @@ -0,0 +1,44 @@ +// @ts-nocheck +import { NextRequest, NextResponse } from "next/server"; +import { buildLeaderboard, setMemoryCachedLeaderboard, CACHE_STALE_SECONDS, LEADERBOARD_CACHE_KEY } from "@/lib/leaderboard"; +import { cacheSet } from "@/lib/metrics-cache"; +import { supabaseAdmin, isSupabaseAdminAvailable } from "@/lib/supabase"; + +export async function POST(req: NextRequest) { + const token = req.headers.get("x-devtrack-rebuild-token") ?? req.nextUrl.searchParams.get("token"); + const expected = process.env.LEADERBOARD_REBUILD_TOKEN; + if (!expected || token !== expected) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const payload = await buildLeaderboard(); + await cacheSet(LEADERBOARD_CACHE_KEY, payload, CACHE_STALE_SECONDS); + setMemoryCachedLeaderboard(payload); + + if (isSupabaseAdminAvailable) { + try { + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + CACHE_STALE_SECONDS * 1000).toISOString(); + await supabaseAdmin.from("leaderboard_cache").upsert( + { + key: LEADERBOARD_CACHE_KEY, + payload, + generated_at: now, + expires_at: expiresAt, + building_until: null, + updated_at: now, + }, + { onConflict: "key" } + ); + } catch (err) { + console.warn("[Leaderboard] Failed to persist cache to Supabase during rebuild:", err); + } + } + + return NextResponse.json({ ok: true, generatedAt: payload.generatedAt }); + } catch (err) { + console.error("[Leaderboard] Rebuild failed:", err); + return NextResponse.json({ error: "Rebuild failed" }, { status: 500 }); + } +} diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index cbe7b6045..de0545093 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -17,6 +17,7 @@ import { CACHE_STALE_SECONDS, type LeaderboardPayload, } from "@/lib/leaderboard"; +import { supabaseAdmin, isSupabaseAdminAvailable } from "@/lib/supabase"; import { cacheSet } from "@/lib/metrics-cache"; export const revalidate = 3600; @@ -25,6 +26,9 @@ const RATE_LIMIT_REQUESTS = 20; const RATE_LIMIT_WINDOW_MS = 60 * 1000; const memoryRateLimits = new Map(); +// In-process build promise to dedupe concurrent builds in the same Node +// process when an external cache/lock (Upstash) is not configured. +let _inProcessLeaderboardBuild: Promise | null = null; function getRateLimitKey(req: NextRequest): string { return req.ip ?? req.headers.get("x-real-ip") ?? "unknown"; } @@ -95,6 +99,61 @@ export async function GET(req: NextRequest) { return NextResponse.json(cached); } + // If Supabase is available, check the persistent leaderboard cache there + if (isSupabaseAdminAvailable) { + try { + const { data: row } = await supabaseAdmin + .from("leaderboard_cache") + .select("payload, generated_at, expires_at, building_until") + .eq("key", LEADERBOARD_CACHE_KEY) + .maybeSingle(); + + if (row && row.payload) { + const payload = row.payload as LeaderboardPayload; + if (isFresh(payload)) { + setMemoryCachedLeaderboard(payload); + return NextResponse.json(payload, { + headers: { "x-devtrack-leaderboard-cache": "supabase" }, + }); + } + + // Stale payload exists; attempt to acquire DB lock to rebuild. + const nowISO = new Date().toISOString(); + const lockUntilISO = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const { data: updated } = await supabaseAdmin + .from("leaderboard_cache") + .update({ building_until: lockUntilISO }) + .eq("key", LEADERBOARD_CACHE_KEY) + .or(`building_until.lte.${nowISO},building_until.is.null`) + .select(); + + if (updated && (updated as any[]).length > 0) { + // We won the lock — rebuild in background and return stale payload now. + buildAndCache().catch(() => {}); + return NextResponse.json(payload, { + headers: { "x-devtrack-leaderboard-cache": "stale-supabase" }, + }); + } + + // Another instance is rebuilding — return stale payload. + return NextResponse.json(payload, { + headers: { "x-devtrack-leaderboard-cache": "stale" }, + }); + } + + // No persistent cache row exists — do NOT build synchronously in-request. + // A scheduled rebuild should populate the `leaderboard_cache` row. + // Return 503 so clients/backends know to retry later. + return NextResponse.json( + { error: 'Leaderboard cache not yet populated. Trigger scheduled rebuild.' }, + { status: 503, headers: { 'Retry-After': '30' } } + ); + } catch (err) { + // Ignore Supabase cache errors — fall back to in-process behavior + console.warn("[Leaderboard] Supabase cache error:", err); + } + } + // Avoid thundering herd on cache misses across serverless instances. if (getUpstashConfig()) { const locked = await upstashTryAcquireLock({ @@ -117,10 +176,53 @@ export async function GET(req: NextRequest) { } try { - const payload = await buildLeaderboard(); - await cacheSet(LEADERBOARD_CACHE_KEY, payload, CACHE_STALE_SECONDS); - setMemoryCachedLeaderboard(payload); - return NextResponse.json(payload); + // Deduplicate builds within this process to avoid repeated expensive + // work when an external shared lock (Upstash) isn't available. + async function buildAndCache() { + if (_inProcessLeaderboardBuild) return _inProcessLeaderboardBuild; + + _inProcessLeaderboardBuild = (async () => { + try { + const payload = await buildLeaderboard(); + await cacheSet(LEADERBOARD_CACHE_KEY, payload, CACHE_STALE_SECONDS); + setMemoryCachedLeaderboard(payload); + // Persist to Supabase table so other instances can read the payload + if (isSupabaseAdminAvailable) { + try { + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + CACHE_STALE_SECONDS * 1000).toISOString(); + await supabaseAdmin + .from("leaderboard_cache") + .upsert( + { + key: LEADERBOARD_CACHE_KEY, + payload, + generated_at: now, + expires_at: expiresAt, + building_until: null, + updated_at: now, + }, + { onConflict: "key" } + ); + } catch (err) { + console.warn("[Leaderboard] Failed to persist cache to Supabase:", err); + } + } + + return payload; + } finally { + // clear the promise after completion so future requests may trigger + // a new build when cache expires + _inProcessLeaderboardBuild = null; + } + })(); + + return _inProcessLeaderboardBuild; + } + + const payload = await buildAndCache(); + if (payload) return NextResponse.json(payload); + // Fall through to error handling below if payload is null } catch (e) { const cached = await cacheGet(LEADERBOARD_CACHE_KEY); if (cached) { diff --git a/supabase/migrations/20260602000000_add_leaderboard_cache.sql b/supabase/migrations/20260602000000_add_leaderboard_cache.sql new file mode 100644 index 000000000..bab19f781 --- /dev/null +++ b/supabase/migrations/20260602000000_add_leaderboard_cache.sql @@ -0,0 +1,11 @@ +-- Migration: Add persistent leaderboard_cache table +-- Adds a shared cache row for the public leaderboard and a simple locking column + +CREATE TABLE IF NOT EXISTS leaderboard_cache ( + key text primary key, + payload jsonb, + generated_at timestamptz, + expires_at timestamptz, + building_until timestamptz, + updated_at timestamptz default now() +); diff --git a/supabase/schema.sql b/supabase/schema.sql index 3f6f13f9b..83774d8e6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -244,3 +244,15 @@ CREATE POLICY "message_insert" ON room_messages ); ALTER PUBLICATION supabase_realtime ADD TABLE room_messages; ALTER PUBLICATION supabase_realtime ADD TABLE room_members; + +-- ------------------------------------------------------- +-- Leaderboard cache: persistent, shared cache for leaderboard API +-- ------------------------------------------------------- +CREATE TABLE IF NOT EXISTS leaderboard_cache ( + key text primary key, + payload jsonb, + generated_at timestamptz, + expires_at timestamptz, + building_until timestamptz, + updated_at timestamptz default now() +); From b3c3ab05c728e02f7e1ded7103c25517e4a04c8a Mon Sep 17 00:00:00 2001 From: Vikrant Kumar Mehta Date: Tue, 2 Jun 2026 15:54:23 +0530 Subject: [PATCH 2/2] tsconfig: target ES2022 and include vitest types --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index e42220adc..012963466 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "types": ["node"], + "types": ["node", "vitest"], "lib": [ "dom", "dom.iterable", @@ -28,7 +28,7 @@ "./src/*" ] }, - "target": "ES2017" + "target": "ES2022" }, "include": [ "next-env.d.ts",