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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c224776c90
commit
11a76cc249
@@ -577,8 +577,9 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
|||||||
trackMetric(`challenge_${ct}`)
|
trackMetric(`challenge_${ct}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate leaderboard cache after Elo/stats update
|
// Invalidate leaderboard cache — selective: only alltime + current season
|
||||||
invalidateLeaderboardCache()
|
const currentSeason = getCurrentSeason()
|
||||||
|
invalidateLeaderboardCache(currentSeason?.id)
|
||||||
|
|
||||||
// Advance tournament bracket if this was a tournament match
|
// Advance tournament bracket if this was a tournament match
|
||||||
try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ }
|
try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,10 +15,16 @@ export const botsRouter = new Hono()
|
|||||||
const leaderboardCache = new Map<string, { data: unknown; expiresAt: number }>()
|
const leaderboardCache = new Map<string, { data: unknown; expiresAt: number }>()
|
||||||
const LEADERBOARD_TTL_MS = 30_000 // 30s cache
|
const LEADERBOARD_TTL_MS = 30_000 // 30s cache
|
||||||
|
|
||||||
export function invalidateLeaderboardCache() {
|
export function invalidateLeaderboardCache(seasonId?: string) {
|
||||||
leaderboardCache.clear()
|
// 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 {
|
function hashSecret(secret: string): string {
|
||||||
return createHash('sha256').update(secret).digest('hex')
|
return createHash('sha256').update(secret).digest('hex')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user