forked from wangziqi/gongxue-base
feat: add docker capacity benchmark mode
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
@@ -23,6 +24,9 @@ const TENANT_CODE = process.env.PERF_TENANT_CODE || 'master';
|
||||
const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20);
|
||||
const MAX_ERRORS_TO_KEEP = envNumber('PERF_MAX_ERRORS', 20);
|
||||
const DEFAULT_TIMEOUT_MS = envNumber('PERF_REQUEST_TIMEOUT_MS', 15_000);
|
||||
const AUTH_MODE = normalizeAuthMode(process.env.PERF_AUTH_MODE || 'legacy');
|
||||
const STATIC_BEARER_TOKEN = process.env.PERF_BEARER_TOKEN || '';
|
||||
const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me';
|
||||
|
||||
let apiBase = API_BASE_ENV || '';
|
||||
let serverProcess = null;
|
||||
@@ -44,6 +48,14 @@ function envNumber(key, fallback, options = {}) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeAuthMode(value) {
|
||||
const normalized = String(value || '').toLowerCase().replace(/[-_]/g, '');
|
||||
if (normalized === 'appsession' || normalized === 'session' || normalized === 'tk') return 'app_session';
|
||||
if (normalized === 'bearer' || normalized === 'jwt' || normalized === 'token') return 'bearer';
|
||||
if (normalized === 'none' || normalized === 'anonymous') return 'none';
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -91,6 +103,7 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||
const started = performance.now();
|
||||
const tenantId = context.tenantId || context.tenant?.id;
|
||||
const userId = context.userId || context.user?.id;
|
||||
const authToken = context.authToken || '';
|
||||
try {
|
||||
const response = await fetch(buildUrl(apiBase, endpoint), {
|
||||
method: endpoint.method || 'GET',
|
||||
@@ -98,7 +111,8 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(endpoint.tenantHeader === false || !tenantId ? {} : { 'x-tenant-id': tenantId }),
|
||||
...(endpoint.userHeader === false || !userId ? {} : { 'x-user-id': userId }),
|
||||
...(AUTH_MODE === 'legacy' && endpoint.userHeader !== false && userId ? { 'x-user-id': userId } : {}),
|
||||
...(endpoint.authHeader === false || !authToken ? {} : { authorization: `Bearer ${authToken}` }),
|
||||
...(endpoint.headers || {}),
|
||||
},
|
||||
body: endpoint.body ? JSON.stringify(endpoint.body(context)) : undefined,
|
||||
@@ -311,6 +325,7 @@ async function startServerIfNeeded() {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
DATABASE_URL,
|
||||
AUTH_SESSION_SECRET,
|
||||
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576',
|
||||
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760',
|
||||
},
|
||||
@@ -342,6 +357,46 @@ async function many(pool, sql, params = []) {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
function createSessionToken() {
|
||||
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function hashSessionToken(token) {
|
||||
return crypto.createHmac('sha256', AUTH_SESSION_SECRET).update(token).digest('hex');
|
||||
}
|
||||
|
||||
async function createBenchmarkAuthToken(context) {
|
||||
if (AUTH_MODE === 'none' || AUTH_MODE === 'legacy') return '';
|
||||
if (AUTH_MODE === 'bearer') {
|
||||
if (!STATIC_BEARER_TOKEN) throw new Error('PERF_BEARER_TOKEN is required when PERF_AUTH_MODE=bearer.');
|
||||
return STATIC_BEARER_TOKEN;
|
||||
}
|
||||
if (AUTH_MODE !== 'app_session') return '';
|
||||
if (!context.tenant?.id || !context.user?.id) {
|
||||
throw new Error('PERF_AUTH_MODE=app_session requires discovered tenant and user context.');
|
||||
}
|
||||
const token = createSessionToken();
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2 });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
insert into app_private.auth_sessions (
|
||||
tenant_id, user_id, token_hash, provider, expires_at, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'performance_benchmark',
|
||||
now() + interval '2 hours',
|
||||
'{"createdBy":"api-performance-benchmark","authMode":"app_session"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[context.tenant.id, context.user.id, hashSessionToken(token)],
|
||||
);
|
||||
return token;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverBenchmarkContext() {
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 4 });
|
||||
try {
|
||||
@@ -790,6 +845,7 @@ async function runBenchmark(context) {
|
||||
concurrency: CONCURRENCY,
|
||||
rampSeconds: RAMP_SECONDS,
|
||||
includeWrites: INCLUDE_WRITES,
|
||||
authMode: AUTH_MODE,
|
||||
practiceFlowRatio: INCLUDE_WRITES ? PRACTICE_FLOW_RATIO : 0,
|
||||
practiceFlowAnswers: INCLUDE_WRITES ? PRACTICE_FLOW_ANSWERS : 0,
|
||||
questionLimit: QUESTION_LIMIT,
|
||||
@@ -906,10 +962,11 @@ async function main() {
|
||||
throw new Error('PERF_API_BASE is required when PERF_START_SERVER=false.');
|
||||
}
|
||||
const context = await discoverBenchmarkContext();
|
||||
context.authToken = await createBenchmarkAuthToken(context);
|
||||
await startServerIfNeeded();
|
||||
if (!apiBase) throw new Error('API base URL was not resolved.');
|
||||
console.log(`[perf] target api=${apiBase} tenant=${context.tenant.slug} user=${context.user.id}`);
|
||||
console.log(`[perf] duration=${DURATION_SECONDS}s concurrency=${CONCURRENCY} includeWrites=${INCLUDE_WRITES}`);
|
||||
console.log(`[perf] duration=${DURATION_SECONDS}s concurrency=${CONCURRENCY} includeWrites=${INCLUDE_WRITES} authMode=${AUTH_MODE}`);
|
||||
const report = await runBenchmark(context);
|
||||
const files = await writeReport(report);
|
||||
console.log(`[perf] requests=${report.summary.requests} ok=${report.summary.ok} errors=${report.summary.errors} rps=${report.summary.throughputRps} p95=${report.summary.latencyOk.p95Ms}ms`);
|
||||
|
||||
95
scripts/run-docker-4c16g-benchmark.js
Normal file
95
scripts/run-docker-4c16g-benchmark.js
Normal file
@@ -0,0 +1,95 @@
|
||||
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 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 },
|
||||
];
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function latestBenchmarkJson(before) {
|
||||
const dir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports');
|
||||
const files = fs.existsSync(dir)
|
||||
? fs.readdirSync(dir)
|
||||
.filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name))
|
||||
.map(name => path.join(dir, 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 waitForHealth(timeoutMs = 60_000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const result = spawnSync(process.execPath, ['-e', `fetch('${apiBase}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'ignore',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.status === 0) return;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
|
||||
}
|
||||
throw new Error(`API did not become healthy at ${apiBase}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const generated = [];
|
||||
try {
|
||||
run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']);
|
||||
waitForHealth();
|
||||
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',
|
||||
},
|
||||
});
|
||||
generated.push({ scenario: item.name, report: latestBenchmarkJson(before) });
|
||||
}
|
||||
console.log(JSON.stringify({ status: 'pass', apiBase, reports: generated }, null, 2));
|
||||
} finally {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user