feat: add leaderboard cache, performance benchmarks, and verify SSE cleanup
- Add 30s TTL leaderboard cache with invalidation on fight completion - Add scoreRound performance benchmark: 1000 rounds in <100ms - Add checkAnswer performance benchmark: 1000 checks in <50ms - Add adversarial regex backtracking test for checkAnswer - Verify SSE cleanup: connections, IP counters, spectator counts, event listeners all properly decremented in finally block on disconnect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7d9b8b1dbf
commit
4074ef94eb
@@ -145,3 +145,33 @@ describe('checkAnswer', () => {
|
||||
expect(checkAnswer('\t\n \r', ['Paris'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkAnswer performance', () => {
|
||||
it('completes 1000 checks in under 50ms (<0.05ms each)', () => {
|
||||
const answers = ['Paris', 'London', 'Tokyo']
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
checkAnswer('I think the answer is probably Paris', answers)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
expect(elapsed).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('no regex backtracking on adversarial input', () => {
|
||||
// ReDoS-style strings that could cause catastrophic backtracking
|
||||
const adversarial = [
|
||||
'a'.repeat(10000),
|
||||
'a'.repeat(5000) + '!' + 'a'.repeat(5000),
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
|
||||
'(((((((((((((((((((((((((((((((',
|
||||
'x'.repeat(2000) + 'y'.repeat(2000),
|
||||
]
|
||||
const start = performance.now()
|
||||
for (const input of adversarial) {
|
||||
checkAnswer(input, ['correct answer', '42', 'true'])
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
// Must complete in <100ms total for all adversarial inputs
|
||||
expect(elapsed).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import { publishFightResult } from './nostr-publish.js'
|
||||
import { getCurrentSeason } from './seasons.js'
|
||||
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
|
||||
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
|
||||
import { invalidateLeaderboardCache } from '../routes/bots.js'
|
||||
|
||||
const webhookResponseSchema = z.object({
|
||||
answer: z.string().nullable().optional(),
|
||||
@@ -539,6 +540,9 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
trackMetric(`challenge_${ct}`)
|
||||
}
|
||||
|
||||
// Invalidate leaderboard cache after Elo/stats update
|
||||
invalidateLeaderboardCache()
|
||||
|
||||
// Advance tournament bracket if this was a tournament match
|
||||
try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ }
|
||||
|
||||
|
||||
@@ -326,3 +326,23 @@ describe('calculateTier', () => {
|
||||
expect(calculateTier(1900, 39)).toBe(5) // below Legend wins
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoreRound performance', () => {
|
||||
it('completes 1000 rounds in under 100ms (<0.1ms each)', () => {
|
||||
const challenge = makeChallenge()
|
||||
const botA = { id: 'a1', name: 'AlphaBot' }
|
||||
const botB = { id: 'b1', name: 'BetaBot' }
|
||||
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
scoreRound(
|
||||
challenge, botA, botB,
|
||||
makeResponse('4', 200 + i),
|
||||
makeResponse('banana', 500 + i),
|
||||
null, i % 6, 0,
|
||||
)
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
expect(elapsed).toBeLessThan(100) // <0.1ms per call
|
||||
})
|
||||
})
|
||||
|
||||
+50
-30
@@ -11,6 +11,14 @@ import { rateLimit } from '../middleware/rate-limit.js'
|
||||
|
||||
export const botsRouter = new Hono()
|
||||
|
||||
// Leaderboard cache — invalidated on fight completion
|
||||
const leaderboardCache = new Map<string, { data: unknown; expiresAt: number }>()
|
||||
const LEADERBOARD_TTL_MS = 30_000 // 30s cache
|
||||
|
||||
export function invalidateLeaderboardCache() {
|
||||
leaderboardCache.clear()
|
||||
}
|
||||
|
||||
function hashSecret(secret: string): string {
|
||||
return createHash('sha256').update(secret).digest('hex')
|
||||
}
|
||||
@@ -150,46 +158,58 @@ botsRouter.get('/:name', async (c) => {
|
||||
|
||||
// Get bot stats -- full account page data
|
||||
|
||||
// Season leaderboard endpoint
|
||||
// Season leaderboard endpoint (cached)
|
||||
botsRouter.get('/leaderboard', async (c) => {
|
||||
const seasonParam = c.req.query('season')
|
||||
const cacheKey = seasonParam || '__alltime__'
|
||||
const now = Date.now()
|
||||
|
||||
const cached = leaderboardCache.get(cacheKey)
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return c.json(cached.data)
|
||||
}
|
||||
|
||||
let result: unknown
|
||||
|
||||
if (seasonParam === 'current' || seasonParam) {
|
||||
const { getCurrentSeason, getSeasonLeaderboard, getSeasonById } = await import('../engine/seasons.js')
|
||||
const season = seasonParam === 'current' ? getCurrentSeason() : getSeasonById(seasonParam)
|
||||
if (!season) return c.json({ error: 'Season not found' }, 404)
|
||||
const entries = await getSeasonLeaderboard(season.id)
|
||||
return c.json({ season, entries })
|
||||
result = { season, entries }
|
||||
} else {
|
||||
// All-time: fall through to default bot list sorted by Elo
|
||||
const allBots = await db.select({
|
||||
id: schema.bots.id,
|
||||
name: schema.bots.name,
|
||||
avatarSeed: schema.bots.avatarSeed,
|
||||
archetype: schema.bots.archetype,
|
||||
eloRating: schema.bots.eloRating,
|
||||
wins: schema.bots.wins,
|
||||
losses: schema.bots.losses,
|
||||
winStreak: schema.bots.winStreak,
|
||||
tier: schema.bots.tier,
|
||||
botType: schema.bots.botType,
|
||||
}).from(schema.bots)
|
||||
|
||||
const ranked = allBots.filter(b => b.botType !== 'classic')
|
||||
ranked.sort((a, b) => b.eloRating - a.eloRating)
|
||||
|
||||
result = { season: null, entries: ranked.map(b => ({
|
||||
botId: b.id,
|
||||
botName: b.name,
|
||||
archetype: b.archetype,
|
||||
tier: b.tier,
|
||||
wins: b.wins,
|
||||
losses: b.losses,
|
||||
eloRating: b.eloRating,
|
||||
avatarSeed: b.avatarSeed,
|
||||
winStreak: b.winStreak,
|
||||
})) }
|
||||
}
|
||||
|
||||
// All-time: fall through to default bot list sorted by Elo
|
||||
const allBots = await db.select({
|
||||
id: schema.bots.id,
|
||||
name: schema.bots.name,
|
||||
avatarSeed: schema.bots.avatarSeed,
|
||||
archetype: schema.bots.archetype,
|
||||
eloRating: schema.bots.eloRating,
|
||||
wins: schema.bots.wins,
|
||||
losses: schema.bots.losses,
|
||||
winStreak: schema.bots.winStreak,
|
||||
tier: schema.bots.tier,
|
||||
botType: schema.bots.botType,
|
||||
}).from(schema.bots)
|
||||
|
||||
const ranked = allBots.filter(b => b.botType !== 'classic')
|
||||
ranked.sort((a, b) => b.eloRating - a.eloRating)
|
||||
|
||||
return c.json({ season: null, entries: ranked.map(b => ({
|
||||
botId: b.id,
|
||||
botName: b.name,
|
||||
archetype: b.archetype,
|
||||
tier: b.tier,
|
||||
wins: b.wins,
|
||||
losses: b.losses,
|
||||
eloRating: b.eloRating,
|
||||
avatarSeed: b.avatarSeed,
|
||||
winStreak: b.winStreak,
|
||||
})) })
|
||||
leaderboardCache.set(cacheKey, { data: result, expiresAt: now + LEADERBOARD_TTL_MS })
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
botsRouter.get('/:name/stats', async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user