forked from wangziqi/gongxue-base
feat: build legacy practice navigation
This commit is contained in:
721
scripts/api-performance-benchmark.js
Normal file
721
scripts/api-performance-benchmark.js
Normal file
@@ -0,0 +1,721 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
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 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);
|
||||
|
||||
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 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;
|
||||
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 }),
|
||||
...(endpoint.userHeader === false || !userId ? {} : { 'x-user-id': userId }),
|
||||
...(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 };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
elapsedMs: performance.now() - started,
|
||||
error: error?.name === 'AbortError' ? 'REQUEST_TIMEOUT' : error?.message || String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
where tm.tenant_id = $1 and tm.status = 'active' and tm.role = 'student'
|
||||
order by 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 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,
|
||||
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 } },
|
||||
{ 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' } },
|
||||
);
|
||||
}
|
||||
if (INCLUDE_WRITES && context.blueprint && context.collection) {
|
||||
endpoints.push({
|
||||
name: 'learning.practice_session.create',
|
||||
weight: 1,
|
||||
method: 'POST',
|
||||
path: '/api/learning/practice-sessions',
|
||||
body: () => ({
|
||||
blueprintId: context.blueprint.id,
|
||||
collectionId: context.collection.id,
|
||||
mode: context.blueprint.mode || 'sequential',
|
||||
questionLimit: Math.min(5, QUESTION_LIMIT),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return expandWeightedEndpoints(endpoints);
|
||||
}
|
||||
|
||||
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) {
|
||||
const endpoint = pickEndpoint(weightedEndpoints);
|
||||
const startedAt = performance.now();
|
||||
const result = await requestJson(endpoint, context);
|
||||
const finishedAt = performance.now();
|
||||
samples.push({
|
||||
name: endpoint.name,
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
elapsedMs: result.elapsedMs,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
});
|
||||
if (!result.ok && errors.length < MAX_ERRORS_TO_KEEP) {
|
||||
errors.push({
|
||||
endpoint: endpoint.name,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
message: result.message,
|
||||
elapsedMs: round(result.elapsedMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runBenchmark(context) {
|
||||
const weightedEndpoints = createEndpointCatalog(context);
|
||||
const endpointNames = [...new Set(weightedEndpoints.map(endpoint => endpoint.name))].sort();
|
||||
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,
|
||||
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 ? '开启' : '关闭'}`);
|
||||
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('- 默认压测是只读混合工作负载,适合在真实迁移库上做烟测。');
|
||||
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();
|
||||
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}`);
|
||||
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;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,232 @@ async function validationChecks(): Promise<CheckResult[]> {
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_practice_navigation_missing_entries',
|
||||
await scalar(
|
||||
`
|
||||
select case
|
||||
when exists (
|
||||
select 1 from public.questions
|
||||
where tenant_id = $1 and legacy_id is not null
|
||||
)
|
||||
and not exists (
|
||||
select 1 from public.content_entries
|
||||
where tenant_id = $1
|
||||
and entry_type = 'question_practice'
|
||||
and legacy_id is not null
|
||||
)
|
||||
then 1 else 0 end
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Imported legacy questions exist but no question_practice content entries were generated.',
|
||||
'Legacy question practice content entries are present when imported questions exist.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_practice_navigation_missing_nodes',
|
||||
await scalar(
|
||||
`
|
||||
select case
|
||||
when exists (
|
||||
select 1 from public.questions
|
||||
where tenant_id = $1 and legacy_id is not null
|
||||
)
|
||||
and not exists (
|
||||
select 1 from public.content_nodes
|
||||
where tenant_id = $1
|
||||
and legacy_id is not null
|
||||
and metadata->>'source' in (
|
||||
'pocketbase.module_nodes',
|
||||
'pocketbase.subjects',
|
||||
'pocketbase.categories',
|
||||
'pocketbase.migration_review.unresolved_category'
|
||||
)
|
||||
)
|
||||
then 1 else 0 end
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Imported legacy questions exist but no content nodes were generated.',
|
||||
'Legacy content nodes are present when imported questions exist.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_practice_collections_missing_items',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.question_collections qc
|
||||
where qc.tenant_id = $1
|
||||
and qc.legacy_id is not null
|
||||
and qc.status = 'active'
|
||||
and qc.question_count = 0
|
||||
and exists (
|
||||
select 1
|
||||
from public.questions q
|
||||
left join public.content_nodes cn
|
||||
on cn.tenant_id = q.tenant_id
|
||||
and cn.id = q.content_node_id
|
||||
where q.tenant_id = qc.tenant_id
|
||||
and q.status = 'published'
|
||||
and (
|
||||
q.content_node_id = qc.node_id
|
||||
or q.category_id = qc.category_id
|
||||
or (qc.category_id is null and q.subject_id = qc.subject_id)
|
||||
)
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Active legacy question collections exist without collection items even though matching questions exist.',
|
||||
'Active legacy question collections have item counts when matching questions exist.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_questions_without_navigation',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions
|
||||
where tenant_id = $1
|
||||
and legacy_id is not null
|
||||
and status = 'published'
|
||||
and (
|
||||
entry_id is null
|
||||
or content_node_id is null
|
||||
or primary_collection_id is null
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some imported published questions are not attached to entry/node/collection navigation.',
|
||||
'Imported published questions are attached to entry/node/collection navigation.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_unresolved_categories_not_isolated',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions q
|
||||
left join public.question_collections qc
|
||||
on qc.tenant_id = q.tenant_id
|
||||
and qc.id = q.primary_collection_id
|
||||
left join public.content_nodes cn
|
||||
on cn.tenant_id = q.tenant_id
|
||||
and cn.id = q.content_node_id
|
||||
where q.tenant_id = $1
|
||||
and q.legacy_id is not null
|
||||
and q.legacy_category_id is not null
|
||||
and q.category_id is null
|
||||
and q.node_id is null
|
||||
and (
|
||||
qc.id is null
|
||||
or qc.status <> 'draft'
|
||||
or qc.access_rules->>'requiresReview' <> 'true'
|
||||
or cn.id is null
|
||||
or cn.is_active <> false
|
||||
or cn.access_rules->>'requiresReview' <> 'true'
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Questions with unresolved legacy categories are not isolated into draft review collections/inactive nodes.',
|
||||
'Questions with unresolved legacy categories are isolated for tenant-admin review.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_orphan_node_questions_not_isolated',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions q
|
||||
left join public.question_collections qc
|
||||
on qc.tenant_id = q.tenant_id
|
||||
and qc.id = q.primary_collection_id
|
||||
left join public.content_nodes cn
|
||||
on cn.tenant_id = q.tenant_id
|
||||
and cn.id = q.content_node_id
|
||||
left join public.content_entries ce
|
||||
on ce.tenant_id = q.tenant_id
|
||||
and ce.id = q.entry_id
|
||||
where q.tenant_id = $1
|
||||
and q.legacy_id is not null
|
||||
and q.legacy_node_id is not null
|
||||
and q.node_id is null
|
||||
and q.category_id is null
|
||||
and q.legacy_category_id is null
|
||||
and not (
|
||||
qc.status = 'draft'
|
||||
and qc.access_rules->>'requiresReview' = 'true'
|
||||
and qc.metadata->>'source' = 'pocketbase.migration_review.orphan_subject'
|
||||
and cn.is_active = false
|
||||
and cn.access_rules->>'requiresReview' = 'true'
|
||||
and ce.visibility = 'hidden'
|
||||
and ce.access_rules->>'requiresReview' = 'true'
|
||||
)
|
||||
and not (
|
||||
qc.status = 'active'
|
||||
and qc.metadata->>'source' = 'pocketbase.subjects'
|
||||
and qc.metadata->>'scope' = 'subject_all'
|
||||
and cn.node_type = 'subject'
|
||||
and cn.marker_type = 'subject'
|
||||
and cn.is_active = true
|
||||
and ce.visibility <> 'hidden'
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Questions with unresolved legacy nodeId are neither isolated into hidden review navigation nor attached to a public subject fallback.',
|
||||
'Questions with unresolved legacy nodeId are isolated or attached to a public subject fallback.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_node_questions_wrong_navigation',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.questions q
|
||||
join public.module_nodes mn
|
||||
on mn.tenant_id = q.tenant_id
|
||||
and mn.id = q.node_id
|
||||
left join public.content_nodes cn
|
||||
on cn.tenant_id = q.tenant_id
|
||||
and cn.legacy_id = 'module_node:' || mn.legacy_id
|
||||
left join public.question_collections qc
|
||||
on qc.tenant_id = q.tenant_id
|
||||
and qc.legacy_id = 'module_node:' || mn.legacy_id || ':direct'
|
||||
where q.tenant_id = $1
|
||||
and q.legacy_id is not null
|
||||
and q.node_id is not null
|
||||
and (
|
||||
cn.id is null
|
||||
or q.content_node_id is distinct from cn.id
|
||||
or qc.id is null
|
||||
or q.primary_collection_id is distinct from qc.id
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some imported questions with legacy nodeId are not attached to the matching content node/direct collection.',
|
||||
'Imported questions with legacy nodeId are attached to matching content nodes and direct collections.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_mock_blueprints_incomplete',
|
||||
|
||||
Reference in New Issue
Block a user