Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/leaderboard-cache.md
Original file line number Diff line number Diff line change
@@ -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 <host> -U <user> -d <db> -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://<your-site>/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.
44 changes: 44 additions & 0 deletions src/app/api/leaderboard/rebuild/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
4 changes: 4 additions & 0 deletions src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
upstashTryAcquireLock,
} from "@/lib/upstash-rest";


export const revalidate = 3600;

const RATE_LIMIT_REQUESTS = 20;
Expand All @@ -28,6 +29,9 @@ const LANGUAGE_REPO_LIMIT = 8;

const memoryRateLimits = new Map<string, RateLimitEntry>();

// 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<import("@/lib/leaderboard").LeaderboardPayload | null> | null = null;
function getRateLimitKey(req: NextRequest): string {
return req.ip ?? req.headers.get("x-real-ip") ?? "unknown";
}
Expand Down
11 changes: 11 additions & 0 deletions supabase/migrations/20260602000000_add_leaderboard_cache.sql
Original file line number Diff line number Diff line change
@@ -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()
);
12 changes: 12 additions & 0 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"types": ["node"],
"types": ["node", "vitest"],
"lib": [
"dom",
"dom.iterable",
Expand Down Expand Up @@ -28,7 +28,7 @@
"./src/*"
]
},
"target": "ES2017"
"target": "ES2022"
},
"include": [
"next-env.d.ts",
Expand Down
Loading