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 7902aa6bb..404dde7bb 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -20,6 +20,7 @@ import { upstashTryAcquireLock, } from "@/lib/upstash-rest"; + export const revalidate = 3600; const RATE_LIMIT_REQUESTS = 20; @@ -28,6 +29,9 @@ const LANGUAGE_REPO_LIMIT = 8; 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"; } 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 6d3988bef..21500a6d8 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -270,3 +270,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() +); 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",