From 458c0be94fe283287af5ee60ca8973ad64af5dc8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 06:19:59 +0800 Subject: [PATCH] test: record docker benchmark resource evidence --- README.md | 2 +- .../refactor/performance-benchmark-runbook.md | 18 + package.json | 2 +- ...docker-benchmark-resource-evidence-test.js | 32 ++ scripts/run-docker-4c16g-benchmark.js | 328 +++++++++++++++++- 5 files changed, 365 insertions(+), 17 deletions(-) create mode 100644 scripts/docker-benchmark-resource-evidence-test.js diff --git a/README.md b/README.md index e11f73cf..6f96acd0 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,7 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" npm run perf:api:docker-4c16g ``` -注意:这个入口只限制 API 容器资源,本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。 +注意:这个入口默认只限制 API 容器资源,并会生成 `docker-4c16g-resource-evidence-*.json/md` 记录 Docker Desktop、API 容器和 Supabase DB 容器的资源上下文;本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。需要更贴近本地 4C16G 模拟时,可以显式设置 `BENCHMARK_LIMIT_DB_RESOURCES=true`、`BENCHMARK_DB_CPUS` 和 `BENCHMARK_DB_MEMORY`,脚本默认会在结束后恢复 DB 容器限制。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。 PostgreSQL 调参与运行证据采集: diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index f0970590..83a030a4 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -186,6 +186,24 @@ Remove-Item Env:\DB_POOL_MAX 这个入口模拟的是“4 核 16G shared-host 中 API 容器的资源约束”,不是完整云服务器复刻。当前本地 Supabase/PostgreSQL 仍跑在 Docker Desktop 的 Supabase stack 中,除非额外手工限制 DB 容器资源,否则数据库容器仍可能使用 Docker Desktop 的全局资源。正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、真实对象存储/CDN 和真实网络下复跑。 +每次运行都会额外生成 `docker-4c16g-resource-evidence-*.json/md`,记录 Docker Desktop 总 CPU/内存、API 容器实际 CPU/内存限制、DB 容器是否被限制、`DB_POOL_MAX` 和每个压测报告的摘要。该文件位于已忽略的 `docs/refactor/performance-reports/`,用于防止后续把“只限制 API 容器”的本地结果误写成完整 4 核 16G 生产容量;它不能替代目标云服务器正式压测证据。 + +如需要在本地显式限制 Supabase PostgreSQL 容器资源,可以打开下面开关。脚本默认会在结束后恢复 DB 容器原始限制;如果你希望保留限制用于排查,才设置 `BENCHMARK_KEEP_DB_LIMIT=true`。 + +```powershell +$env:BENCHMARK_LIMIT_DB_RESOURCES="true" +$env:BENCHMARK_DB_CONTAINER="supabase_db_tiku-saas-local" +$env:BENCHMARK_DB_CPUS="2.0" +$env:BENCHMARK_DB_MEMORY="8g" +npm run perf:api:docker-4c16g +Remove-Item Env:\BENCHMARK_LIMIT_DB_RESOURCES +Remove-Item Env:\BENCHMARK_DB_CONTAINER +Remove-Item Env:\BENCHMARK_DB_CPUS +Remove-Item Env:\BENCHMARK_DB_MEMORY +``` + +注意:Docker `update` 只能限制已有容器的 cgroup 资源,不会替代 PostgreSQL 参数调优。限制 DB 容器后仍必须跑 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json` 检查 `shared_buffers`、`work_mem`、超时、JIT、`pg_stat_statements` 和 `pending_restart`。 + 如需保留 API 容器便于排查: ```powershell diff --git a/package.json b/package.json index 9f264b28..58020cd1 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", diff --git a/scripts/docker-benchmark-resource-evidence-test.js b/scripts/docker-benchmark-resource-evidence-test.js new file mode 100644 index 00000000..11a15f55 --- /dev/null +++ b/scripts/docker-benchmark-resource-evidence-test.js @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, 'scripts', 'run-docker-4c16g-benchmark.js'); +const runbookPath = path.join(repoRoot, 'docs', 'refactor', 'performance-benchmark-runbook.md'); +const readmePath = path.join(repoRoot, 'README.md'); + +const script = fs.readFileSync(scriptPath, 'utf8'); +const runbook = fs.readFileSync(runbookPath, 'utf8'); +const readme = fs.readFileSync(readmePath, 'utf8'); + +assert.match(script, /docker-4c16g-resource-evidence-\$\{shanghaiStamp\(\)\}\.json/, 'benchmark script should write resource evidence JSON'); +assert.match(script, /BENCHMARK_LIMIT_DB_RESOURCES/, 'benchmark script should expose explicit DB resource limiting'); +assert.match(script, /BENCHMARK_KEEP_DB_LIMIT/, 'benchmark script should support keeping DB limits only when requested'); +assert.match(script, /dockerJson\(\['info', '--format', '\{\{json \.\}\}'\]\)/, 'benchmark script should record Docker Desktop resources'); +assert.match(script, /inspectContainer\(composeServiceContainerId\('api'\)\)/, 'benchmark script should inspect the API container after compose starts'); +assert.match(script, /inspectContainer\(dbContainerName\)/, 'benchmark script should inspect the Supabase DB container'); +assert.match(script, /restoreDbLimit\(dbRestore, resourceEvidence\)/, 'benchmark script should restore DB resource limits by default'); +assert.match(script, /readBenchmarkSummary\(report\)/, 'benchmark script should attach benchmark summaries to resource evidence'); + +assert.match(runbook, /docker-4c16g-resource-evidence-\*\.json\/md/, 'runbook should document the resource evidence artifact'); +assert.match(runbook, /BENCHMARK_LIMIT_DB_RESOURCES="true"/, 'runbook should document explicit DB resource limiting'); +assert.match(runbook, /默认会在结束后恢复 DB 容器原始限制/, 'runbook should document DB limit restoration'); +assert.match(runbook, /不能替代目标云服务器正式压测证据/, 'runbook should keep local benchmark scope explicit'); + +assert.match(readme, /docker-4c16g-resource-evidence-\*\.json\/md/, 'README should mention resource evidence artifacts'); +assert.match(readme, /BENCHMARK_LIMIT_DB_RESOURCES=true/, 'README should mention optional DB resource limiting'); +assert.match(readme, /正式容量承诺仍要在目标 4 核 16G 云服务器复跑/, 'README should avoid overstating local Docker capacity'); + +console.log('[PASS] docker benchmark resource evidence guardrails'); diff --git a/scripts/run-docker-4c16g-benchmark.js b/scripts/run-docker-4c16g-benchmark.js index bfc8cecf..6fafb2a5 100644 --- a/scripts/run-docker-4c16g-benchmark.js +++ b/scripts/run-docker-4c16g-benchmark.js @@ -5,6 +5,14 @@ import path from 'node:path'; const repoRoot = process.cwd(); const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const apiBase = process.env.PERF_API_BASE || 'http://127.0.0.1:8787'; +const outputDir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports'); +const dbContainerName = process.env.BENCHMARK_DB_CONTAINER || 'supabase_db_tiku-saas-local'; +const shouldLimitDb = process.env.BENCHMARK_LIMIT_DB_RESOURCES === 'true'; +const shouldKeepDbLimit = process.env.BENCHMARK_KEEP_DB_LIMIT === 'true'; +const dbLimit = { + cpus: process.env.BENCHMARK_DB_CPUS || '2.0', + memory: process.env.BENCHMARK_DB_MEMORY || '8g', +}; const matrix = [ { name: 'read-30', duration: 120, concurrency: 30, ramp: 15, writes: false, ratio: 0 }, { name: 'mixed-50', duration: 60, concurrency: 50, ramp: 10, writes: true, ratio: 0.1 }, @@ -12,32 +20,217 @@ const matrix = [ { name: 'mixed-150', duration: 60, concurrency: 150, ramp: 20, writes: true, ratio: 0.06 }, ]; +function baseEnv(extra = {}) { + return { + ...process.env, + DATABASE_URL: databaseUrl, + AUTH_SESSION_SECRET: process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me', + DB_POOL_MAX: process.env.DB_POOL_MAX || '10', + BENCHMARK_API_CPUS: process.env.BENCHMARK_API_CPUS || '2.0', + BENCHMARK_API_MEMORY: process.env.BENCHMARK_API_MEMORY || '4g', + ...extra, + }; +} + function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: repoRoot, stdio: 'inherit', shell: process.platform === 'win32', - env: { - ...process.env, - DATABASE_URL: databaseUrl, - AUTH_SESSION_SECRET: process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me', - DB_POOL_MAX: process.env.DB_POOL_MAX || '10', - BENCHMARK_API_CPUS: process.env.BENCHMARK_API_CPUS || '2.0', - BENCHMARK_API_MEMORY: process.env.BENCHMARK_API_MEMORY || '4g', - ...options.env, - }, + env: baseEnv(options.env), }); if (result.status !== 0) { throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`); } } +function runCapture(command, args, options = {}) { + return spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + shell: process.platform === 'win32', + env: baseEnv(options.env), + }); +} + +function dockerJson(args) { + const result = runCapture('docker', args); + if (result.status !== 0) { + return { unavailable: true, message: `${result.stderr || result.stdout || `docker ${args.join(' ')} failed`}`.trim() }; + } + try { + return JSON.parse(result.stdout || '{}'); + } catch (error) { + return { unavailable: true, message: error?.message || String(error), raw: result.stdout }; + } +} + +function dockerText(args) { + const result = runCapture('docker', args); + if (result.status !== 0) return ''; + return String(result.stdout || '').trim(); +} + +function redactUrl(value) { + return String(value || '').replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'); +} + +function formatBytes(bytes) { + const value = Number(bytes || 0); + if (!Number.isFinite(value) || value <= 0) return value === 0 ? 'unlimited' : ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let next = value; + let unitIndex = 0; + while (next >= 1024 && unitIndex < units.length - 1) { + next /= 1024; + unitIndex += 1; + } + return `${next >= 10 ? next.toFixed(0) : next.toFixed(1)}${units[unitIndex]}`; +} + +function shanghaiStamp(date = new Date()) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date).map(part => [part.type, part.value]), + ); + return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`; +} + +function dockerInfoSummary() { + const info = dockerJson(['info', '--format', '{{json .}}']); + if (info.unavailable) return info; + return { + operatingSystem: info.OperatingSystem, + serverVersion: info.ServerVersion, + ncpu: info.NCPU, + memoryBytes: info.MemTotal, + memory: formatBytes(info.MemTotal), + cgroupVersion: info.CgroupVersion, + context: info.ClientInfo?.Context, + }; +} + +function resourceSummaryFromHostConfig(hostConfig = {}) { + const nanoCpus = Number(hostConfig.NanoCpus || 0); + const memoryBytes = Number(hostConfig.Memory || 0); + const memorySwapBytes = Number(hostConfig.MemorySwap || 0); + return { + cpus: nanoCpus > 0 ? Number((nanoCpus / 1_000_000_000).toFixed(2)) : null, + memoryBytes, + memory: formatBytes(memoryBytes), + memorySwapBytes, + memorySwap: memorySwapBytes === -1 ? 'unlimited' : formatBytes(memorySwapBytes), + cpuQuota: hostConfig.CpuQuota || 0, + cpuPeriod: hostConfig.CpuPeriod || 0, + raw: { + nanoCpus, + memory: memoryBytes, + memorySwap: memorySwapBytes, + }, + }; +} + +function inspectContainer(container) { + if (!container) return { unavailable: true, message: 'container name/id is empty' }; + const inspect = dockerJson(['inspect', container]); + if (inspect.unavailable) return inspect; + const item = Array.isArray(inspect) ? inspect[0] : inspect; + if (!item) return { unavailable: true, message: `container ${container} not found` }; + return { + id: item.Id, + name: String(item.Name || '').replace(/^\//, ''), + image: item.Config?.Image || '', + state: item.State?.Status || '', + resources: resourceSummaryFromHostConfig(item.HostConfig), + }; +} + +function composeServiceContainerId(service) { + return dockerText(['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'ps', '-q', service]) + .split(/\r?\n/) + .map(item => item.trim()) + .filter(Boolean)[0] || ''; +} + +function updateContainerResources(container, resources) { + const args = ['update', '--cpus', resources.cpus, '--memory', resources.memory, '--memory-swap', resources.memorySwap, container]; + const result = runCapture('docker', args); + if (result.status !== 0) { + throw new Error(`docker ${args.join(' ')} failed: ${result.stderr || result.stdout}`); + } +} + +function maybeApplyDbLimit(resourceEvidence) { + resourceEvidence.database = { + containerName: dbContainerName, + limitRequested: shouldLimitDb, + requestedLimit: { + cpus: shouldLimitDb ? dbLimit.cpus : null, + memory: shouldLimitDb ? dbLimit.memory : null, + }, + before: inspectContainer(dbContainerName), + }; + + if (!shouldLimitDb) { + resourceEvidence.database.note = 'DB container resources were not changed. Local Supabase/PostgreSQL may still use Docker Desktop global resources.'; + return null; + } + + if (resourceEvidence.database.before.unavailable) { + resourceEvidence.database.limitApplied = false; + resourceEvidence.database.limitError = resourceEvidence.database.before.message; + return null; + } + + const beforeRaw = resourceEvidence.database.before.resources.raw; + const restore = { + cpus: beforeRaw.nanoCpus > 0 ? String(beforeRaw.nanoCpus / 1_000_000_000) : '0', + memory: String(beforeRaw.memory || 0), + memorySwap: String(beforeRaw.memorySwap || 0), + }; + + try { + updateContainerResources(dbContainerName, { + cpus: dbLimit.cpus, + memory: dbLimit.memory, + memorySwap: dbLimit.memory, + }); + resourceEvidence.database.limitApplied = true; + resourceEvidence.database.afterLimit = inspectContainer(dbContainerName); + resourceEvidence.database.restorePlanned = !shouldKeepDbLimit; + return restore; + } catch (error) { + resourceEvidence.database.limitApplied = false; + resourceEvidence.database.limitError = error?.message || String(error); + return null; + } +} + +function restoreDbLimit(restore, resourceEvidence) { + if (!restore || shouldKeepDbLimit) return; + try { + updateContainerResources(dbContainerName, restore); + resourceEvidence.database.restored = true; + resourceEvidence.database.afterRestore = inspectContainer(dbContainerName); + } catch (error) { + resourceEvidence.database.restored = false; + resourceEvidence.database.restoreError = error?.message || String(error); + } +} + function latestBenchmarkJson(before) { - const dir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports'); - const files = fs.existsSync(dir) - ? fs.readdirSync(dir) + const files = fs.existsSync(outputDir) + ? fs.readdirSync(outputDir) .filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name)) - .map(name => path.join(dir, name)) + .map(name => path.join(outputDir, name)) .filter(file => fs.statSync(file).mtimeMs >= before) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs) : []; @@ -45,6 +238,17 @@ function latestBenchmarkJson(before) { return path.relative(repoRoot, files[0]).replaceAll('\\', '/'); } +function readBenchmarkSummary(relativePath) { + const absolutePath = path.join(repoRoot, relativePath); + const report = JSON.parse(fs.readFileSync(absolutePath, 'utf8')); + return { + startedAt: report.startedAt, + finishedAt: report.finishedAt, + config: report.config, + summary: report.summary, + }; +} + function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -67,10 +271,30 @@ async function waitForHealth(timeoutMs = 120_000) { } async function main() { + const resourceEvidence = { + generatedAt: new Date().toISOString(), + benchmarkKind: 'docker-api-4c16g-observation', + databaseUrl: redactUrl(databaseUrl), + apiBase, + docker: dockerInfoSummary(), + api: { + requestedLimit: { + cpus: process.env.BENCHMARK_API_CPUS || '2.0', + memory: process.env.BENCHMARK_API_MEMORY || '4g', + }, + dbPoolMax: process.env.DB_POOL_MAX || '10', + }, + matrix, + reports: [], + }; const generated = []; + const dbRestore = maybeApplyDbLimit(resourceEvidence); + let status = 'pass'; + let failure = null; try { run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']); await waitForHealth(); + resourceEvidence.api.container = inspectContainer(composeServiceContainerId('api')); for (const item of matrix) { const before = Date.now(); run('node', ['scripts/api-performance-benchmark.js'], { @@ -86,10 +310,34 @@ async function main() { PERF_PRACTICE_FLOW_ANSWERS: '3', }, }); - generated.push({ scenario: item.name, report: latestBenchmarkJson(before) }); + const report = latestBenchmarkJson(before); + const summary = readBenchmarkSummary(report); + generated.push({ scenario: item.name, report, summary: summary.summary }); + resourceEvidence.reports.push({ scenario: item.name, report, ...summary }); } - console.log(JSON.stringify({ status: 'pass', apiBase, reports: generated }, null, 2)); + } catch (error) { + status = 'fail'; + failure = error; + resourceEvidence.error = error?.message || String(error); + throw error; } finally { + restoreDbLimit(dbRestore, resourceEvidence); + resourceEvidence.finishedAt = new Date().toISOString(); + resourceEvidence.status = status; + fs.mkdirSync(outputDir, { recursive: true }); + const resourceJson = path.join(outputDir, `docker-4c16g-resource-evidence-${shanghaiStamp()}.json`); + const resourceMd = resourceJson.replace(/\.json$/, '.md'); + fs.writeFileSync(resourceJson, `${JSON.stringify(resourceEvidence, null, 2)}\n`, 'utf8'); + fs.writeFileSync(resourceMd, resourceEvidenceMarkdown(resourceEvidence), 'utf8'); + if (failure) console.error(`[benchmark] resource evidence written before failure: ${path.relative(repoRoot, resourceJson)}`); + else { + console.log(JSON.stringify({ + status: 'pass', + apiBase, + resourceEvidence: path.relative(repoRoot, resourceJson).replaceAll('\\', '/'), + reports: generated, + }, null, 2)); + } if (process.env.PERF_KEEP_DOCKER_API !== 'true') { spawnSync('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'down'], { cwd: repoRoot, @@ -100,6 +348,56 @@ async function main() { } } +function resourceEvidenceMarkdown(evidence) { + const lines = [ + '# Docker 4c16g 压测资源证据', + '', + `生成时间:${new Date(evidence.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + '', + `状态:${evidence.status || 'running'}`, + '', + `API:${evidence.apiBase}`, + '', + `数据库:${evidence.databaseUrl}`, + '', + '## Docker Desktop', + '', + `- 系统:${evidence.docker.operatingSystem || 'unknown'}`, + `- Docker:${evidence.docker.serverVersion || 'unknown'}`, + `- CPU:${evidence.docker.ncpu || 'unknown'}`, + `- 内存:${evidence.docker.memory || 'unknown'}`, + '', + '## 资源限制', + '', + `- API 请求限制:${evidence.api.requestedLimit.cpus} CPU / ${evidence.api.requestedLimit.memory} / DB_POOL_MAX=${evidence.api.dbPoolMax}`, + `- API 实际限制:${evidence.api.container?.resources?.cpus ?? 'unlimited'} CPU / ${evidence.api.container?.resources?.memory || 'unknown'}`, + `- DB 容器:${evidence.database?.containerName || 'unknown'}`, + `- DB 限制请求:${evidence.database?.limitRequested ? `${evidence.database.requestedLimit.cpus} CPU / ${evidence.database.requestedLimit.memory}` : '未请求'}`, + `- DB 压测前限制:${evidence.database?.before?.resources?.cpus ?? 'unlimited'} CPU / ${evidence.database?.before?.resources?.memory || 'unknown'}`, + ]; + if (evidence.database?.afterLimit) { + lines.push(`- DB 限制后:${evidence.database.afterLimit.resources.cpus ?? 'unlimited'} CPU / ${evidence.database.afterLimit.resources.memory}`); + } + if (evidence.database?.afterRestore) { + lines.push(`- DB 恢复后:${evidence.database.afterRestore.resources.cpus ?? 'unlimited'} CPU / ${evidence.database.afterRestore.resources.memory}`); + } + if (evidence.database?.note) lines.push(`- 说明:${evidence.database.note}`); + if (evidence.database?.limitError) lines.push(`- DB 限制错误:${evidence.database.limitError}`); + if (evidence.database?.restoreError) lines.push(`- DB 恢复错误:${evidence.database.restoreError}`); + lines.push('', '## 压测摘要', ''); + lines.push('| 场景 | 并发 | 时长 | 写入 | 请求 | 错误率 | RPS | P95 | P99 |'); + lines.push('| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: |'); + for (const report of evidence.reports || []) { + const summary = report.summary || {}; + const latency = summary.latencyAll || {}; + lines.push( + `| ${report.scenario} | ${report.config?.concurrency ?? '-'} | ${report.config?.durationSeconds ?? '-'}s | ${report.config?.includeWrites ? '是' : '否'} | ${summary.requests ?? '-'} | ${((summary.errorRate || 0) * 100).toFixed(2)}% | ${summary.throughputRps ?? '-'} | ${latency.p95Ms ?? '-'}ms | ${latency.p99Ms ?? '-'}ms |`, + ); + } + lines.push('', '说明:这份文件只记录本地 Docker 资源上下文,不能替代目标云服务器正式压测证据。'); + return `${lines.join('\n')}\n`; +} + main().catch(error => { console.error(error); process.exitCode = 1;