From 8e3252081fd97f16b7193b54c6c48d37880dedac Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 13:21:17 +0800 Subject: [PATCH] chore: add performance evidence summary tool --- README.md | 2 +- .../refactor/performance-benchmark-runbook.md | 10 +- package.json | 2 + scripts/performance-summary-test.js | 111 +++++++++++ scripts/performance-summary.js | 185 ++++++++++++++++++ 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 scripts/performance-summary-test.js create mode 100644 scripts/performance-summary.js diff --git a/README.md b/README.md index 23292021..3a35ecc0 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,7 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" npm run perf:api:local ``` -报告输出到已忽略的 `docs/refactor/performance-reports/`。4 核 16G 云服务器应按压测 runbook 跑 6/30/50/100 阶梯并发,并结合 PostgreSQL 调参文档观察慢 SQL、连接数、锁等待和 P95/P99: +报告输出到已忽略的 `docs/refactor/performance-reports/`。可以用 `npm run perf:summary -- --input --json` 自动提取 `launch:gate` 需要的错误率、P95/P99、并发和时长摘要。4 核 16G 云服务器应按压测 runbook 跑 6/30/50/100 阶梯并发,并结合 PostgreSQL 调参文档观察慢 SQL、连接数、锁等待和 P95/P99: ```text docs/refactor/postgresql-4c16g-tuning.md diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index 943f8fb3..658fd4e8 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -182,6 +182,14 @@ npm run launch:gate - `durationSeconds >= 120` - `includeWrites = false` -这些字段应从 `perf:api:local` JSON 报告的 `summary.errors`、`summary.errorRate`、`summary.latencyOk.p95Ms`、`summary.latencyOk.p99Ms`、`config.concurrency`、`config.durationSeconds`、`config.includeWrites` 转写到证据摘要。更高的 50/100 并发、写入混合场景和容量结论仍应作为人工容量报告归档;门禁只负责挡住明显不达标的基础读路径。 +建议用摘要工具从 `perf:api:local` JSON 报告自动提取门禁字段,避免手工抄错: + +```powershell +npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-20260630-xxxxxx.json --json +``` + +工具会从 `summary.errors`、`summary.errorRate`、`summary.latencyOk.p95Ms`、`summary.latencyOk.p99Ms`、`config.concurrency`、`config.durationSeconds`、`config.includeWrites` 生成 `launchGateCheck.summary`,并按上线门禁阈值返回退出码。通过后,把 `launchGateCheck.summary` 转写到 `production-launch-evidence.json`;`artifact` 保留对应日志或报告路径。 + +更高的 50/100 并发、写入混合场景和容量结论仍应作为人工容量报告归档;门禁只负责挡住明显不达标的基础读路径。 证据中只记录报告路径、并发矩阵、P95/P99、错误率和结论,不保存真实 token、支付密钥、用户隐私或完整响应。 diff --git a/package.json b/package.json index 497eb612..e5151c55 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,11 @@ "test:launch-gate": "node scripts/production-launch-gate-test.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js", + "test:perf:summary": "node scripts/performance-summary-test.js", "readiness:production": "node scripts/production-readiness-check.js --skip-db", "readiness:production:db": "node scripts/production-readiness-check.js --check-db", "launch:gate": "node scripts/production-launch-gate.js", + "perf:summary": "node scripts/performance-summary.js", "test:api:remote": "node scripts/api-integration-test.js", "dev:taro:h5": "npm --workspace @tiku-saas/taro run dev:h5", "build:taro:h5": "npm --workspace @tiku-saas/taro run build:h5", diff --git a/scripts/performance-summary-test.js b/scripts/performance-summary-test.js new file mode 100644 index 00000000..e6f6754f --- /dev/null +++ b/scripts/performance-summary-test.js @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { evaluateSummary, launchGateCheck, summarizeBenchmark } from './performance-summary.js'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'performance-summary.js'); + +function sampleReport(overrides = {}) { + return { + startedAt: '2026-06-30T04:45:08.281Z', + finishedAt: '2026-06-30T04:50:08.281Z', + apiBase: 'https://api.example.com', + config: { + durationSeconds: 300, + concurrency: 30, + rampSeconds: 30, + includeWrites: false, + questionLimit: 20, + timeoutMs: 15000, + ...(overrides.config || {}), + }, + summary: { + requests: 12000, + ok: 12000, + errors: 0, + errorRate: 0, + throughputRps: 40, + okThroughputRps: 40, + latencyOk: { + p50Ms: 35, + p90Ms: 120, + p95Ms: 180, + p99Ms: 430, + maxMs: 700, + ...(overrides.latencyOk || {}), + }, + ...(overrides.summary || {}), + }, + }; +} + +function runScript(report) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-perf-summary-')); + const inputPath = path.join(tempDir, 'report.json'); + fs.writeFileSync(inputPath, JSON.stringify(report, null, 2), 'utf8'); + const result = spawnSync(process.execPath, [scriptPath, '--input', inputPath, '--json'], { + cwd: repoRoot, + encoding: 'utf8', + }); + const payload = JSON.parse(result.stdout || '{}'); + fs.rmSync(tempDir, { recursive: true, force: true }); + return { ...result, payload }; +} + +const summary = summarizeBenchmark(sampleReport()); +assert.deepEqual( + { + errors: summary.errors, + errorRate: summary.errorRate, + p95Ms: summary.p95Ms, + p99Ms: summary.p99Ms, + concurrency: summary.concurrency, + durationSeconds: summary.durationSeconds, + includeWrites: summary.includeWrites, + }, + { + errors: 0, + errorRate: 0, + p95Ms: 180, + p99Ms: 430, + concurrency: 30, + durationSeconds: 300, + includeWrites: false, + }, +); + +const passingEvaluation = evaluateSummary(summary); +assert.equal(passingEvaluation.status, 'pass'); +assert.deepEqual(launchGateCheck(passingEvaluation).summary, { + errors: 0, + errorRate: 0, + p95Ms: 180, + p99Ms: 430, + concurrency: 30, + durationSeconds: 300, + includeWrites: false, +}); + +const okRun = runScript(sampleReport()); +assert.equal(okRun.status, 0, `passing report should exit 0: ${okRun.stdout} ${okRun.stderr}`); +assert.equal(okRun.payload.evaluation?.status, 'pass'); + +const slowRun = runScript(sampleReport({ latencyOk: { p95Ms: 301 } })); +assert.notEqual(slowRun.status, 0, 'slow report should fail'); +assert.equal(slowRun.payload.evaluation?.status, 'fail'); +assert.ok( + slowRun.payload.evaluation?.failures?.some(item => item.includes('p95Ms expected <= 300')), + 'slow report should explain p95 threshold failure', +); + +const writeRun = runScript(sampleReport({ config: { includeWrites: true } })); +assert.notEqual(writeRun.status, 0, 'write benchmark should fail read-path gate'); +assert.ok( + writeRun.payload.evaluation?.failures?.some(item => item.includes('includeWrites expected false')), + 'write benchmark should explain includeWrites failure', +); + +console.log('[PASS] performance summary'); diff --git a/scripts/performance-summary.js b/scripts/performance-summary.js new file mode 100644 index 00000000..aa9f4e7d --- /dev/null +++ b/scripts/performance-summary.js @@ -0,0 +1,185 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const defaultThresholds = { + errors: 0, + errorRate: 0.001, + p95Ms: 300, + p99Ms: 800, + concurrency: 30, + durationSeconds: 120, +}; + +function parseArgs(argv) { + const options = { + input: '', + output: '', + json: false, + quiet: false, + thresholds: { ...defaultThresholds }, + }; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (arg === '--quiet') options.quiet = true; + else if (arg === '--input') { + options.input = argv[index + 1] || ''; + index += 1; + } else if (arg.startsWith('--input=')) { + options.input = arg.slice('--input='.length); + } else if (arg === '--output') { + options.output = argv[index + 1] || ''; + index += 1; + } else if (arg.startsWith('--output=')) { + options.output = arg.slice('--output='.length); + } else if (arg === '--max-p95-ms') { + options.thresholds.p95Ms = numberArg(argv[index + 1], options.thresholds.p95Ms); + index += 1; + } else if (arg.startsWith('--max-p95-ms=')) { + options.thresholds.p95Ms = numberArg(arg.slice('--max-p95-ms='.length), options.thresholds.p95Ms); + } else if (arg === '--max-p99-ms') { + options.thresholds.p99Ms = numberArg(argv[index + 1], options.thresholds.p99Ms); + index += 1; + } else if (arg.startsWith('--max-p99-ms=')) { + options.thresholds.p99Ms = numberArg(arg.slice('--max-p99-ms='.length), options.thresholds.p99Ms); + } else if (arg === '--max-error-rate') { + options.thresholds.errorRate = numberArg(argv[index + 1], options.thresholds.errorRate); + index += 1; + } else if (arg.startsWith('--max-error-rate=')) { + options.thresholds.errorRate = numberArg(arg.slice('--max-error-rate='.length), options.thresholds.errorRate); + } else if (arg === '--min-concurrency') { + options.thresholds.concurrency = numberArg(argv[index + 1], options.thresholds.concurrency); + index += 1; + } else if (arg.startsWith('--min-concurrency=')) { + options.thresholds.concurrency = numberArg(arg.slice('--min-concurrency='.length), options.thresholds.concurrency); + } else if (arg === '--min-duration-seconds') { + options.thresholds.durationSeconds = numberArg(argv[index + 1], options.thresholds.durationSeconds); + index += 1; + } else if (arg.startsWith('--min-duration-seconds=')) { + options.thresholds.durationSeconds = numberArg(arg.slice('--min-duration-seconds='.length), options.thresholds.durationSeconds); + } else if (!arg.startsWith('-') && !options.input) { + options.input = arg; + } + } + return options; +} + +function numberArg(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function numberValue(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function booleanValue(value) { + return value === true || value === 'true'; +} + +function summarizeBenchmark(report) { + const summary = report.summary || {}; + const latencyOk = summary.latencyOk || {}; + const config = report.config || {}; + return { + errors: numberValue(summary.errors), + errorRate: numberValue(summary.errorRate), + p95Ms: numberValue(latencyOk.p95Ms), + p99Ms: numberValue(latencyOk.p99Ms), + concurrency: numberValue(config.concurrency), + durationSeconds: numberValue(config.durationSeconds), + includeWrites: booleanValue(config.includeWrites), + requests: numberValue(summary.requests), + ok: numberValue(summary.ok), + throughputRps: numberValue(summary.throughputRps), + apiBase: report.apiBase || '', + startedAt: report.startedAt || '', + finishedAt: report.finishedAt || '', + }; +} + +function evaluateSummary(summary, thresholds = defaultThresholds) { + const failures = []; + if (summary.errors !== thresholds.errors) failures.push(`errors expected ${thresholds.errors} but got ${summary.errors}`); + if (summary.errorRate > thresholds.errorRate) failures.push(`errorRate expected <= ${thresholds.errorRate} but got ${summary.errorRate}`); + if (summary.p95Ms > thresholds.p95Ms) failures.push(`p95Ms expected <= ${thresholds.p95Ms} but got ${summary.p95Ms}`); + if (summary.p99Ms > thresholds.p99Ms) failures.push(`p99Ms expected <= ${thresholds.p99Ms} but got ${summary.p99Ms}`); + if (summary.concurrency < thresholds.concurrency) failures.push(`concurrency expected >= ${thresholds.concurrency} but got ${summary.concurrency}`); + if (summary.durationSeconds < thresholds.durationSeconds) failures.push(`durationSeconds expected >= ${thresholds.durationSeconds} but got ${summary.durationSeconds}`); + if (summary.includeWrites !== false) failures.push('includeWrites expected false'); + return { + status: failures.length ? 'fail' : 'pass', + failures, + summary, + thresholds, + }; +} + +function launchGateCheck(evaluation) { + return { + id: 'performance.api-real-data-read', + status: evaluation.status, + command: 'npm run perf:api:local', + completedAt: new Date().toISOString(), + artifact: '', + summary: { + errors: evaluation.summary.errors, + errorRate: evaluation.summary.errorRate, + p95Ms: evaluation.summary.p95Ms, + p99Ms: evaluation.summary.p99Ms, + concurrency: evaluation.summary.concurrency, + durationSeconds: evaluation.summary.durationSeconds, + includeWrites: evaluation.summary.includeWrites, + }, + }; +} + +async function main() { + const options = parseArgs(process.argv); + if (!options.input) { + console.error('Usage: node scripts/performance-summary.js --input docs/refactor/performance-reports/api-benchmark-xxx.json [--json]'); + process.exitCode = 1; + return; + } + + const inputPath = path.resolve(process.cwd(), options.input); + const report = readJson(inputPath); + const summary = summarizeBenchmark(report); + const evaluation = evaluateSummary(summary, options.thresholds); + const payload = { + input: inputPath, + evaluation, + launchGateCheck: { + ...launchGateCheck(evaluation), + artifact: options.input, + }, + }; + + if (options.output) { + const outputPath = path.resolve(process.cwd(), options.output); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + } + + if (options.json) { + console.log(JSON.stringify(payload, null, 2)); + } else if (!options.quiet) { + console.log(`[${evaluation.status.toUpperCase()}] ${path.basename(inputPath)} p95=${summary.p95Ms}ms p99=${summary.p99Ms}ms errors=${summary.errors} errorRate=${summary.errorRate} concurrency=${summary.concurrency} duration=${summary.durationSeconds}s`); + for (const failure of evaluation.failures) console.log(`- ${failure}`); + } + + if (evaluation.status !== 'pass') process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} + +export { defaultThresholds, evaluateSummary, launchGateCheck, summarizeBenchmark };