From daaa5402c766dfd9959a964577157f84b5e76212 Mon Sep 17 00:00:00 2001 From: Harshita Nagpal Date: Thu, 4 Jun 2026 01:29:48 +0530 Subject: [PATCH 1/2] feat(perf): cache leaderboard response for 5 minutes and invalidate on opt-out (#1908) --- src/app/api/leaderboard/route.ts | 2 +- src/lib/leaderboard.ts | 66 +++++++++++++++++++++++--------- src/lib/metrics-cache.ts | 26 +++++++++++++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 7902aa6bb..832f1ede8 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -20,7 +20,7 @@ import { upstashTryAcquireLock, } from "@/lib/upstash-rest"; -export const revalidate = 3600; +export const dynamic = "force-dynamic"; const RATE_LIMIT_REQUESTS = 20; const RATE_LIMIT_WINDOW_MS = 60 * 1000; diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts index c72b6ce2a..88dcdcaa3 100644 --- a/src/lib/leaderboard.ts +++ b/src/lib/leaderboard.ts @@ -1,12 +1,13 @@ import { supabaseAdmin } from "@/lib/supabase"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; -import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache"; +import { cacheGet, cacheSet, cacheDelete, invalidateLeaderboardCache } from "@/lib/metrics-cache"; import { pruneExpiredLeaderboardCache, type LeaderboardCacheEntry, } from "@/lib/leaderboard-cache"; +import { unstable_cache, revalidateTag } from "next/cache"; -export const CACHE_REFRESH_SECONDS = 60 * 60; // 1 hour +export const CACHE_REFRESH_SECONDS = 300; // 5 minutes export const CACHE_STALE_SECONDS = 6 * 60 * 60; // 6 hours export const LEADERBOARD_CACHE_KEY = "leaderboard:v1"; export const LEADERBOARD_BUILD_LOCK_KEY = "leaderboard:build-lock:v1"; @@ -134,10 +135,11 @@ export async function clearLeaderboardCache(): Promise { // 1. Drop the module-level in-process cache. _memoryCache.clear(); - // 2. Drop the shared key from the metrics memory map and from Redis/Upstash - // so that other serverless instances also see a cache miss on their next - // leaderboard request. - await cacheDelete(LEADERBOARD_CACHE_KEY); + // 2. Drop all leaderboard shared keys in metrics memory map and Redis/Upstash. + await invalidateLeaderboardCache(); + + // 3. Invalidate Next.js unstable_cache + revalidateTag("leaderboard"); } async function mapWithConcurrency( @@ -334,15 +336,43 @@ export async function refreshLeaderboardCache( const cacheKey = getLeaderboardCacheKey(period); await cacheSet(cacheKey, payload, CACHE_STALE_SECONDS); setMemoryCachedLeaderboard(payload, period); + revalidateTag("leaderboard"); return payload; } +export const getCachedLeaderboard = (filters: LeaderboardFilters = {}) => { + const period = filters.period ?? DEFAULT_PERIOD; + return unstable_cache( + async () => buildLeaderboard(filters), + ["leaderboard", period], + { revalidate: 300 } + )(); +}; + export async function getLeaderboardData( bypass = false, filters: LeaderboardFilters = {} ): Promise { const period = filters.period ?? DEFAULT_PERIOD; - if (!bypass) { + + if (bypass) { + try { + const payload = await buildLeaderboard(filters); + const cacheKey = getLeaderboardCacheKey(period); + await cacheSet(cacheKey, payload, CACHE_STALE_SECONDS); + setMemoryCachedLeaderboard(payload, period); + return payload; + } catch (err) { + console.error("[Leaderboard] Build failed:", err); + return null; + } + } + + try { + return await getCachedLeaderboard(filters); + } catch (err) { + console.error("[Leaderboard] unstable_cache failed, falling back to custom cache:", err); + const mem = getMemoryCachedLeaderboard(period); if (mem) return mem; @@ -351,17 +381,17 @@ export async function getLeaderboardData( setMemoryCachedLeaderboard(cached, period); return cached; } - } - try { - const payload = await buildLeaderboard(filters); - const cacheKey = getLeaderboardCacheKey(period); - await cacheSet(cacheKey, payload, CACHE_STALE_SECONDS); - setMemoryCachedLeaderboard(payload, period); - return payload; - } catch (err) { - console.error("[Leaderboard] Build failed:", err); - const stale = await cacheGet(getLeaderboardCacheKey(period)); - return stale ?? null; + try { + const payload = await buildLeaderboard(filters); + const cacheKey = getLeaderboardCacheKey(period); + await cacheSet(cacheKey, payload, CACHE_STALE_SECONDS); + setMemoryCachedLeaderboard(payload, period); + return payload; + } catch (buildErr) { + console.error("[Leaderboard] Fallback build failed:", buildErr); + const stale = await cacheGet(getLeaderboardCacheKey(period)); + return stale ?? null; + } } } diff --git a/src/lib/metrics-cache.ts b/src/lib/metrics-cache.ts index 581f896a9..5e44d749d 100644 --- a/src/lib/metrics-cache.ts +++ b/src/lib/metrics-cache.ts @@ -246,3 +246,29 @@ export async function invalidateUserMetricsCache(userId: string): Promise // Invalidation failures must not break the webhook response. } } + +export async function invalidateLeaderboardCache(): Promise { + const prefix = `leaderboard:`; + + for (const key of memoryCache.keys()) { + if (key.startsWith(prefix)) { + memoryCache.delete(key); + } + } + + const redis = getRedisClient(); + if (!redis) return; + + try { + let cursor = 0; + do { + const [nextCursor, keys] = await redis.scan(cursor, { match: `${prefix}*`, count: 100 }); + if (keys.length > 0) { + await redis.del(...keys); + } + cursor = Number(nextCursor); + } while (cursor !== 0); + } catch (e) { + // Invalidation failures must not break the settings/webhook response. + } +} From fd1e4224ab8763e6081da3500ce1686a306c5d41 Mon Sep 17 00:00:00 2001 From: Harshita Nagpal Date: Thu, 4 Jun 2026 02:29:29 +0530 Subject: [PATCH 2/2] Fix E2E: start server before Playwright tests --- .github/workflows/e2e.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cb961b557..a5cb718ed 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -50,6 +50,15 @@ jobs: PLAYWRIGHT_SERVER_MODE=start EOF npm run build + - name: Start Next.js server + run: npm start & + - name: Wait for server + run: npx wait-on http://127.0.0.1:3000 + + - name: Set up Python (required by Playwright deps) + uses: actions/setup-python@v5 + with: + python-version: "3.11" - name: Install Playwright browsers run: npx playwright install --with-deps chromium