import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; 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 }, { name: 'mixed-100', duration: 60, concurrency: 100, ramp: 15, writes: true, ratio: 0.08 }, { 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: 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', 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 dockerHostRestoreLimit() { const info = dockerInfoSummary(); const ncpu = Number(info.ncpu || 0); const memoryBytes = Number(info.memoryBytes || 0); return { cpus: ncpu > 0 ? String(ncpu) : '0', memory: memoryBytes > 0 ? String(Math.floor(memoryBytes * 0.98)) : '0', memorySwap: memoryBytes > 0 ? String(Math.floor(memoryBytes * 0.98)) : '-1', note: ncpu > 0 && memoryBytes > 0 ? 'Original DB container was unlimited; Docker Desktop may not clear live memory limits with docker update, so restore uses the Docker Desktop host limit.' : 'Original DB container was unlimited but Docker host resources were unavailable; attempted docker update unlimited restore.', }; } 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 = beforeRaw.nanoCpus > 0 || beforeRaw.memory > 0 ? { cpus: beforeRaw.nanoCpus > 0 ? String(beforeRaw.nanoCpus / 1_000_000_000) : '0', memory: String(beforeRaw.memory || 0), memorySwap: String(beforeRaw.memorySwap || 0), note: 'Restoring DB container resource limits captured before benchmark.', } : dockerHostRestoreLimit(); 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.restoreLimit = restore; resourceEvidence.database.afterRestore = inspectContainer(dbContainerName); } catch (error) { resourceEvidence.database.restored = false; resourceEvidence.database.restoreError = error?.message || String(error); } } function latestBenchmarkJson(before) { const files = fs.existsSync(outputDir) ? fs.readdirSync(outputDir) .filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(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) : []; if (!files[0]) throw new Error('Benchmark report JSON was not generated.'); 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)); } async function waitForHealth(timeoutMs = 120_000) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { const response = await fetch(`${apiBase}/health`); const payload = await response.json().catch(() => ({})); if (response.ok && payload.ok) return; lastError = new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`); } catch (error) { lastError = error; } await sleep(1000); } throw new Error(`API did not become healthy at ${apiBase}. ${lastError?.message || ''}`); } 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'], { env: { PERF_START_SERVER: 'false', PERF_API_BASE: apiBase, PERF_AUTH_MODE: 'app_session', PERF_DURATION_SECONDS: String(item.duration), PERF_CONCURRENCY: String(item.concurrency), PERF_RAMP_SECONDS: String(item.ramp), PERF_INCLUDE_WRITES: item.writes ? 'true' : 'false', PERF_PRACTICE_FLOW_RATIO: String(item.ratio), PERF_PRACTICE_FLOW_ANSWERS: '3', }, }); 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 }); } } 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, stdio: 'inherit', shell: process.platform === 'win32', }); } } } 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?.restoreLimit?.note) lines.push(`- DB 恢复策略:${evidence.database.restoreLimit.note}`); 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; });