forked from wangziqi/gongxue-base
chore: add launch readiness smoke and tuning evidence
This commit is contained in:
482
scripts/launch-persona-smoke.js
Normal file
482
scripts/launch-persona-smoke.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL;
|
||||
const outputDir = process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||
const startServer = boolEnv('LAUNCH_SMOKE_START_SERVER', !process.env.LAUNCH_SMOKE_API_BASE);
|
||||
const fixedPort = Number(process.env.LAUNCH_SMOKE_API_PORT || 0) || 0;
|
||||
|
||||
const ids = {
|
||||
tenant: '00000000-0000-0000-0000-000000000001',
|
||||
partnerTenant: '00000000-0000-0000-0000-000000000901',
|
||||
student: '00000000-0000-0000-0000-000000000101',
|
||||
tenantAdmin: '00000000-0000-0000-0000-000000000102',
|
||||
platformAdmin: '00000000-0000-0000-0000-000000000999',
|
||||
region: '00000000-0000-0000-0000-000000000301',
|
||||
plan: '00000000-0000-0000-0000-000000000201',
|
||||
};
|
||||
|
||||
let apiBase = process.env.LAUNCH_SMOKE_API_BASE || process.env.API_BASE || '';
|
||||
let serverProcess = null;
|
||||
let serverLogs = '';
|
||||
|
||||
function boolEnv(key, fallback) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function shanghaiStamp(date = new Date()) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date).map(part => [part.type, part.value]),
|
||||
);
|
||||
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(fixedPort, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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 (!startServer) 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: databaseUrl,
|
||||
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();
|
||||
}
|
||||
|
||||
function buildUrl(pathname, query = {}) {
|
||||
const target = new URL(pathname, apiBase);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') target.searchParams.set(key, String(value));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function request(pathname, options = {}) {
|
||||
const response = await fetch(buildUrl(pathname, options.query), {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || ids.tenant }),
|
||||
...(options.userId === false ? {} : { 'x-user-id': options.userId || ids.student }),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const expectedStatus = options.expectStatus || 200;
|
||||
assert.equal(response.status, expectedStatus, `${options.method || 'GET'} ${pathname} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function one(pool, sql, params = []) {
|
||||
const result = await pool.query(sql, params);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
function pickAnswer(question) {
|
||||
const correctIndices = Array.isArray(question.correctOptionIndices) ? question.correctOptionIndices : [];
|
||||
if (correctIndices.length) return { selectedOptions: correctIndices.map(item => String(item)) };
|
||||
if (question.correctOptionIndex !== undefined && question.correctOptionIndex !== null) return { selectedOptions: [String(question.correctOptionIndex)] };
|
||||
if (Array.isArray(question.options) && question.options.length) return { selectedOptions: ['0'] };
|
||||
return { answerText: 'launch smoke answer', selfJudgedCorrect: true };
|
||||
}
|
||||
|
||||
async function ensureSvipEntitlement(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.entitlements (
|
||||
tenant_id, user_id, entitlement_type, scope_type, scope_id,
|
||||
source_type, legacy_source_id, starts_at, expires_at, status, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'svip', 'tenant', null,
|
||||
'launch_persona_smoke', 'launch-persona-smoke', now() - interval '1 minute',
|
||||
now() + interval '1 day', 'active',
|
||||
'{"source":"launch_persona_smoke","temporary":true}'::jsonb
|
||||
)
|
||||
on conflict do nothing
|
||||
`,
|
||||
[ids.tenant, ids.student],
|
||||
);
|
||||
}
|
||||
|
||||
async function discoverStudentPath(pool) {
|
||||
const entry = await one(
|
||||
pool,
|
||||
`
|
||||
select id, name
|
||||
from public.content_entries
|
||||
where tenant_id = $1 and is_active = true and entry_type = 'question_practice'
|
||||
order by sort_order asc, created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
assert.ok(entry, 'launch smoke needs an active question_practice content entry');
|
||||
|
||||
const collection = await one(
|
||||
pool,
|
||||
`
|
||||
select qc.id, qc.name, qc.node_id as content_node_id
|
||||
from public.question_collections qc
|
||||
where qc.tenant_id = $1
|
||||
and qc.status = 'active'
|
||||
and exists (
|
||||
select 1
|
||||
from public.question_collection_items qci
|
||||
join public.questions q on q.tenant_id = qci.tenant_id and q.id = qci.question_id
|
||||
where qci.tenant_id = qc.tenant_id
|
||||
and qci.collection_id = qc.id
|
||||
and q.status = 'published'
|
||||
)
|
||||
order by qc.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
assert.ok(collection, 'launch smoke needs an active question collection with questions');
|
||||
|
||||
const blueprint = await one(
|
||||
pool,
|
||||
`
|
||||
select id, name, mode
|
||||
from public.practice_blueprints
|
||||
where tenant_id = $1 and collection_id = $2 and status = 'active'
|
||||
order by case mode when 'sequential' then 0 when 'random' then 1 else 2 end, created_at asc
|
||||
limit 1
|
||||
`,
|
||||
[ids.tenant, collection.id],
|
||||
);
|
||||
assert.ok(blueprint, 'launch smoke needs an active practice blueprint');
|
||||
|
||||
return { entry, collection, blueprint };
|
||||
}
|
||||
|
||||
async function studentJourney(pool) {
|
||||
await ensureSvipEntitlement(pool);
|
||||
const pathInfo = await discoverStudentPath(pool);
|
||||
|
||||
const profile = await request('/api/profile/me');
|
||||
assert.equal(profile.item?.userId, ids.student, 'student profile should load current user');
|
||||
|
||||
const entitlements = await request('/api/commerce/entitlements');
|
||||
assert.equal(entitlements.summary?.isSvip, true, 'student should be SVIP before practice journey');
|
||||
|
||||
const entries = await request('/api/catalog/content-entries', { query: { entryType: 'question_practice' } });
|
||||
assert.ok(entries.items?.some(item => item.id === pathInfo.entry.id), 'student should see question practice entry');
|
||||
|
||||
const collections = await request('/api/catalog/question-collections', { query: { nodeId: pathInfo.collection.content_node_id } });
|
||||
assert.ok(collections.items?.some(item => item.id === pathInfo.collection.id), 'student should see question collection');
|
||||
|
||||
const session = await request('/api/learning/practice-sessions', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
blueprintId: pathInfo.blueprint.id,
|
||||
collectionId: pathInfo.collection.id,
|
||||
questionLimit: 3,
|
||||
metadata: { source: 'launch_persona_smoke' },
|
||||
},
|
||||
});
|
||||
assert.ok(session.item?.id, 'student should create practice session');
|
||||
assert.notEqual(session.item?.accessMode, 'free', 'SVIP student practice should not be constrained by free quota');
|
||||
|
||||
const detail = await request('/api/learning/practice-sessions/detail', {
|
||||
query: { practiceSessionId: session.item.id },
|
||||
});
|
||||
const questions = detail.item?.questions || [];
|
||||
assert.ok(questions.length > 0, 'practice detail should include questions');
|
||||
|
||||
const firstQuestion = questions[0];
|
||||
await request('/api/learning/favorites/questions', {
|
||||
method: 'POST',
|
||||
body: { questionId: firstQuestion.id, favorite: true },
|
||||
});
|
||||
|
||||
await request('/api/learning/answers', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
practiceSessionId: session.item.id,
|
||||
questionId: firstQuestion.id,
|
||||
...pickAnswer(firstQuestion),
|
||||
},
|
||||
});
|
||||
|
||||
const report = await request('/api/learning/practice-sessions/submit', {
|
||||
method: 'POST',
|
||||
body: { practiceSessionId: session.item.id },
|
||||
});
|
||||
assert.equal(report.item?.practiceSessionId, session.item.id, 'student should submit practice and receive report');
|
||||
|
||||
const favorites = await request('/api/learning/favorites/questions', { query: { limit: 20 } });
|
||||
assert.ok(favorites.items?.some(item => item.id === firstQuestion.id || item.questionId === firstQuestion.id), 'favorite list should include toggled question');
|
||||
|
||||
const wrong = await request('/api/learning/wrong-questions', { query: { limit: 20 } });
|
||||
const wrongPlan = await request('/api/learning/wrong-questions/review-plan');
|
||||
const favoriteReview = await request('/api/learning/practice-sessions', {
|
||||
method: 'POST',
|
||||
body: { mode: 'favorite_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } },
|
||||
});
|
||||
assert.equal(favoriteReview.item?.mode, 'favorite_review', 'student should create favorite review session');
|
||||
|
||||
const wrongReview =
|
||||
wrong.items?.length > 0
|
||||
? await request('/api/learning/practice-sessions', {
|
||||
method: 'POST',
|
||||
body: { mode: 'wrong_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
profile: { profileId: profile.item?.id, userId: profile.item?.userId, avatarPreset: profile.item?.avatarPreset || null },
|
||||
entitlement: { isSvip: entitlements.summary?.isSvip, count: entitlements.items?.length || 0 },
|
||||
practice: {
|
||||
sessionId: session.item.id,
|
||||
accessMode: session.item.accessMode,
|
||||
questionCount: questions.length,
|
||||
reportId: report.item?.id,
|
||||
correctCount: report.item?.correctCount,
|
||||
wrongCount: report.item?.wrongCount,
|
||||
},
|
||||
favorite: { questionId: firstQuestion.id, favoriteCount: favorites.items?.length || 0, reviewSessionId: favoriteReview.item?.id },
|
||||
wrongReview: {
|
||||
wrongCount: wrong.items?.length || 0,
|
||||
reviewPlanCount: wrongPlan.items?.length || 0,
|
||||
reviewSessionId: wrongReview?.item?.id || null,
|
||||
skippedReason: wrong.items?.length > 0 ? null : 'No wrong questions were present for this student after the sampled answer.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function tenantAdminJourney() {
|
||||
const overview = await request('/api/tenant-admin/overview', { userId: ids.tenantAdmin });
|
||||
const dashboard = await request('/api/tenant-admin/dashboard', {
|
||||
userId: ids.tenantAdmin,
|
||||
query: { timeRange: '7d', regionId: ids.region },
|
||||
});
|
||||
const theme = await request('/api/tenant-admin/theme', { userId: ids.tenantAdmin });
|
||||
const students = await request('/api/tenant-admin/students', {
|
||||
userId: ids.tenantAdmin,
|
||||
query: { limit: 10 },
|
||||
});
|
||||
const conversion = await request('/api/referral/conversion-report', {
|
||||
userId: ids.tenantAdmin,
|
||||
query: { limit: 10 },
|
||||
});
|
||||
const studentDenied = await request('/api/tenant-admin/dashboard', { expectStatus: 403 });
|
||||
const crossTenantDenied = await request('/api/tenant-admin/dashboard', {
|
||||
tenantId: ids.partnerTenant,
|
||||
userId: ids.tenantAdmin,
|
||||
expectStatus: 403,
|
||||
});
|
||||
|
||||
return {
|
||||
overview: { tenantId: overview.item?.id, name: overview.item?.name },
|
||||
dashboard: {
|
||||
tenantId: dashboard.item?.scope?.tenantId,
|
||||
trendBuckets: dashboard.item?.trends?.length || 0,
|
||||
activeHourBuckets: dashboard.item?.activeHours?.length || 0,
|
||||
questionCount: dashboard.item?.cards?.content?.questions || 0,
|
||||
},
|
||||
theme: { status: theme.item?.status || null, activeTemplateCode: theme.item?.activeTemplateCode || null },
|
||||
students: { count: students.items?.length || 0 },
|
||||
referralConversion: {
|
||||
mode: conversion.item?.aggregation?.mode || 'realtime_or_legacy',
|
||||
leadCount: conversion.item?.summary?.leadCount || 0,
|
||||
paidSourceCount: conversion.item?.summary?.paidSourceCount || 0,
|
||||
},
|
||||
guards: {
|
||||
studentDashboardDenied: studentDenied.code,
|
||||
crossTenantDenied: crossTenantDenied.code,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function platformAdminJourney() {
|
||||
const headers = { 'x-platform-admin-key': process.env.PLATFORM_ADMIN_API_KEY || 'local-platform-admin-key' };
|
||||
const overview = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers,
|
||||
});
|
||||
const tenants = await request('/api/platform-admin/tenants', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers,
|
||||
query: { limit: 10 },
|
||||
});
|
||||
const plans = await request('/api/platform-admin/plans', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers,
|
||||
});
|
||||
const auditAlerts = await request('/api/platform-admin/audit-alerts', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers,
|
||||
query: { status: 'open', limit: 10 },
|
||||
});
|
||||
const studentDenied = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
expectStatus: 403,
|
||||
});
|
||||
|
||||
return {
|
||||
overview: {
|
||||
tenantTotal: overview.item?.tenants?.total || 0,
|
||||
activeTenantCount: overview.item?.tenants?.active || 0,
|
||||
},
|
||||
tenants: { count: tenants.items?.length || 0 },
|
||||
plans: { count: plans.items?.length || 0 },
|
||||
auditAlerts: { openCount: auditAlerts.items?.length || 0 },
|
||||
guards: { studentPlatformDenied: studentDenied.code },
|
||||
};
|
||||
}
|
||||
|
||||
function stepCell(step, formatPassed) {
|
||||
if (step.status === 'pass') return formatPassed(step.result);
|
||||
return `失败:${step.error?.message || 'unknown error'}`;
|
||||
}
|
||||
|
||||
async function writeReport(report) {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const jsonPath = path.join(outputDir, `launch-persona-smoke-${shanghaiStamp()}.json`);
|
||||
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
||||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
const lines = [
|
||||
'# 上线前角色旅程烟测报告',
|
||||
'',
|
||||
`生成时间:${new Date(report.finishedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||
'',
|
||||
'| 角色 | 结果 | 关键验证 |',
|
||||
'| --- | --- | --- |',
|
||||
`| 普通学生 | ${report.student.status} | ${stepCell(report.student, result => `SVIP=${result.entitlement.isSvip}; session=${result.practice.sessionId}; favoriteReview=${result.favorite.reviewSessionId}; wrongReview=${result.wrongReview.reviewSessionId || result.wrongReview.skippedReason}`)} |`,
|
||||
`| 租户管理员 | ${report.tenantAdmin.status} | ${stepCell(report.tenantAdmin, result => `dashboardBuckets=${result.dashboard.trendBuckets}; studentDenied=${result.guards.studentDashboardDenied}; crossTenant=${result.guards.crossTenantDenied}`)} |`,
|
||||
`| 平台管理员 | ${report.platformAdmin.status} | ${stepCell(report.platformAdmin, result => `tenants=${result.overview.tenantTotal}; studentDenied=${result.guards.studentPlatformDenied}`)} |`,
|
||||
'',
|
||||
'说明:该脚本会写入少量 `launch_persona_smoke` 测试权益、练习 session、答题和收藏记录。生产环境仅建议在灰度/演练租户运行。',
|
||||
'',
|
||||
];
|
||||
await fs.writeFile(mdPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return { jsonPath, mdPath };
|
||||
}
|
||||
|
||||
async function runStep(name, fn) {
|
||||
const startedAt = nowIso();
|
||||
try {
|
||||
const result = await fn();
|
||||
return { name, status: 'pass', startedAt, finishedAt: nowIso(), result };
|
||||
} catch (error) {
|
||||
return {
|
||||
name,
|
||||
status: 'fail',
|
||||
startedAt,
|
||||
finishedAt: nowIso(),
|
||||
error: {
|
||||
message: error?.message || String(error),
|
||||
stack: error?.stack || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 4 });
|
||||
try {
|
||||
await startServerIfNeeded();
|
||||
if (!apiBase) throw new Error('LAUNCH_SMOKE_API_BASE is required when LAUNCH_SMOKE_START_SERVER=false.');
|
||||
const report = {
|
||||
startedAt: nowIso(),
|
||||
finishedAt: '',
|
||||
apiBase,
|
||||
databaseUrl: databaseUrl.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'),
|
||||
student: await runStep('student', () => studentJourney(pool)),
|
||||
tenantAdmin: await runStep('tenant-admin', tenantAdminJourney),
|
||||
platformAdmin: await runStep('platform-admin', platformAdminJourney),
|
||||
};
|
||||
report.finishedAt = nowIso();
|
||||
report.status = [report.student, report.tenantAdmin, report.platformAdmin].every(item => item.status === 'pass') ? 'pass' : 'fail';
|
||||
const files = await writeReport(report);
|
||||
console.log(`[launch-smoke] status=${report.status}`);
|
||||
console.log(`[launch-smoke] wrote ${files.jsonPath}`);
|
||||
console.log(`[launch-smoke] wrote ${files.mdPath}`);
|
||||
if (report.status !== 'pass') process.exitCode = 1;
|
||||
} finally {
|
||||
await pool.end();
|
||||
stopServer();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
if (serverLogs) console.error(serverLogs);
|
||||
stopServer();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -42,11 +42,11 @@ function sampleReport(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function runScript(report) {
|
||||
function runScript(report, args = []) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-perf-summary-'));
|
||||
const inputPath = path.join(tempDir, 'report.json');
|
||||
fs.writeFileSync(inputPath, JSON.stringify(report, null, 2), 'utf8');
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--input', inputPath, '--json'], {
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--input', inputPath, '--json', ...args], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
@@ -108,4 +108,18 @@ assert.ok(
|
||||
'write benchmark should explain includeWrites failure',
|
||||
);
|
||||
|
||||
const allowedWriteRun = runScript(sampleReport({ config: { includeWrites: true } }), [
|
||||
'--allow-writes',
|
||||
'--min-duration-seconds=60',
|
||||
'--min-concurrency=30',
|
||||
'--max-p95-ms=500',
|
||||
'--max-p99-ms=1200',
|
||||
]);
|
||||
assert.equal(allowedWriteRun.status, 0, `allowed write report should exit 0: ${allowedWriteRun.stdout} ${allowedWriteRun.stderr}`);
|
||||
assert.equal(allowedWriteRun.payload.evaluation?.status, 'pass');
|
||||
assert.equal(allowedWriteRun.payload.evaluation?.options?.allowWrites, true);
|
||||
assert.equal(allowedWriteRun.payload.launchGateCheck, undefined);
|
||||
assert.equal(allowedWriteRun.payload.capacityObservation?.id, 'performance.api-real-data-mixed');
|
||||
assert.equal(allowedWriteRun.payload.capacityObservation?.summary?.includeWrites, true);
|
||||
|
||||
console.log('[PASS] performance summary');
|
||||
|
||||
@@ -18,12 +18,14 @@ function parseArgs(argv) {
|
||||
output: '',
|
||||
json: false,
|
||||
quiet: false,
|
||||
allowWrites: false,
|
||||
thresholds: { ...defaultThresholds },
|
||||
};
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--json') options.json = true;
|
||||
else if (arg === '--quiet') options.quiet = true;
|
||||
else if (arg === '--allow-writes') options.allowWrites = true;
|
||||
else if (arg === '--input') {
|
||||
options.input = argv[index + 1] || '';
|
||||
index += 1;
|
||||
@@ -105,7 +107,7 @@ function summarizeBenchmark(report) {
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateSummary(summary, thresholds = defaultThresholds) {
|
||||
function evaluateSummary(summary, thresholds = defaultThresholds, options = {}) {
|
||||
const failures = [];
|
||||
if (summary.errors !== thresholds.errors) failures.push(`errors expected ${thresholds.errors} but got ${summary.errors}`);
|
||||
if (summary.errorRate > thresholds.errorRate) failures.push(`errorRate expected <= ${thresholds.errorRate} but got ${summary.errorRate}`);
|
||||
@@ -113,12 +115,15 @@ function evaluateSummary(summary, thresholds = defaultThresholds) {
|
||||
if (summary.p99Ms > thresholds.p99Ms) failures.push(`p99Ms expected <= ${thresholds.p99Ms} but got ${summary.p99Ms}`);
|
||||
if (summary.concurrency < thresholds.concurrency) failures.push(`concurrency expected >= ${thresholds.concurrency} but got ${summary.concurrency}`);
|
||||
if (summary.durationSeconds < thresholds.durationSeconds) failures.push(`durationSeconds expected >= ${thresholds.durationSeconds} but got ${summary.durationSeconds}`);
|
||||
if (summary.includeWrites !== false) failures.push('includeWrites expected false');
|
||||
if (!options.allowWrites && summary.includeWrites !== false) failures.push('includeWrites expected false');
|
||||
return {
|
||||
status: failures.length ? 'fail' : 'pass',
|
||||
failures,
|
||||
summary,
|
||||
thresholds,
|
||||
options: {
|
||||
allowWrites: Boolean(options.allowWrites),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +146,27 @@ function launchGateCheck(evaluation) {
|
||||
};
|
||||
}
|
||||
|
||||
function capacityObservation(evaluation) {
|
||||
return {
|
||||
id: evaluation.summary.includeWrites ? 'performance.api-real-data-mixed' : 'performance.api-real-data-read-observation',
|
||||
status: evaluation.status,
|
||||
command: 'npm run perf:api:local',
|
||||
completedAt: new Date().toISOString(),
|
||||
artifact: '',
|
||||
summary: {
|
||||
errors: evaluation.summary.errors,
|
||||
errorRate: evaluation.summary.errorRate,
|
||||
p95Ms: evaluation.summary.p95Ms,
|
||||
p99Ms: evaluation.summary.p99Ms,
|
||||
concurrency: evaluation.summary.concurrency,
|
||||
durationSeconds: evaluation.summary.durationSeconds,
|
||||
includeWrites: evaluation.summary.includeWrites,
|
||||
requests: evaluation.summary.requests,
|
||||
throughputRps: evaluation.summary.throughputRps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
if (!options.input) {
|
||||
@@ -152,12 +178,14 @@ async function main() {
|
||||
const inputPath = path.resolve(process.cwd(), options.input);
|
||||
const report = readJson(inputPath);
|
||||
const summary = summarizeBenchmark(report);
|
||||
const evaluation = evaluateSummary(summary, options.thresholds);
|
||||
const evaluation = evaluateSummary(summary, options.thresholds, { allowWrites: options.allowWrites });
|
||||
const evidenceKey = options.allowWrites ? 'capacityObservation' : 'launchGateCheck';
|
||||
const evidenceValue = options.allowWrites ? capacityObservation(evaluation) : launchGateCheck(evaluation);
|
||||
const payload = {
|
||||
input: inputPath,
|
||||
evaluation,
|
||||
launchGateCheck: {
|
||||
...launchGateCheck(evaluation),
|
||||
[evidenceKey]: {
|
||||
...evidenceValue,
|
||||
artifact: options.input,
|
||||
},
|
||||
};
|
||||
@@ -182,4 +210,4 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
||||
main();
|
||||
}
|
||||
|
||||
export { defaultThresholds, evaluateSummary, launchGateCheck, summarizeBenchmark };
|
||||
export { capacityObservation, defaultThresholds, evaluateSummary, launchGateCheck, summarizeBenchmark };
|
||||
|
||||
259
scripts/postgres-tuning-evidence.js
Normal file
259
scripts/postgres-tuning-evidence.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
const outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||
|
||||
const settingNames = [
|
||||
'max_connections',
|
||||
'shared_buffers',
|
||||
'effective_cache_size',
|
||||
'work_mem',
|
||||
'maintenance_work_mem',
|
||||
'autovacuum_work_mem',
|
||||
'wal_buffers',
|
||||
'min_wal_size',
|
||||
'max_wal_size',
|
||||
'checkpoint_timeout',
|
||||
'checkpoint_completion_target',
|
||||
'effective_io_concurrency',
|
||||
'random_page_cost',
|
||||
'jit',
|
||||
'log_min_duration_statement',
|
||||
'idle_in_transaction_session_timeout',
|
||||
'statement_timeout',
|
||||
'lock_timeout',
|
||||
];
|
||||
|
||||
function shanghaiStamp(date = new Date()) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date).map(part => [part.type, part.value]),
|
||||
);
|
||||
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
|
||||
}
|
||||
|
||||
async function query(pool, sql, params = []) {
|
||||
const result = await pool.query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function maybeQuery(pool, sql, params = []) {
|
||||
try {
|
||||
return await query(pool, sql, params);
|
||||
} catch (error) {
|
||||
return { unavailable: true, message: error?.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function redactUrl(value) {
|
||||
return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
|
||||
}
|
||||
|
||||
function explainSetting(setting) {
|
||||
const value = `${setting.setting}${setting.unit || ''}`;
|
||||
return {
|
||||
name: setting.name,
|
||||
value,
|
||||
source: setting.source,
|
||||
pendingRestart: setting.pending_restart,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(settingsRows) {
|
||||
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
||||
const warnings = [];
|
||||
const maxConnections = Number(byName.get('max_connections')?.setting || 0);
|
||||
const jit = String(byName.get('jit')?.setting || '').toLowerCase();
|
||||
const statementTimeout = String(byName.get('statement_timeout')?.setting || '');
|
||||
const idleTimeout = String(byName.get('idle_in_transaction_session_timeout')?.setting || '');
|
||||
const lockTimeout = String(byName.get('lock_timeout')?.setting || '');
|
||||
|
||||
if (maxConnections > 150) warnings.push('max_connections is high for a 4 vCPU database; prefer API/pooler limits over direct connections.');
|
||||
if (jit === 'on') warnings.push('jit is on; short OLTP-style question-bank API queries usually start safer with jit=off.');
|
||||
if (statementTimeout === '0') warnings.push('statement_timeout is disabled; production API should have a bounded global timeout and import jobs should override per session.');
|
||||
if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.');
|
||||
if (lockTimeout === '0') warnings.push('lock_timeout is disabled; ordinary API requests may wait too long behind locks.');
|
||||
if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.');
|
||||
|
||||
return {
|
||||
status: warnings.length ? 'warn' : 'pass',
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function markdown(report) {
|
||||
const lines = [
|
||||
'# PostgreSQL 调参与运行证据',
|
||||
'',
|
||||
`生成时间:${new Date(report.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||
'',
|
||||
`数据库:${report.databaseUrl}`,
|
||||
'',
|
||||
`评估:${report.evaluation.status}`,
|
||||
'',
|
||||
];
|
||||
if (report.evaluation.warnings.length) {
|
||||
lines.push('## 警告');
|
||||
lines.push('');
|
||||
for (const warning of report.evaluation.warnings) lines.push(`- ${warning}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('## 关键参数');
|
||||
lines.push('');
|
||||
lines.push('| 参数 | 当前值 | 来源 | 待重启 |');
|
||||
lines.push('| --- | ---: | --- | --- |');
|
||||
for (const item of report.settings) {
|
||||
lines.push(`| ${item.name} | ${item.value} | ${item.source} | ${item.pendingRestart ? '是' : '否'} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 连接与等待');
|
||||
lines.push('');
|
||||
lines.push('| state | wait_event_type | wait_event | count |');
|
||||
lines.push('| --- | --- | --- | ---: |');
|
||||
for (const item of report.activityByWait) {
|
||||
lines.push(`| ${item.state || '-'} | ${item.wait_event_type || '-'} | ${item.wait_event || '-'} | ${item.count} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 缓存与事务');
|
||||
lines.push('');
|
||||
lines.push('| datname | commits | rollbacks | cache_hit_ratio |');
|
||||
lines.push('| --- | ---: | ---: | ---: |');
|
||||
for (const item of report.databaseStats) {
|
||||
lines.push(`| ${item.datname} | ${item.xact_commit} | ${item.xact_rollback} | ${item.cache_hit_ratio ?? '-'} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 大表规模');
|
||||
lines.push('');
|
||||
lines.push('| 表 | 估算行数 | 总大小 | 索引大小 |');
|
||||
lines.push('| --- | ---: | ---: | ---: |');
|
||||
for (const item of report.largeRelations) {
|
||||
lines.push(`| ${item.relation} | ${item.estimated_rows} | ${item.total_size} | ${item.index_size} |`);
|
||||
}
|
||||
lines.push('');
|
||||
if (Array.isArray(report.pgStatStatements)) {
|
||||
lines.push('## pg_stat_statements Top SQL');
|
||||
lines.push('');
|
||||
lines.push('| calls | total_exec_ms | mean_exec_ms | rows | query |');
|
||||
lines.push('| ---: | ---: | ---: | ---: | --- |');
|
||||
for (const item of report.pgStatStatements) {
|
||||
lines.push(`| ${item.calls} | ${item.total_exec_ms} | ${item.mean_exec_ms} | ${item.rows} | ${String(item.query || '').replaceAll('|', '\\|')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('## pg_stat_statements');
|
||||
lines.push('');
|
||||
lines.push(`未采集:${report.pgStatStatements?.message || 'extension/view unavailable'}`);
|
||||
lines.push('');
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
|
||||
try {
|
||||
const settingsRaw = await query(
|
||||
pool,
|
||||
`
|
||||
select name, setting, unit, source, pending_restart
|
||||
from pg_settings
|
||||
where name = any($1::text[])
|
||||
order by array_position($1::text[], name)
|
||||
`,
|
||||
[settingNames],
|
||||
);
|
||||
const activityByWait = await query(
|
||||
pool,
|
||||
`
|
||||
select state, wait_event_type, wait_event, count(*)::int as count
|
||||
from pg_stat_activity
|
||||
where datname = current_database()
|
||||
group by state, wait_event_type, wait_event
|
||||
order by count desc, state nulls last
|
||||
`,
|
||||
);
|
||||
const databaseStats = await query(
|
||||
pool,
|
||||
`
|
||||
select datname,
|
||||
xact_commit::text,
|
||||
xact_rollback::text,
|
||||
blks_read::text,
|
||||
blks_hit::text,
|
||||
round(blks_hit * 100.0 / nullif(blks_hit + blks_read, 0), 2)::text as cache_hit_ratio
|
||||
from pg_stat_database
|
||||
where datname = current_database()
|
||||
`,
|
||||
);
|
||||
const bgwriter = await maybeQuery(
|
||||
pool,
|
||||
`
|
||||
select checkpoints_timed::text, checkpoints_req::text,
|
||||
checkpoint_write_time::text, checkpoint_sync_time::text
|
||||
from pg_stat_bgwriter
|
||||
`,
|
||||
);
|
||||
const largeRelations = await query(
|
||||
pool,
|
||||
`
|
||||
select relid::regclass::text as relation,
|
||||
n_live_tup::bigint::text as estimated_rows,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
|
||||
pg_size_pretty(pg_indexes_size(relid)) as index_size
|
||||
from pg_stat_user_tables
|
||||
where schemaname = 'public'
|
||||
order by pg_total_relation_size(relid) desc
|
||||
limit 20
|
||||
`,
|
||||
);
|
||||
const pgStatStatements = await maybeQuery(
|
||||
pool,
|
||||
`
|
||||
select calls::bigint::text,
|
||||
round(total_exec_time::numeric, 2)::text as total_exec_ms,
|
||||
round(mean_exec_time::numeric, 2)::text as mean_exec_ms,
|
||||
rows::bigint::text,
|
||||
left(regexp_replace(query, '\\s+', ' ', 'g'), 180) as query
|
||||
from pg_stat_statements
|
||||
order by total_exec_time desc
|
||||
limit 10
|
||||
`,
|
||||
);
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
databaseUrl: redactUrl(databaseUrl),
|
||||
evaluation: evaluate(settingsRaw),
|
||||
settings: settingsRaw.map(explainSetting),
|
||||
activityByWait,
|
||||
databaseStats,
|
||||
bgwriter,
|
||||
largeRelations,
|
||||
pgStatStatements,
|
||||
};
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`);
|
||||
const mdPath = jsonPath.replace(/\.json$/, '.md');
|
||||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
await fs.writeFile(mdPath, markdown(report), 'utf8');
|
||||
console.log(`[pg-evidence] status=${report.evaluation.status}`);
|
||||
for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`);
|
||||
console.log(`[pg-evidence] wrote ${jsonPath}`);
|
||||
console.log(`[pg-evidence] wrote ${mdPath}`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user