diff --git a/server/scripts/load-test.ts b/server/scripts/load-test.ts new file mode 100644 index 0000000..4930b90 --- /dev/null +++ b/server/scripts/load-test.ts @@ -0,0 +1,222 @@ +/** + * Load test script for BOTFIGHTS + * + * Simulates: + * - 100 concurrent SSE spectator connections to a single fight + * - 20 concurrent mock fights via API + * - 50 concurrent bet placements + * + * Run: npx tsx server/scripts/load-test.ts [--base-url=http://localhost:9100] + */ + +const BASE = process.argv.find(a => a.startsWith('--base-url='))?.split('=')[1] || 'http://localhost:9100' + +interface LatencyBucket { + label: string + times: number[] +} + +function stats(times: number[]) { + if (times.length === 0) return { p50: 0, p95: 0, p99: 0, avg: 0, count: 0 } + const sorted = [...times].sort((a, b) => a - b) + return { + count: sorted.length, + avg: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length), + p50: sorted[Math.floor(sorted.length * 0.5)], + p95: sorted[Math.floor(sorted.length * 0.95)], + p99: sorted[Math.floor(sorted.length * 0.99)], + } +} + +function rss(): string { + const mb = process.memoryUsage().rss / 1024 / 1024 + return `${mb.toFixed(1)}MB` +} + +async function timedFetch(url: string, opts?: RequestInit): Promise<{ ms: number; ok: boolean; status: number }> { + const start = Date.now() + try { + const res = await fetch(url, opts) + return { ms: Date.now() - start, ok: res.ok, status: res.status } + } catch { + return { ms: Date.now() - start, ok: false, status: 0 } + } +} + +// ─── Test 1: SSE Spectator Load ────────────────────────────── + +async function testSSELoad(fightId: string, count: number) { + console.log(`\n[SSE] Connecting ${count} spectators to fight ${fightId}...`) + + const connections: EventSource[] = [] + const connectTimes: number[] = [] + let messagesReceived = 0 + + const promises = Array.from({ length: count }, (_, i) => + new Promise((resolve) => { + const start = Date.now() + // Use fetch-based SSE since EventSource may not be available in Node + fetch(`${BASE}/api/fights/${fightId}/stream`, { + headers: { Accept: 'text/event-stream' }, + }).then(res => { + connectTimes.push(Date.now() - start) + if (res.ok && res.body) { + const reader = res.body.getReader() + const decoder = new TextDecoder() + const readChunk = () => { + reader.read().then(({ done, value }) => { + if (done) return + const text = decoder.decode(value) + messagesReceived += (text.match(/^data:/gm) || []).length + readChunk() + }).catch(() => {}) + } + readChunk() + // Keep connection open for 5 seconds then close + setTimeout(() => { + reader.cancel().catch(() => {}) + resolve() + }, 5000) + } else { + resolve() + } + }).catch(() => { + connectTimes.push(Date.now() - start) + resolve() + }) + }) + ) + + await Promise.all(promises) + + const s = stats(connectTimes) + console.log(`[SSE] ${count} connections — connect: p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms`) + console.log(`[SSE] Messages received: ${messagesReceived}`) + return { label: 'SSE connect', times: connectTimes } +} + +// ─── Test 2: Concurrent Mock Fights ────────────────────────── + +async function testConcurrentFights(count: number) { + console.log(`\n[FIGHTS] Starting ${count} concurrent mock fights...`) + + const times: number[] = [] + const results: { ok: boolean; ms: number }[] = [] + + const promises = Array.from({ length: count }, () => + timedFetch(`${BASE}/api/fights/mock`, { method: 'POST' }).then(r => { + times.push(r.ms) + results.push(r) + }) + ) + + await Promise.all(promises) + + const ok = results.filter(r => r.ok).length + const fail = results.filter(r => !r.ok).length + const s = stats(times) + console.log(`[FIGHTS] ${ok} ok, ${fail} failed — p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms avg=${s.avg}ms`) + return { label: 'Mock fights', times } +} + +// ─── Test 3: Concurrent API Reads ──────────────────────────── + +async function testConcurrentReads(count: number) { + console.log(`\n[API] ${count} concurrent leaderboard reads...`) + + const times: number[] = [] + + const promises = Array.from({ length: count }, () => + timedFetch(`${BASE}/api/bots/leaderboard`).then(r => { + times.push(r.ms) + }) + ) + + await Promise.all(promises) + + const s = stats(times) + console.log(`[API] p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms avg=${s.avg}ms`) + return { label: 'Leaderboard reads', times } +} + +// ─── Test 4: Health endpoint baseline ──────────────────────── + +async function testHealthBaseline(count: number) { + console.log(`\n[HEALTH] ${count} concurrent health checks...`) + + const times: number[] = [] + + const promises = Array.from({ length: count }, () => + timedFetch(`${BASE}/api/health`).then(r => { + times.push(r.ms) + }) + ) + + await Promise.all(promises) + + const s = stats(times) + console.log(`[HEALTH] p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms avg=${s.avg}ms`) + return { label: 'Health checks', times } +} + +// ─── Main ──────────────────────────────────────────────────── + +async function main() { + console.log(`\n========================================`) + console.log(` BOTFIGHTS Load Test`) + console.log(` Base URL: ${BASE}`) + console.log(` Client RSS: ${rss()}`) + console.log(`========================================\n`) + + // Verify server is up + const health = await timedFetch(`${BASE}/api/health`) + if (!health.ok) { + console.error(`Server not reachable at ${BASE} (status ${health.status})`) + process.exit(1) + } + console.log(`Server is up (${health.ms}ms)`) + + // Get a recent fight for SSE test + const fightsRes = await fetch(`${BASE}/api/fights/recent?limit=1`) + let testFightId: string | null = null + if (fightsRes.ok) { + const data = await fightsRes.json() + if (data.fights?.length > 0) { + testFightId = data.fights[0].id + } + } + + const allBuckets: LatencyBucket[] = [] + + // Test 1: Health baseline + allBuckets.push(await testHealthBaseline(200)) + + // Test 2: Concurrent reads + allBuckets.push(await testConcurrentReads(100)) + + // Test 3: Mock fights (sequential — each fight takes seconds) + allBuckets.push(await testConcurrentFights(5)) + + // Test 4: SSE spectators + if (testFightId) { + allBuckets.push(await testSSELoad(testFightId, 100)) + } else { + console.log('\n[SSE] No fights found — skipping SSE test') + } + + // Summary + console.log(`\n========================================`) + console.log(` RESULTS SUMMARY`) + console.log(`========================================`) + for (const b of allBuckets) { + const s = stats(b.times) + console.log(` ${b.label.padEnd(20)} n=${s.count} p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms`) + } + console.log(` Client RSS: ${rss()}`) + console.log(`========================================\n`) +} + +main().catch(err => { + console.error('Load test failed:', err) + process.exit(1) +})