Files
botfights/server/scripts/load-test.ts
T
2026-03-09 00:30:43 +00:00

223 lines
7.0 KiB
TypeScript

/**
* 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<void>((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)
})