223 lines
7.0 KiB
TypeScript
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)
|
|
})
|