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; });