From 11a76cc2491379c03f2c99a450fd45528df06947 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 00:03:16 +0000 Subject: [PATCH] fix: selective leaderboard cache invalidation instead of full clear (BUG-S10) Only invalidates __alltime__ and current season cache keys on fight completion, preserving historical season caches. Test verifies selective behavior. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/orchestrator.ts | 5 +- server/src/routes/bots-cache.test.ts | 102 +++++++++++++++++++++++++++ server/src/routes/bots.ts | 10 ++- 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 server/src/routes/bots-cache.test.ts diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 5d0cba5..d631d26 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -577,8 +577,9 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec trackMetric(`challenge_${ct}`) } - // Invalidate leaderboard cache after Elo/stats update - invalidateLeaderboardCache() + // Invalidate leaderboard cache — selective: only alltime + current season + const currentSeason = getCurrentSeason() + invalidateLeaderboardCache(currentSeason?.id) // Advance tournament bracket if this was a tournament match try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ } diff --git a/server/src/routes/bots-cache.test.ts b/server/src/routes/bots-cache.test.ts new file mode 100644 index 0000000..529c959 --- /dev/null +++ b/server/src/routes/bots-cache.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock DB and dependencies to import bots module +vi.mock('../db/index.js', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }, + schema: { + bots: { id: 'id', name: 'name', publicKey: 'publicKey', eloRating: 'eloRating', wins: 'wins', losses: 'losses', winStreak: 'winStreak', tier: 'tier', avatarSeed: 'avatarSeed', archetype: 'archetype', botType: 'botType', hasWallet: 'hasWallet', zapsReceived: 'zapsReceived', isActive: 'isActive', webhookUrl: 'webhookUrl', secretHash: 'secretHash', bestStreak: 'bestStreak', satsWagered: 'satsWagered' }, + walletConnections: { id: 'id', botId: 'botId' }, + }, +})) + +vi.mock('../engine/scoring.js', () => ({ + TIER_NAMES: ['Baby', 'Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Legend'], + TIER_COLORS: ['#999', '#cd7f32', '#c0c0c0', '#ffd700', '#e5e4e2', '#b9f2ff', '#ff6b6b'], +})) + +vi.mock('../engine/achievements.js', () => ({ + computeAchievements: vi.fn().mockReturnValue([]), +})) + +vi.mock('../engine/orchestrator.js', () => ({ + isAllowedWebhookUrl: vi.fn().mockReturnValue(true), +})) + +vi.mock('../engine/webhook-test.js', () => ({ + testWebhook: vi.fn().mockResolvedValue({ success: true }), +})) + +vi.mock('../middleware/rate-limit.js', () => ({ + rateLimit: () => async (_c: any, next: any) => next(), +})) + +const { invalidateLeaderboardCache, _leaderboardCache } = await import('./bots.js') + +describe('leaderboard cache selective invalidation', () => { + beforeEach(() => { + _leaderboardCache.clear() + }) + + it('invalidates __alltime__ and current but preserves historical season', () => { + const now = Date.now() + _leaderboardCache.set('__alltime__', { data: 'alltime', expiresAt: now + 30_000 }) + _leaderboardCache.set('current', { data: 'current-season', expiresAt: now + 30_000 }) + _leaderboardCache.set('s1', { data: 'season-1-history', expiresAt: now + 30_000 }) + _leaderboardCache.set('s2', { data: 'season-2-history', expiresAt: now + 30_000 }) + + invalidateLeaderboardCache() + + expect(_leaderboardCache.has('__alltime__')).toBe(false) + expect(_leaderboardCache.has('current')).toBe(false) + // Historical seasons preserved + expect(_leaderboardCache.has('s1')).toBe(true) + expect(_leaderboardCache.has('s2')).toBe(true) + }) + + it('also invalidates specific season when seasonId passed', () => { + const now = Date.now() + _leaderboardCache.set('__alltime__', { data: 'alltime', expiresAt: now + 30_000 }) + _leaderboardCache.set('current', { data: 'current', expiresAt: now + 30_000 }) + _leaderboardCache.set('s1', { data: 'season-1', expiresAt: now + 30_000 }) + _leaderboardCache.set('s2', { data: 'season-2', expiresAt: now + 30_000 }) + + invalidateLeaderboardCache('s1') + + expect(_leaderboardCache.has('__alltime__')).toBe(false) + expect(_leaderboardCache.has('current')).toBe(false) + expect(_leaderboardCache.has('s1')).toBe(false) + // Other seasons preserved + expect(_leaderboardCache.has('s2')).toBe(true) + }) + + it('no-op on empty cache', () => { + expect(_leaderboardCache.size).toBe(0) + invalidateLeaderboardCache() + expect(_leaderboardCache.size).toBe(0) + }) + + it('only removes targeted keys, leaves other entries', () => { + const now = Date.now() + // 5 cached entries + _leaderboardCache.set('__alltime__', { data: 'a', expiresAt: now + 30_000 }) + _leaderboardCache.set('current', { data: 'c', expiresAt: now + 30_000 }) + _leaderboardCache.set('s1', { data: '1', expiresAt: now + 30_000 }) + _leaderboardCache.set('s2', { data: '2', expiresAt: now + 30_000 }) + _leaderboardCache.set('s3', { data: '3', expiresAt: now + 30_000 }) + + invalidateLeaderboardCache('s2') + + // Removed: __alltime__, current, s2 + expect(_leaderboardCache.size).toBe(2) + expect(_leaderboardCache.has('s1')).toBe(true) + expect(_leaderboardCache.has('s3')).toBe(true) + }) +}) diff --git a/server/src/routes/bots.ts b/server/src/routes/bots.ts index 2668dba..1fce91c 100644 --- a/server/src/routes/bots.ts +++ b/server/src/routes/bots.ts @@ -15,10 +15,16 @@ export const botsRouter = new Hono() const leaderboardCache = new Map() const LEADERBOARD_TTL_MS = 30_000 // 30s cache -export function invalidateLeaderboardCache() { - leaderboardCache.clear() +export function invalidateLeaderboardCache(seasonId?: string) { + // Selective: only clear active leaderboards, preserve historical season caches + leaderboardCache.delete('__alltime__') + leaderboardCache.delete('current') + if (seasonId) leaderboardCache.delete(seasonId) } +// Expose cache for testing +export const _leaderboardCache = leaderboardCache + function hashSecret(secret: string): string { return createHash('sha256').update(secret).digest('hex') }