Files
gongxue-base/scripts/api-performance-benchmark.js
2026-07-01 03:31:17 +08:00

989 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { performance } from 'node:perf_hooks';
import pg from 'pg';
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const DATABASE_URL = process.env.DATABASE_URL || DEFAULT_DATABASE_URL;
const OUTPUT_DIR = process.env.PERF_OUTPUT_DIR || 'docs/refactor/performance-reports';
const API_BASE_ENV = process.env.PERF_API_BASE || process.env.API_BASE || '';
const START_SERVER = envBool('PERF_START_SERVER', !API_BASE_ENV);
const DURATION_SECONDS = envNumber('PERF_DURATION_SECONDS', 15);
const CONCURRENCY = envNumber('PERF_CONCURRENCY', 6);
const RAMP_SECONDS = envNumber('PERF_RAMP_SECONDS', 3, { allowZero: true });
const INCLUDE_WRITES = envBool('PERF_INCLUDE_WRITES', false);
const INCLUDE_LEADERBOARD = envBool('PERF_INCLUDE_LEADERBOARD', false);
const PRACTICE_FLOW_RATIO = Math.min(Math.max(envNumber('PERF_PRACTICE_FLOW_RATIO', 0.15, { allowZero: true }), 0), 1);
const PRACTICE_FLOW_ANSWERS = envNumber('PERF_PRACTICE_FLOW_ANSWERS', 3);
const ENSURE_SVIP_FOR_WRITES = envBool('PERF_ENSURE_SVIP_FOR_WRITES', true);
const START_PORT = Number(process.env.PERF_API_PORT || 0) || 0;
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;
let serverLogs = '';
function envBool(key, fallback) {
const value = process.env[key];
if (value === undefined || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
}
function envNumber(key, fallback, options = {}) {
const raw = process.env[key];
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) return fallback;
if (value > 0) return value;
if (options.allowZero && value === 0) return 0;
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();
server.on('error', reject);
server.listen(START_PORT, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
});
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function shanghaiTimestampForFile(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 buildUrl(baseUrl, endpoint) {
const target = new URL(endpoint.path, baseUrl);
for (const [key, value] of Object.entries(endpoint.query || {})) {
if (value !== undefined && value !== null && value !== '') {
target.searchParams.set(key, String(value));
}
}
return target;
}
async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
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',
signal: controller.signal,
headers: {
'content-type': 'application/json',
...(endpoint.tenantHeader === false || !tenantId ? {} : { 'x-tenant-id': tenantId }),
...(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,
});
const elapsedMs = performance.now() - started;
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return {
ok: false,
status: response.status,
elapsedMs,
error: payload?.code || payload?.error || payload?.message || response.statusText,
message: payload?.error || payload?.message || response.statusText,
};
}
return { ok: true, status: response.status, elapsedMs, bytes: JSON.stringify(payload).length, payload };
} catch (error) {
return {
ok: false,
status: 0,
elapsedMs: performance.now() - started,
error: error?.name === 'AbortError' ? 'REQUEST_TIMEOUT' : error?.message || String(error),
};
} finally {
clearTimeout(timeout);
}
}
function sampleFromResult(name, result, startedAt, finishedAt) {
return {
name,
ok: result.ok,
status: result.status,
elapsedMs: result.elapsedMs,
startedAt,
finishedAt,
};
}
function pickAnswerPayload(question) {
const subQuestions = Array.isArray(question.subQuestions) ? question.subQuestions : [];
if (subQuestions.length) {
return {
subAnswers: subQuestions.slice(0, Math.max(PRACTICE_FLOW_ANSWERS, 1)).map((subQuestion, index) => {
const subCorrectIndices = Array.isArray(subQuestion.correctOptionIndices) ? subQuestion.correctOptionIndices : [];
const subCorrectIndex = subQuestion.correctOptionIndex;
const subAnswer = {
subQuestionId: subQuestion.id || subQuestion.subQuestionId || subQuestion.key || `sub_${index + 1}`,
selectedOptions: [],
answerText: '',
};
if (subCorrectIndices.length) {
subAnswer.selectedOptions = subCorrectIndices.map(item => String(item));
} else if (subCorrectIndex !== null && subCorrectIndex !== undefined && Number.isFinite(Number(subCorrectIndex))) {
subAnswer.selectedOptions = [String(Number(subCorrectIndex))];
} else if (Array.isArray(subQuestion.options) && subQuestion.options.length) {
subAnswer.selectedOptions = ['0'];
} else {
subAnswer.answerText = 'performance benchmark answer';
subAnswer.selfJudgedCorrect = true;
}
return subAnswer;
}),
};
}
const correctIndices = Array.isArray(question.correctOptionIndices) ? question.correctOptionIndices : [];
const correctIndex = question.correctOptionIndex;
if (correctIndices.length) {
return { selectedOptions: correctIndices.map(item => String(item)) };
}
if (correctIndex !== null && correctIndex !== undefined && Number.isFinite(Number(correctIndex))) {
return { selectedOptions: [String(Number(correctIndex))] };
}
if (Array.isArray(question.options) && question.options.length) {
return { selectedOptions: ['0'] };
}
return { answerText: 'performance benchmark answer', selfJudgedCorrect: true };
}
async function runPracticeFlow(context, samples, errors) {
if (!context.blueprint || !context.collection) return false;
const flowStartedAt = performance.now();
const create = await requestJson({
name: 'learning.practice_flow.create',
method: 'POST',
path: '/api/learning/practice-sessions',
body: () => ({
blueprintId: context.blueprint.id,
collectionId: context.collection.id,
mode: context.blueprint.mode || 'sequential',
questionLimit: Math.min(QUESTION_LIMIT, Math.max(PRACTICE_FLOW_ANSWERS, 3)),
metadata: { source: 'api-performance-benchmark' },
}),
}, context);
const flowFinishedCreate = performance.now();
samples.push(sampleFromResult('learning.practice_flow.create', create, flowStartedAt, flowFinishedCreate));
if (!create.ok) {
pushError(errors, 'learning.practice_flow.create', create);
return true;
}
const practiceSessionId = create.payload?.item?.id;
if (!practiceSessionId) {
pushError(errors, 'learning.practice_flow.create', {
status: 200,
elapsedMs: create.elapsedMs,
error: 'PRACTICE_SESSION_ID_MISSING',
message: 'Practice session create response did not include item.id',
});
return true;
}
const detailStartedAt = performance.now();
const detail = await requestJson({
name: 'learning.practice_flow.detail',
method: 'GET',
path: '/api/learning/practice-sessions/detail',
query: { practiceSessionId },
}, context);
const detailFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.detail', detail, detailStartedAt, detailFinishedAt));
if (!detail.ok) {
pushError(errors, 'learning.practice_flow.detail', detail);
return true;
}
const questions = Array.isArray(detail.payload?.item?.questions) ? detail.payload.item.questions : [];
for (const question of questions.slice(0, PRACTICE_FLOW_ANSWERS)) {
if (!question?.id) continue;
const answerStartedAt = performance.now();
const answer = await requestJson({
name: 'learning.practice_flow.answer',
method: 'POST',
path: '/api/learning/answers',
body: () => ({
practiceSessionId,
questionId: question.id,
...pickAnswerPayload(question),
}),
}, context);
const answerFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.answer', answer, answerStartedAt, answerFinishedAt));
if (!answer.ok) {
pushError(errors, 'learning.practice_flow.answer', answer);
return true;
}
}
const submitStartedAt = performance.now();
const submit = await requestJson({
name: 'learning.practice_flow.submit',
method: 'POST',
path: '/api/learning/practice-sessions/submit',
body: () => ({ practiceSessionId }),
}, context);
const submitFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.submit', submit, submitStartedAt, submitFinishedAt));
if (!submit.ok) {
pushError(errors, 'learning.practice_flow.submit', submit);
return true;
}
const reportStartedAt = performance.now();
const report = await requestJson({
name: 'learning.practice_flow.report',
method: 'GET',
path: '/api/learning/practice-sessions/report',
query: { practiceSessionId },
}, context);
const reportFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.report', report, reportStartedAt, reportFinishedAt));
if (!report.ok) pushError(errors, 'learning.practice_flow.report', report);
return true;
}
function pushError(errors, endpointName, result) {
if (errors.length >= MAX_ERRORS_TO_KEEP) return;
errors.push({
endpoint: endpointName,
status: result.status,
error: result.error,
message: result.message,
elapsedMs: round(result.elapsedMs),
});
}
async function waitForHealth(timeoutMs = 20_000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(new URL('/health', apiBase));
const payload = await response.json().catch(() => ({}));
if (response.ok && payload.ok) return;
} catch (error) {
lastError = error;
}
await sleep(250);
}
throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`);
}
async function startServerIfNeeded() {
if (!START_SERVER) return;
const port = await getFreePort();
apiBase = `http://127.0.0.1:${port}`;
serverProcess = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
cwd: process.cwd(),
env: {
...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',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
serverProcess.stdout.on('data', chunk => {
serverLogs += chunk.toString();
});
serverProcess.stderr.on('data', chunk => {
serverLogs += chunk.toString();
});
await waitForHealth();
}
function stopServer() {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
async function one(pool, sql, params = []) {
const result = await pool.query(sql, params);
return result.rows[0] || null;
}
async function many(pool, sql, params = []) {
const result = await pool.query(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 {
const tenant = await one(
pool,
`
select id, slug, name
from public.tenants
where status = 'active'
order by case when slug = $1 then 0 else 1 end, slug asc
limit 1
`,
[TENANT_CODE],
);
if (!tenant) throw new Error('No active tenant found. Run smoke seed or PocketBase import first.');
const user = await one(
pool,
`
select pu.id, coalesce(pu.name, pu.username, pu.phone, pu.legacy_id, pu.id::text) as name
from public.tenant_memberships tm
join public.platform_users pu on pu.id = tm.user_id
left join public.entitlements e on e.tenant_id = tm.tenant_id
and e.user_id = pu.id
and e.entitlement_type = 'svip'
and e.status = 'active'
and (e.expires_at is null or e.expires_at > now())
where tm.tenant_id = $1 and tm.status = 'active' and tm.role = 'student'
group by pu.id, pu.name, pu.username, pu.phone, pu.legacy_id, pu.created_at
order by case when count(e.id) > 0 then 0 else 1 end, pu.created_at asc
limit 1
`,
[tenant.id],
);
if (!user) throw new Error(`No active student user found for tenant ${tenant.slug}.`);
const entry = await one(
pool,
`
select id, name
from public.content_entries
where tenant_id = $1
and entry_type = 'question_practice'
and is_active = true
and visibility <> 'hidden'
order by sort_order asc, created_at asc
limit 1
`,
[tenant.id],
);
const node = entry
? await one(
pool,
`
select id, name
from public.content_nodes
where tenant_id = $1 and entry_id = $2 and is_active = true
order by depth asc, sort_order asc, created_at asc
limit 1
`,
[tenant.id, entry.id],
)
: null;
const collection = await one(
pool,
`
select id, name, question_count as "questionCount", entry_id as "entryId", node_id as "nodeId"
from public.question_collections
where tenant_id = $1 and status = 'active' and question_count > 0
order by question_count desc, sort_order asc, created_at asc
limit 1
`,
[tenant.id],
);
const blueprint = collection
? await one(
pool,
`
select id, name, mode
from public.practice_blueprints
where tenant_id = $1 and status = 'active' and collection_id = $2
order by case mode when 'sequential' then 0 when 'random' then 1 else 2 end, sort_order asc
limit 1
`,
[tenant.id, collection.id],
)
: null;
const vocabularyUnit = await one(
pool,
`
select id, name
from public.vocabulary_units
where tenant_id = $1 and is_active = true
order by sort_order asc, created_at asc
limit 1
`,
[tenant.id],
);
const handbookSubject = await one(
pool,
`
select id, name
from public.handbook_subjects
where tenant_id = $1 and is_active = true
order by sort_order asc, created_at asc
limit 1
`,
[tenant.id],
);
const handbookChapter = handbookSubject
? await one(
pool,
`
select id, name
from public.handbook_chapters
where tenant_id = $1 and subject_id = $2 and is_active = true
order by sort_order asc, created_at asc
limit 1
`,
[tenant.id, handbookSubject.id],
)
: null;
const hasActiveEntitlement = user
? await one(
pool,
`
select id
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and (expires_at is null or expires_at > now())
limit 1
`,
[tenant.id, user.id],
)
: null;
let benchmarkEntitlementCreated = false;
if (INCLUDE_WRITES && ENSURE_SVIP_FOR_WRITES && user && !hasActiveEntitlement) {
await pool.query(
`
insert into public.entitlements (
tenant_id, user_id, entitlement_type, scope_type, source_type,
starts_at, expires_at, status, metadata
)
values ($1, $2, 'svip', 'tenant', 'performance_benchmark',
now(), now() + interval '1 day', 'active',
'{"createdBy":"api-performance-benchmark"}'::jsonb)
`,
[tenant.id, user.id],
);
benchmarkEntitlementCreated = true;
}
const dbStats = {
before: await captureDbStats(pool),
tableCounts: await many(
pool,
`
select label, count_value::bigint::text as value
from (
select 'questions' as label, count(*) as count_value from public.questions where tenant_id = $1
union all select 'content_entries', count(*) from public.content_entries where tenant_id = $1
union all select 'content_nodes', count(*) from public.content_nodes where tenant_id = $1
union all select 'question_collections', count(*) from public.question_collections where tenant_id = $1
union all select 'practice_blueprints', count(*) from public.practice_blueprints where tenant_id = $1
union all select 'vocabulary_words', count(*) from public.vocabulary_words where tenant_id = $1
union all select 'handbook_entries', count(*) from public.handbook_entries where tenant_id = $1
) counts
order by label asc
`,
[tenant.id],
),
};
return {
tenant,
user: {
...user,
entitlement: {
hadActiveSvip: Boolean(hasActiveEntitlement),
benchmarkEntitlementCreated,
},
},
entry,
node,
collection,
blueprint,
vocabularyUnit,
handbookSubject,
handbookChapter,
dbStats,
};
} finally {
await pool.end();
}
}
async function captureDbStats(pool) {
const database = await one(
pool,
`
select datname, xact_commit::bigint::text as "xactCommit",
xact_rollback::bigint::text as "xactRollback",
blks_read::bigint::text as "blocksRead",
blks_hit::bigint::text as "blocksHit",
tup_returned::bigint::text as "tuplesReturned",
tup_fetched::bigint::text as "tuplesFetched",
tup_inserted::bigint::text as "tuplesInserted",
tup_updated::bigint::text as "tuplesUpdated",
tup_deleted::bigint::text as "tuplesDeleted",
deadlocks::bigint::text as deadlocks
from pg_stat_database
where datname = current_database()
`,
);
const activity = await many(
pool,
`
select state, wait_event_type as "waitEventType", wait_event as "waitEvent", count(*)::int
from pg_stat_activity
where datname = current_database()
group by state, wait_event_type, wait_event
order by count desc
limit 20
`,
);
return { database, activity };
}
function createEndpointCatalog(context) {
const endpoints = [
{ name: 'health', weight: 6, method: 'GET', path: '/health', tenantHeader: false, userHeader: false },
{ name: 'tenant.resolve', weight: 6, method: 'GET', path: '/api/tenant/resolve', query: { tenantCode: context.tenant.slug }, tenantHeader: false, userHeader: false },
{ name: 'catalog.regions', weight: 4, method: 'GET', path: '/api/catalog/regions' },
{ name: 'catalog.content_entries', weight: 10, method: 'GET', path: '/api/catalog/content-entries', query: { entryType: 'question_practice' } },
{ name: 'learning.stats', weight: 5, method: 'GET', path: '/api/learning/stats' },
{ name: 'learning.trend', weight: 4, method: 'GET', path: '/api/learning/trend', query: { days: 14 } },
];
if (INCLUDE_LEADERBOARD) {
endpoints.push({
name: 'leaderboard.questions_7d',
weight: 3,
method: 'GET',
path: '/api/learning/leaderboard',
query: { metric: 'questions', period: '7d', limit: 20 },
});
}
if (context.entry) {
endpoints.push(
{ name: 'catalog.content_nodes.flat', weight: 10, method: 'GET', path: '/api/catalog/content-nodes', query: { entryId: context.entry.id, mode: 'flat' } },
{ name: 'catalog.question_collections.entry', weight: 8, method: 'GET', path: '/api/catalog/question-collections', query: { entryId: context.entry.id, limit: 50 } },
);
}
if (context.node) {
endpoints.push(
{ name: 'catalog.question_collections.node', weight: 5, method: 'GET', path: '/api/catalog/question-collections', query: { nodeId: context.node.id, limit: 50 } },
);
}
if (context.collection) {
endpoints.push(
{ name: 'catalog.collection_questions', weight: 12, method: 'GET', path: '/api/catalog/question-collections/questions', query: { collectionId: context.collection.id, limit: QUESTION_LIMIT } },
{ name: 'catalog.practice_blueprints', weight: 7, method: 'GET', path: '/api/catalog/practice-blueprints', query: { collectionId: context.collection.id, limit: 20 } },
);
}
if (context.vocabularyUnit) {
endpoints.push(
{ name: 'catalog.vocabulary_words', weight: 5, method: 'GET', path: '/api/catalog/vocabulary-words', query: { unitId: context.vocabularyUnit.id, limit: 80 } },
{ name: 'learning.vocabulary.stats', weight: 3, method: 'GET', path: '/api/learning/vocabulary/stats' },
);
}
if (context.handbookSubject) {
endpoints.push(
{ name: 'catalog.handbook_chapters', weight: 4, method: 'GET', path: '/api/catalog/handbook-chapters', query: { subjectId: context.handbookSubject.id } },
);
}
if (context.handbookChapter) {
endpoints.push(
{ name: 'catalog.handbook_entries', weight: 5, method: 'GET', path: '/api/catalog/handbook-entries', query: { chapterId: context.handbookChapter.id, includeContent: 'true' } },
);
}
return expandWeightedEndpoints(endpoints);
}
function benchmarkEndpointNames(weightedEndpoints, context) {
const names = new Set(weightedEndpoints.map(endpoint => endpoint.name));
if (INCLUDE_WRITES && context.blueprint && context.collection && PRACTICE_FLOW_RATIO > 0) {
for (const name of [
'learning.practice_flow.create',
'learning.practice_flow.detail',
'learning.practice_flow.answer',
'learning.practice_flow.submit',
'learning.practice_flow.report',
]) names.add(name);
}
return [...names].sort();
}
function expandWeightedEndpoints(endpoints) {
const weighted = [];
for (const endpoint of endpoints) {
for (let i = 0; i < endpoint.weight; i += 1) weighted.push(endpoint);
}
if (!weighted.length) throw new Error('No benchmark endpoints could be built from discovered data.');
return weighted;
}
function pickEndpoint(weightedEndpoints) {
return weightedEndpoints[Math.floor(Math.random() * weightedEndpoints.length)];
}
function percentile(sortedValues, p) {
if (!sortedValues.length) return 0;
const index = Math.ceil((p / 100) * sortedValues.length) - 1;
return sortedValues[Math.min(Math.max(index, 0), sortedValues.length - 1)];
}
function summarizeSamples(samples) {
if (!samples.length) {
return {
requests: 0,
ok: 0,
errors: 0,
errorRate: 0,
throughputRps: 0,
okThroughputRps: 0,
latencyAll: { p50Ms: 0, p90Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 },
latencyOk: { p50Ms: 0, p90Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 },
endpoints: [],
};
}
const okSamples = samples.filter(sample => sample.ok);
const allLatencies = samples.map(sample => sample.elapsedMs).sort((a, b) => a - b);
const okLatencies = okSamples.map(sample => sample.elapsedMs).sort((a, b) => a - b);
const byEndpoint = new Map();
for (const sample of samples) {
const bucket = byEndpoint.get(sample.name) || [];
bucket.push(sample);
byEndpoint.set(sample.name, bucket);
}
const endpointSummaries = [...byEndpoint.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, endpointSamples]) => {
const ok = endpointSamples.filter(sample => sample.ok);
const latencies = ok.map(sample => sample.elapsedMs).sort((a, b) => a - b);
return {
name,
requests: endpointSamples.length,
ok: ok.length,
errors: endpointSamples.length - ok.length,
p50Ms: round(percentile(latencies, 50)),
p90Ms: round(percentile(latencies, 90)),
p95Ms: round(percentile(latencies, 95)),
p99Ms: round(percentile(latencies, 99)),
maxMs: round(latencies.at(-1) || 0),
};
});
const durationMs = Math.max(...samples.map(sample => sample.finishedAt), 0) - Math.min(...samples.map(sample => sample.startedAt), 0);
const durationSeconds = durationMs > 0 ? durationMs / 1000 : DURATION_SECONDS;
return {
requests: samples.length,
ok: okSamples.length,
errors: samples.length - okSamples.length,
errorRate: samples.length ? round((samples.length - okSamples.length) / samples.length) : 0,
throughputRps: round(samples.length / durationSeconds),
okThroughputRps: round(okSamples.length / durationSeconds),
latencyAll: {
p50Ms: round(percentile(allLatencies, 50)),
p90Ms: round(percentile(allLatencies, 90)),
p95Ms: round(percentile(allLatencies, 95)),
p99Ms: round(percentile(allLatencies, 99)),
maxMs: round(allLatencies.at(-1) || 0),
},
latencyOk: {
p50Ms: round(percentile(okLatencies, 50)),
p90Ms: round(percentile(okLatencies, 90)),
p95Ms: round(percentile(okLatencies, 95)),
p99Ms: round(percentile(okLatencies, 99)),
maxMs: round(okLatencies.at(-1) || 0),
},
endpoints: endpointSummaries,
};
}
function round(value, digits = 2) {
return Number(Number(value || 0).toFixed(digits));
}
async function workerLoop(workerId, weightedEndpoints, context, stopAt, samples, errors) {
const rampDelay = RAMP_SECONDS > 0 ? (workerId / Math.max(CONCURRENCY, 1)) * RAMP_SECONDS * 1000 : 0;
if (rampDelay > 0) await sleep(rampDelay);
while (performance.now() < stopAt) {
if (INCLUDE_WRITES && PRACTICE_FLOW_RATIO > 0 && Math.random() < PRACTICE_FLOW_RATIO) {
await runPracticeFlow(context, samples, errors);
continue;
}
const endpoint = pickEndpoint(weightedEndpoints);
const startedAt = performance.now();
const result = await requestJson(endpoint, context);
const finishedAt = performance.now();
samples.push(sampleFromResult(endpoint.name, result, startedAt, finishedAt));
if (!result.ok) pushError(errors, endpoint.name, result);
}
}
async function runBenchmark(context) {
const weightedEndpoints = createEndpointCatalog(context);
const endpointNames = benchmarkEndpointNames(weightedEndpoints, context);
const samples = [];
const errors = [];
const startedAt = new Date();
const startPerf = performance.now();
const stopAt = startPerf + DURATION_SECONDS * 1000;
await Promise.all(
Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index, weightedEndpoints, context, stopAt, samples, errors)),
);
const finishedAt = new Date();
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2 });
let afterStats = null;
try {
afterStats = await captureDbStats(pool);
} finally {
await pool.end();
}
return {
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
apiBase,
config: {
durationSeconds: DURATION_SECONDS,
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,
timeoutMs: DEFAULT_TIMEOUT_MS,
},
target: {
tenant: context.tenant,
user: context.user,
entry: context.entry,
node: context.node,
collection: context.collection,
blueprint: context.blueprint,
vocabularyUnit: context.vocabularyUnit,
handbookSubject: context.handbookSubject,
handbookChapter: context.handbookChapter,
endpointNames,
},
db: {
before: context.dbStats.before,
after: afterStats,
tableCounts: context.dbStats.tableCounts,
},
summary: summarizeSamples(samples),
errors,
};
}
function markdownReport(report) {
const lines = [];
lines.push('# API 本地压测报告');
lines.push('');
lines.push(`生成时间:${new Date(report.finishedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`);
lines.push('');
lines.push('## 配置');
lines.push('');
lines.push(`- API${report.apiBase}`);
lines.push(`- 租户:${report.target.tenant.slug} / ${report.target.tenant.name}`);
lines.push(`- 并发:${report.config.concurrency}`);
lines.push(`- 持续时间:${report.config.durationSeconds}s`);
lines.push(`- Ramp${report.config.rampSeconds}s`);
lines.push(`- 写入压测:${report.config.includeWrites ? '开启' : '关闭'}`);
if (report.config.includeWrites) {
lines.push(`- 刷题闭环比例:${Math.round((report.config.practiceFlowRatio || 0) * 100)}%`);
lines.push(`- 每个刷题闭环提交答案数:${report.config.practiceFlowAnswers || 0}`);
}
lines.push(`- 请求超时:${report.config.timeoutMs}ms`);
lines.push('');
lines.push('## 总览');
lines.push('');
lines.push('| 指标 | 数值 |');
lines.push('| --- | ---: |');
lines.push(`| 请求数 | ${report.summary.requests} |`);
lines.push(`| 成功数 | ${report.summary.ok} |`);
lines.push(`| 错误数 | ${report.summary.errors} |`);
lines.push(`| 错误率 | ${(report.summary.errorRate * 100).toFixed(2)}% |`);
lines.push(`| 吞吐 | ${report.summary.throughputRps} req/s |`);
lines.push(`| 成功吞吐 | ${report.summary.okThroughputRps} req/s |`);
lines.push(`| P50 | ${report.summary.latencyOk.p50Ms} ms |`);
lines.push(`| P90 | ${report.summary.latencyOk.p90Ms} ms |`);
lines.push(`| P95 | ${report.summary.latencyOk.p95Ms} ms |`);
lines.push(`| P99 | ${report.summary.latencyOk.p99Ms} ms |`);
lines.push(`| Max | ${report.summary.latencyOk.maxMs} ms |`);
lines.push('');
lines.push('## 接口明细');
lines.push('');
lines.push('| 接口 | 请求 | 成功 | 错误 | P50 ms | P90 ms | P95 ms | P99 ms | Max ms |');
lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
for (const item of report.summary.endpoints) {
lines.push(`| ${item.name} | ${item.requests} | ${item.ok} | ${item.errors} | ${item.p50Ms} | ${item.p90Ms} | ${item.p95Ms} | ${item.p99Ms} | ${item.maxMs} |`);
}
lines.push('');
lines.push('## 数据规模');
lines.push('');
lines.push('| 表 | 当前租户记录数 |');
lines.push('| --- | ---: |');
for (const item of report.db.tableCounts) {
lines.push(`| ${item.label} | ${item.value} |`);
}
lines.push('');
if (report.errors.length) {
lines.push('## 错误样本');
lines.push('');
lines.push('| 接口 | 状态 | 错误 | 耗时 ms |');
lines.push('| --- | ---: | --- | ---: |');
for (const error of report.errors) {
const errorText = [error.error, error.message && error.message !== error.error ? error.message : ''].filter(Boolean).join(': ');
lines.push(`| ${error.endpoint} | ${error.status} | ${String(errorText).replaceAll('|', '\\|')} | ${error.elapsedMs} |`);
}
lines.push('');
}
lines.push('## 说明');
lines.push('');
lines.push('- 默认压测是只读混合工作负载,适合在真实迁移库上做烟测;开启写入后会执行“创建练习 session -> 读取详情 -> 提交答案 -> 交卷 -> 读取报告”的刷题闭环。');
lines.push('- 并发 worker 是无停顿请求流,不能直接等同于真实在线人数;真实学生有读题、思考、翻页和网络间隔,应结合前端埋点估算每人 RPS。');
lines.push('- 结果只能代表当前本机 Docker、API 进程和数据库状态4 核 16G 云服务器需要按 runbook 跑阶梯并发。');
lines.push('- 若 P95 明显升高,下一步应结合 PostgreSQL 慢 SQL、`pg_stat_activity` 和 API 日志定位。');
lines.push('');
return `${lines.join('\n')}\n`;
}
async function writeReport(report) {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const stamp = shanghaiTimestampForFile(new Date(report.finishedAt));
const jsonPath = path.join(OUTPUT_DIR, `api-benchmark-${stamp}.json`);
const mdPath = path.join(OUTPUT_DIR, `api-benchmark-${stamp}.md`);
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await fs.writeFile(mdPath, markdownReport(report), 'utf8');
return { jsonPath, mdPath };
}
async function main() {
try {
if (!apiBase && !START_SERVER) {
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} 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`);
console.log(`[perf] wrote ${files.jsonPath}`);
console.log(`[perf] wrote ${files.mdPath}`);
if (report.summary.errors > 0) {
process.exitCode = 1;
}
} finally {
stopServer();
}
}
main().catch(error => {
console.error(error);
if (serverLogs) console.error(serverLogs);
stopServer();
process.exitCode = 1;
});