Spaces:
Running
Running
| /** | |
| * Stress test for the PDF export backend (/api/generate_pdf). | |
| * | |
| * Measures how many concurrent PDF exports the server can handle: | |
| * - success rate | |
| * - latency distribution (avg / p50 / p95 / max) | |
| * - throughput (req/min) | |
| * | |
| * Usage: | |
| * node stress/run-stress-test.js --base http://localhost:17861 --concurrency 1,2,4,6,8 --total 20 --profile medium | |
| * | |
| * Each concurrency level fires `total` requests in waves of `concurrency` | |
| * parallel requests. Between levels the server is given a rest period | |
| * (--settle-ms) so memory/CPU can drain back down. | |
| * | |
| * Output: JSON report written to stress/results/. | |
| */ | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const http = require('http'); | |
| const { prepareProfile } = require('./payload-generator'); | |
| function parseArgs(argv) { | |
| const args = { concurrency: '1,2,4,6,8', total: 20, base: 'http://localhost:17861', profile: 'medium', settleMs: 10000, tag: '', timeoutMs: 180000 }; | |
| for (let i = 0; i < argv.length; i++) { | |
| const m = argv[i].match(/^--([^=]+)(?:=(.*))?$/); | |
| if (!m) continue; | |
| if (m[2] !== undefined) { | |
| args[m[1]] = m[2]; | |
| } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { | |
| args[m[1]] = argv[++i]; | |
| } else { | |
| args[m[1]] = true; | |
| } | |
| } | |
| return args; | |
| } | |
| function percentile(sorted, p) { | |
| if (sorted.length === 0) return 0; | |
| const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); | |
| return sorted[idx]; | |
| } | |
| function sendPdfRequest(baseUrl, requestBody, timeoutMs) { | |
| const url = new URL('/api/generate_pdf', baseUrl); | |
| const payload = JSON.stringify(requestBody); | |
| return new Promise((resolve) => { | |
| const started = Date.now(); | |
| const options = { | |
| hostname: url.hostname, | |
| port: url.port || 80, | |
| path: url.pathname, | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Content-Length': Buffer.byteLength(payload), | |
| }, | |
| timeout: timeoutMs, | |
| }; | |
| const req = http.request(options, (res) => { | |
| const chunks = []; | |
| res.on('data', (c) => chunks.push(c)); | |
| res.on('end', () => { | |
| const elapsed = Date.now() - started; | |
| const body = Buffer.concat(chunks); | |
| if (res.statusCode === 200) { | |
| resolve({ ok: true, status: 200, elapsed, pdfBytes: body.length }); | |
| } else { | |
| resolve({ ok: false, status: res.statusCode, elapsed, error: body.toString().substring(0, 500) }); | |
| } | |
| }); | |
| res.on('error', (e) => { | |
| resolve({ ok: false, status: 0, elapsed: Date.now() - started, error: e.message }); | |
| }); | |
| }); | |
| req.on('error', (e) => { | |
| resolve({ ok: false, status: 0, elapsed: Date.now() - started, error: e.message }); | |
| }); | |
| req.on('timeout', () => { | |
| req.destroy(new Error(`timeout after ${timeoutMs}ms`)); | |
| }); | |
| req.write(payload); | |
| req.end(); | |
| }); | |
| } | |
| function fireWave(baseUrl, requestBody, concurrency, timeoutMs, onDone) { | |
| return new Promise((resolve) => { | |
| const results = []; | |
| let active = 0; | |
| let queued = concurrency; | |
| let done = 0; | |
| function startOne() { | |
| active++; | |
| sendPdfRequest(baseUrl, requestBody, timeoutMs).then((r) => { | |
| results.push(r); | |
| active--; | |
| done++; | |
| if (onDone) onDone(r); | |
| if (queued > 0) { queued--; startOne(); } | |
| else if (active === 0) resolve(results); | |
| }); | |
| } | |
| for (let i = 0; i < concurrency && queued > 0; i++) { queued--; startOne(); } | |
| }); | |
| } | |
| function sleep(ms) { | |
| return new Promise((r) => setTimeout(r, ms)); | |
| } | |
| async function runConcurrencyLevel(baseUrl, requestBody, concurrency, total, timeoutMs, settleMs, onProgress) { | |
| const waves = Math.ceil(total / concurrency); | |
| const all = []; | |
| const startTs = Date.now(); | |
| for (let w = 0; w < waves; w++) { | |
| const remaining = total - all.length; | |
| const thisWave = Math.min(concurrency, remaining); | |
| const waveResults = await fireWave(baseUrl, requestBody, thisWave, timeoutMs, onProgress); | |
| all.push(...waveResults); | |
| } | |
| const durationMs = Date.now() - startTs; | |
| await sleep(settleMs); | |
| return analyze(all, durationMs); | |
| } | |
| function analyze(results, durationMs) { | |
| const ok = results.filter((r) => r.ok); | |
| const fail = results.filter((r) => !r.ok); | |
| const latencies = ok.map((r) => r.elapsed).sort((a, b) => a - b); | |
| const totalOk = ok.length; | |
| const totalFail = fail.length; | |
| // Wall-clock throughput: requests actually completed per minute across the level. | |
| const rate = durationMs > 0 ? (totalOk / (durationMs / 60000)) : 0; | |
| const errorBreakdown = {}; | |
| for (const f of fail) { | |
| const key = f.status === 500 ? '500' : (f.status === 0 ? 'conn/' + (f.error || 'unknown') : String(f.status)); | |
| errorBreakdown[key] = (errorBreakdown[key] || 0) + 1; | |
| } | |
| return { | |
| totalRequests: results.length, | |
| success: totalOk, | |
| failed: totalFail, | |
| successRate: results.length ? (totalOk / results.length) : 0, | |
| latencyMs: { | |
| avg: latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0, | |
| p50: percentile(latencies, 50), | |
| p90: percentile(latencies, 90), | |
| p95: percentile(latencies, 95), | |
| p99: percentile(latencies, 99), | |
| max: latencies.length ? latencies[latencies.length - 1] : 0, | |
| min: latencies.length ? latencies[0] : 0, | |
| }, | |
| throughputPerMin: Math.round(rate), | |
| errorBreakdown, | |
| durationMs, | |
| }; | |
| } | |
| async function main() { | |
| const args = parseArgs(process.argv.slice(2)); | |
| const baseUrl = args.base.replace(/\/$/, ''); | |
| const concurrencyLevels = args.concurrency.split(',').map((n) => parseInt(n, 10)).filter((n) => n > 0); | |
| const total = parseInt(args.total, 10); | |
| const timeoutMs = parseInt(args.timeoutMs, 10); | |
| const settleMs = parseInt(args.settleMs, 10); | |
| const profileName = args.profile; | |
| const profileDefs = { | |
| small: { name: 'small', opts: { textMB: 0.05, images: 0, codeRatio: 0.3 } }, | |
| medium: { name: 'medium', opts: { textMB: 0.3, images: 3, codeRatio: 0.35 } }, | |
| large: { name: 'large', opts: { textMB: 1.0, images: 10, codeRatio: 0.4 } }, | |
| }; | |
| const profileDef = profileDefs[profileName]; | |
| if (!profileDef) { | |
| console.error(`Unknown profile: ${profileName}. Choose from ${Object.keys(profileDefs).join(', ')}`); | |
| process.exit(1); | |
| } | |
| const profile = prepareProfile(profileDef); | |
| const requestBody = { ...profile.requestBody, html: profile.html }; | |
| console.log(`Profile: ${profileName} html=${profile.htmlSizeMB.toFixed(3)} MB text=${profile.textOnlySizeMB.toFixed(3)} MB images=${profile.imageCount}`); | |
| console.log(`Base URL: ${baseUrl}`); | |
| console.log(`Concurrency levels: ${concurrencyLevels.join(', ')} total/level: ${total}\n`); | |
| const report = { | |
| generatedAt: new Date().toISOString(), | |
| tag: args.tag, | |
| baseUrl, | |
| profile: { name: profileName, htmlSizeMB: profile.htmlSizeMB, textOnlySizeMB: profile.textOnlySizeMB, imageCount: profile.imageCount }, | |
| concurrencyLevels, | |
| totalPerLevel: total, | |
| levels: {}, | |
| }; | |
| const allRequests = []; | |
| for (const conc of concurrencyLevels) { | |
| process.stdout.write(`\n=== Concurrency ${conc} (${total} requests) ===\n`); | |
| const level = await runConcurrencyLevel(baseUrl, requestBody, conc, total, timeoutMs, settleMs, (r) => { | |
| allRequests.push(r); | |
| process.stdout.write(r.ok ? '.' : 'X'); | |
| }); | |
| process.stdout.write('\n'); | |
| report.levels[String(conc)] = level; | |
| const l = level; | |
| console.log(` success=${l.success}/${l.totalRequests} (${(l.successRate * 100).toFixed(1)}%) ` + | |
| `latency avg=${l.latencyMs.avg}ms p50=${l.latencyMs.p50}ms p95=${l.latencyMs.p95}ms max=${l.latencyMs.max}ms ` + | |
| `throughput=${l.throughputPerMin}/min errors=${JSON.stringify(l.errorBreakdown)}`); | |
| } | |
| // Summary table | |
| console.log('\n=== Summary ==='); | |
| console.log('Concurrency | Success | Rate% | avg(ms) | p50(ms) | p95(ms) | max(ms) | req/min |'); | |
| console.log('------------|---------|--------|---------|---------|---------|---------|---------|'); | |
| for (const conc of concurrencyLevels) { | |
| const l = report.levels[String(conc)]; | |
| console.log(`${String(conc).padEnd(11)}| ${String(l.success).padEnd(8)}| ${(l.successRate * 100).toFixed(1).padEnd(7)}| ${String(l.latencyMs.avg).padEnd(8)}| ${String(l.latencyMs.p50).padEnd(8)}| ${String(l.latencyMs.p95).padEnd(8)}| ${String(l.latencyMs.max).padEnd(8)}| ${String(l.throughputPerMin).padEnd(8)}|`); | |
| } | |
| const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); | |
| const tagSuffix = args.tag ? `-${args.tag}` : ''; | |
| const outFile = path.join(__dirname, 'results', `stress-${profileName}${tagSuffix}-${stamp}.json`); | |
| fs.writeFileSync(outFile, JSON.stringify(report, null, 2)); | |
| console.log(`\nReport saved: ${outFile}`); | |
| } | |
| main().catch((e) => { | |
| console.error(e); | |
| process.exit(1); | |
| }); | |