Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ jobs:
EOF
npm run build

- 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

Expand Down
3 changes: 1 addition & 2 deletions src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +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;
Expand Down
66 changes: 48 additions & 18 deletions src/lib/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -134,10 +135,11 @@ export async function clearLeaderboardCache(): Promise<void> {
// 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<T, R>(
Expand Down Expand Up @@ -336,15 +338,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<LeaderboardPayload | null> {
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;

Expand All @@ -353,17 +383,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<LeaderboardPayload>(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<LeaderboardPayload>(getLeaderboardCacheKey(period));
return stale ?? null;
}
}
}
26 changes: 26 additions & 0 deletions src/lib/metrics-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,29 @@ export async function invalidateUserMetricsCache(userId: string): Promise<void>
// Invalidation failures must not break the webhook response.
}
}

export async function invalidateLeaderboardCache(): Promise<void> {
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.
}
}
Loading