api-server / tests /stress /run-stress-test.js
XWX-AI's picture
fix(widget): DOCX图表fallback成数据表格根因修复 — widget渲染浏览器池+CDP有限超时+渲染硬超时+队列超时取消+BrowserPool补位bug+body-parser JSON兜底, bump v2.1.11
24a2ddf
Raw
History Blame Contribute Delete
8.86 kB
/**
* 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);
});