573 lines
17 KiB
JavaScript
573 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createRequire } from 'node:module';
|
|
|
|
const DEFAULTS = {
|
|
databaseUrl: process.env.DATABASE_URL,
|
|
productId: 'perf-product-001',
|
|
projectId: 'perf-project-001-001',
|
|
versionId: 'perf-version-001-001-001',
|
|
userId: 'perf-user-dev-01',
|
|
search: 'REQ',
|
|
limit: 50,
|
|
dryRun: false,
|
|
json: false,
|
|
strictPlan: false,
|
|
};
|
|
|
|
const REQUIRED_INDEXES = [
|
|
'requirements_product_project_status_created_at_idx',
|
|
'requirements_version_status_created_at_idx',
|
|
'version_plans_version_type_status_idx',
|
|
'version_plans_owner_status_end_idx',
|
|
'dev_tasks_version_status_updated_at_idx',
|
|
'dev_tasks_assignee_unfinished_idx',
|
|
'test_cases_version_round_status_updated_at_idx',
|
|
'test_cases_version_round_status_updated_at_desc_idx',
|
|
'test_cases_assignee_unfinished_idx',
|
|
'bugs_version_status_severity_updated_at_idx',
|
|
'bugs_assignee_open_idx',
|
|
'xiaobao_risk_summaries_warning_score_idx',
|
|
'xiaobao_risk_summaries_dirty_updated_at_idx',
|
|
'projects_product_created_at_idx',
|
|
'versions_product_created_at_idx',
|
|
'versions_product_project_created_at_idx',
|
|
'work_activities_version_occurred_at_idx',
|
|
'task_worklogs_version_date_idx',
|
|
'ai_logs_operation_created_at_idx',
|
|
'ai_logs_status_created_at_idx',
|
|
];
|
|
|
|
function parseArgs(argv) {
|
|
const args = { ...DEFAULTS };
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === '--dry-run') {
|
|
args.dryRun = true;
|
|
continue;
|
|
}
|
|
if (arg === '--json') {
|
|
args.json = true;
|
|
continue;
|
|
}
|
|
if (arg === '--strict-plan') {
|
|
args.strictPlan = true;
|
|
continue;
|
|
}
|
|
if (arg === '--database-url') {
|
|
args.databaseUrl = argv[++index] ?? args.databaseUrl;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--database-url=')) {
|
|
args.databaseUrl = arg.slice('--database-url='.length);
|
|
continue;
|
|
}
|
|
if (arg === '--product-id') {
|
|
args.productId = argv[++index] ?? args.productId;
|
|
continue;
|
|
}
|
|
if (arg === '--project-id') {
|
|
args.projectId = argv[++index] ?? args.projectId;
|
|
continue;
|
|
}
|
|
if (arg === '--version-id') {
|
|
args.versionId = argv[++index] ?? args.versionId;
|
|
continue;
|
|
}
|
|
if (arg === '--user-id') {
|
|
args.userId = argv[++index] ?? args.userId;
|
|
continue;
|
|
}
|
|
if (arg === '--search') {
|
|
args.search = argv[++index] ?? args.search;
|
|
continue;
|
|
}
|
|
if (arg === '--limit') {
|
|
args.limit = parsePositiveInt(argv[++index], args.limit);
|
|
continue;
|
|
}
|
|
if (arg === '--help' || arg === '-h') {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node scripts/explain-hot-queries.mjs [--dry-run] [--database-url URL]
|
|
|
|
Audits V2.6 hot query budgets and indexes.
|
|
|
|
Options:
|
|
--dry-run Print hot query contracts without opening a database connection.
|
|
--json Print machine-readable JSON.
|
|
--strict-plan Fail when a SQL plan contains a sequential scan on a hot table.
|
|
--database-url PostgreSQL URL. Defaults to DATABASE_URL.
|
|
--product-id Fixture product id. Defaults to ${DEFAULTS.productId}
|
|
--project-id Fixture project id. Defaults to ${DEFAULTS.projectId}
|
|
--version-id Fixture version id. Defaults to ${DEFAULTS.versionId}
|
|
--user-id Fixture assignee/owner id. Defaults to ${DEFAULTS.userId}
|
|
--search Requirement search term. Defaults to ${DEFAULTS.search}
|
|
--limit Requirement page limit. Defaults to ${DEFAULTS.limit}
|
|
`);
|
|
}
|
|
|
|
function parsePositiveInt(raw, fallback) {
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed)) return fallback;
|
|
return Math.max(1, Math.floor(parsed));
|
|
}
|
|
|
|
function buildExplainTargets(args) {
|
|
const search = `%${args.search}%`;
|
|
const limit = args.limit + 1;
|
|
|
|
return [
|
|
{
|
|
key: 'healthVersion',
|
|
label: 'health/version',
|
|
budget: 'HTTP p95 <= 500ms; no database query',
|
|
partitionKey: 'none',
|
|
expectedIndexes: [],
|
|
sql: null,
|
|
},
|
|
{
|
|
key: 'requirementPool',
|
|
label: 'V2.2 requirement pool search',
|
|
budget: 'HTTP p95 <= 1000ms; SQL should prune by product_id',
|
|
partitionKey: 'requirements.product_id',
|
|
expectedIndexes: [
|
|
'requirements_product_project_status_created_at_idx',
|
|
'requirements_product_id_code_key',
|
|
],
|
|
sql: `
|
|
SELECT r.*
|
|
FROM requirements r
|
|
LEFT JOIN users u ON u.id = r.creator_id
|
|
WHERE r.product_id = $1
|
|
AND (r.code ILIKE $2 OR r.title ILIKE $2)
|
|
ORDER BY r.created_at DESC
|
|
LIMIT $3`,
|
|
params: [args.productId, search, limit],
|
|
},
|
|
{
|
|
key: 'versionDetail.version',
|
|
label: 'V2.2 version detail root',
|
|
budget: 'point lookup by versions.id',
|
|
partitionKey: 'versions.id',
|
|
expectedIndexes: ['versions_pkey'],
|
|
sql: 'SELECT * FROM versions WHERE id = $1',
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'versionDetail.requirements',
|
|
label: 'V2.2 version detail requirements',
|
|
budget: 'child rows constrained by version_id',
|
|
partitionKey: 'requirements.version_id',
|
|
expectedIndexes: ['requirements_version_status_created_at_idx'],
|
|
sql: `
|
|
SELECT r.*
|
|
FROM requirements r
|
|
LEFT JOIN users u ON u.id = r.creator_id
|
|
WHERE r.version_id = $1
|
|
ORDER BY r.created_at DESC`,
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'versionDetail.plans',
|
|
label: 'V2.2 version detail plans',
|
|
budget: 'child rows constrained by version_id',
|
|
partitionKey: 'version_plans.version_id',
|
|
expectedIndexes: ['version_plans_version_type_status_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM version_plans
|
|
WHERE version_id = $1
|
|
ORDER BY type ASC, created_at DESC`,
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'versionDetail.devTasks',
|
|
label: 'V2.2 version detail dev tasks',
|
|
budget: 'partition prune by version_id',
|
|
partitionKey: 'dev_tasks.version_id',
|
|
expectedIndexes: ['dev_tasks_version_status_updated_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM dev_tasks
|
|
WHERE version_id = $1
|
|
ORDER BY status ASC, updated_at DESC`,
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'versionDetail.testCases',
|
|
label: 'V2.2 version detail test cases',
|
|
budget: 'partition prune by version_id',
|
|
partitionKey: 'test_cases.version_id',
|
|
expectedIndexes: ['test_cases_version_round_status_updated_at_desc_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM test_cases
|
|
WHERE version_id = $1
|
|
ORDER BY round_no DESC, status ASC, updated_at DESC`,
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'versionDetail.bugs',
|
|
label: 'V2.2 version detail bugs',
|
|
budget: 'partition prune by version_id',
|
|
partitionKey: 'bugs.version_id',
|
|
expectedIndexes: ['bugs_version_status_priority_updated_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM bugs
|
|
WHERE version_id = $1
|
|
ORDER BY status ASC, priority ASC, updated_at DESC`,
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'workspace.plans',
|
|
label: 'V2.2 workspace plans',
|
|
budget: 'owner unfinished lookup',
|
|
partitionKey: 'version_plans.owner_id',
|
|
expectedIndexes: ['version_plans_owner_open_due_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM version_plans
|
|
WHERE owner_id = $1
|
|
AND status <> 'completed'
|
|
ORDER BY expected_end_at ASC, updated_at DESC`,
|
|
params: [args.userId],
|
|
},
|
|
{
|
|
key: 'workspace.devTasks',
|
|
label: 'V2.2 workspace dev tasks',
|
|
budget: 'assignee unfinished lookup',
|
|
partitionKey: 'dev_tasks.assignee_id',
|
|
expectedIndexes: ['dev_tasks_assignee_open_priority_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM dev_tasks
|
|
WHERE assignee_id = $1
|
|
AND status <> 'submitted'
|
|
ORDER BY priority ASC, updated_at DESC`,
|
|
params: [args.userId],
|
|
},
|
|
{
|
|
key: 'workspace.testCases',
|
|
label: 'V2.2 workspace test cases',
|
|
budget: 'assignee unfinished lookup',
|
|
partitionKey: 'test_cases.assignee_id',
|
|
expectedIndexes: ['test_cases_assignee_open_priority_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM test_cases
|
|
WHERE assignee_id = $1
|
|
AND status NOT IN ('passed', 'failed', 'blocked')
|
|
ORDER BY priority ASC, updated_at DESC`,
|
|
params: [args.userId],
|
|
},
|
|
{
|
|
key: 'workspace.bugs',
|
|
label: 'V2.2 workspace bugs',
|
|
budget: 'assignee open lookup',
|
|
partitionKey: 'bugs.assignee_id',
|
|
expectedIndexes: ['bugs_assignee_open_priority_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM bugs
|
|
WHERE assignee_id = $1
|
|
AND status IN ('open', 'fixing', 'fixed', 'verifying')
|
|
ORDER BY priority ASC, updated_at DESC`,
|
|
params: [args.userId],
|
|
},
|
|
{
|
|
key: 'xiaobao.managerWarnings',
|
|
label: 'V2.2 Xiaobao manager warning list',
|
|
budget: 'HTTP p95 <= 1000ms; warning rows sorted by score',
|
|
partitionKey: 'xiaobao_risk_summaries.version_id',
|
|
expectedIndexes: ['xiaobao_risk_summaries_warning_score_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM xiaobao_risk_summaries
|
|
WHERE risk_level <> 'on_track'
|
|
ORDER BY risk_score DESC, updated_at DESC`,
|
|
params: [],
|
|
},
|
|
{
|
|
key: 'xiaobao.dirtyQueue',
|
|
label: 'V2.6 Xiaobao dirty summary queue',
|
|
budget: 'background worker batch scan',
|
|
partitionKey: 'xiaobao_risk_summaries.version_id',
|
|
expectedIndexes: ['xiaobao_risk_summaries_dirty_updated_at_idx'],
|
|
sql: `
|
|
SELECT version_id
|
|
FROM xiaobao_risk_summaries
|
|
WHERE dirty = true
|
|
ORDER BY updated_at ASC
|
|
LIMIT 100`,
|
|
params: [],
|
|
},
|
|
{
|
|
key: 'domain.projects',
|
|
label: 'Project list by product',
|
|
budget: 'domain list keeps product_id prefix',
|
|
partitionKey: 'projects.product_id',
|
|
expectedIndexes: ['projects_product_created_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM projects
|
|
WHERE product_id = $1
|
|
ORDER BY created_at DESC`,
|
|
params: [args.productId],
|
|
},
|
|
{
|
|
key: 'domain.versionsByProduct',
|
|
label: 'Version list by product',
|
|
budget: 'domain list keeps product_id prefix',
|
|
partitionKey: 'versions.product_id',
|
|
expectedIndexes: ['versions_product_created_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM versions
|
|
WHERE product_id = $1
|
|
ORDER BY created_at DESC`,
|
|
params: [args.productId],
|
|
},
|
|
{
|
|
key: 'domain.versionsByProject',
|
|
label: 'Version list by product/project',
|
|
budget: 'domain list keeps product_id + project_id prefix',
|
|
partitionKey: 'versions.product_id, versions.project_id',
|
|
expectedIndexes: ['versions_product_project_created_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM versions
|
|
WHERE product_id = $1
|
|
AND project_id = $2
|
|
ORDER BY created_at DESC`,
|
|
params: [args.productId, args.projectId],
|
|
},
|
|
{
|
|
key: 'xiaobao.versionActivities',
|
|
label: 'Xiaobao evidence activity scan',
|
|
budget: 'background summary recompute by version',
|
|
partitionKey: 'work_activities.version_id',
|
|
expectedIndexes: ['work_activities_version_occurred_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM work_activities
|
|
WHERE version_id = $1
|
|
ORDER BY occurred_at DESC
|
|
LIMIT 200`,
|
|
params: [args.versionId],
|
|
},
|
|
{
|
|
key: 'audit.searchAdapter',
|
|
label: 'Audit search adapter contract',
|
|
budget: 'V2.5 audit module not landed; AiLog audit uses operation/status created_at indexes',
|
|
partitionKey: 'ai_logs.created_at',
|
|
expectedIndexes: ['ai_logs_operation_created_at_idx', 'ai_logs_status_created_at_idx'],
|
|
sql: `
|
|
SELECT *
|
|
FROM ai_logs
|
|
WHERE operation = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`,
|
|
params: ['risk-interpret'],
|
|
adapter: 'Replace with audit_events(actor_id, resource_type, created_at) once V2.5 audit contract lands.',
|
|
},
|
|
];
|
|
}
|
|
|
|
function printDryRun(targets) {
|
|
console.log('Hot query explain dry-run');
|
|
console.log('key\tpartition_key\texpected_indexes\tbudget');
|
|
for (const target of targets) {
|
|
console.log([
|
|
target.key,
|
|
target.partitionKey,
|
|
target.expectedIndexes.join(',') || '-',
|
|
target.budget,
|
|
].join('\t'));
|
|
}
|
|
}
|
|
|
|
async function loadPrismaClient(databaseUrl) {
|
|
if (databaseUrl) process.env.DATABASE_URL = databaseUrl;
|
|
const serverRequire = createRequire(new URL('../apps/server/package.json', import.meta.url));
|
|
const { PrismaClient } = serverRequire('@prisma/client');
|
|
return new PrismaClient();
|
|
}
|
|
|
|
async function auditIndexes(prisma, indexNames) {
|
|
const uniqueNames = Array.from(new Set(indexNames));
|
|
const valuesSql = uniqueNames.map((_, index) => `($${index + 1})`).join(', ');
|
|
const rows = await prisma.$queryRawUnsafe(
|
|
`
|
|
WITH wanted(index_name) AS (
|
|
VALUES ${valuesSql}
|
|
)
|
|
SELECT wanted.index_name, to_regclass('public.' || quote_ident(wanted.index_name)) IS NOT NULL AS present
|
|
FROM wanted
|
|
ORDER BY wanted.index_name
|
|
`,
|
|
...uniqueNames,
|
|
);
|
|
return rows.map((row) => ({
|
|
indexName: row.index_name,
|
|
present: Boolean(row.present),
|
|
}));
|
|
}
|
|
|
|
async function explainTarget(prisma, target) {
|
|
if (!target.sql) {
|
|
return {
|
|
key: target.key,
|
|
label: target.label,
|
|
skipped: true,
|
|
reason: 'no SQL query',
|
|
};
|
|
}
|
|
|
|
const rows = await prisma.$queryRawUnsafe(
|
|
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${target.sql}`,
|
|
...(target.params ?? []),
|
|
);
|
|
const plan = readExplainPlan(rows);
|
|
const summary = summarizePlan(plan);
|
|
return {
|
|
key: target.key,
|
|
label: target.label,
|
|
skipped: false,
|
|
summary,
|
|
seqScans: collectSeqScans(plan),
|
|
};
|
|
}
|
|
|
|
function readExplainPlan(rows) {
|
|
const first = rows?.[0];
|
|
if (!first) return null;
|
|
const raw = first['QUERY PLAN'] ?? first['QUERY PLAN'.toLowerCase()] ?? first.query_plan;
|
|
if (Array.isArray(raw)) return raw[0];
|
|
if (typeof raw === 'string') return JSON.parse(raw)[0];
|
|
return raw?.[0] ?? raw;
|
|
}
|
|
|
|
function summarizePlan(plan) {
|
|
const root = plan?.Plan ?? plan;
|
|
return {
|
|
nodeType: root?.['Node Type'] ?? 'unknown',
|
|
totalCost: Number(root?.['Total Cost'] ?? 0),
|
|
actualTotalTimeMs: Number(root?.['Actual Total Time'] ?? 0),
|
|
actualRows: Number(root?.['Actual Rows'] ?? 0),
|
|
sharedHitBlocks: Number(root?.['Shared Hit Blocks'] ?? 0),
|
|
sharedReadBlocks: Number(root?.['Shared Read Blocks'] ?? 0),
|
|
};
|
|
}
|
|
|
|
function collectSeqScans(plan) {
|
|
const found = [];
|
|
visitPlan(plan?.Plan ?? plan, (node) => {
|
|
if (node?.['Node Type'] === 'Seq Scan') {
|
|
found.push({
|
|
relation: node['Relation Name'] ?? 'unknown',
|
|
alias: node.Alias ?? '',
|
|
filter: node.Filter ?? '',
|
|
});
|
|
}
|
|
});
|
|
return found;
|
|
}
|
|
|
|
function visitPlan(node, callback) {
|
|
if (!node) return;
|
|
callback(node);
|
|
for (const child of node.Plans ?? []) visitPlan(child, callback);
|
|
}
|
|
|
|
function printResults(indexAudit, explainResults, strictPlan) {
|
|
console.log('Index audit');
|
|
for (const item of indexAudit) {
|
|
console.log(`${item.present ? 'ok' : 'missing'}\t${item.indexName}`);
|
|
}
|
|
|
|
console.log('\nExplain plans');
|
|
console.log('key\tnode\tactual_ms\tactual_rows\tshared_read_blocks\tseq_scan');
|
|
for (const result of explainResults) {
|
|
if (result.skipped) {
|
|
console.log(`${result.key}\tskipped\t-\t-\t-\t${result.reason}`);
|
|
continue;
|
|
}
|
|
const seqScan = result.seqScans.map((scan) => scan.relation).join(',') || '-';
|
|
console.log([
|
|
result.key,
|
|
result.summary.nodeType,
|
|
result.summary.actualTotalTimeMs.toFixed(3),
|
|
result.summary.actualRows,
|
|
result.summary.sharedReadBlocks,
|
|
seqScan,
|
|
].join('\t'));
|
|
}
|
|
|
|
if (strictPlan) {
|
|
console.log('\nstrict-plan enabled: sequential scans on SQL targets are treated as failures.');
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const targets = buildExplainTargets(args);
|
|
const expectedIndexes = [
|
|
...REQUIRED_INDEXES,
|
|
...targets.flatMap((target) => target.expectedIndexes),
|
|
];
|
|
|
|
if (args.dryRun) {
|
|
const payload = {
|
|
targets: targets.map(({ sql, params, ...target }) => ({
|
|
...target,
|
|
sql: sql?.trim() ?? null,
|
|
params,
|
|
})),
|
|
requiredIndexes: Array.from(new Set(expectedIndexes)).sort(),
|
|
};
|
|
if (args.json) console.log(JSON.stringify(payload, null, 2));
|
|
else printDryRun(targets);
|
|
return;
|
|
}
|
|
|
|
if (!args.databaseUrl) {
|
|
throw new Error('DATABASE_URL is required unless --dry-run is used');
|
|
}
|
|
|
|
const prisma = await loadPrismaClient(args.databaseUrl);
|
|
try {
|
|
const indexAudit = await auditIndexes(prisma, expectedIndexes);
|
|
const explainResults = [];
|
|
for (const target of targets) {
|
|
explainResults.push(await explainTarget(prisma, target));
|
|
}
|
|
|
|
if (args.json) {
|
|
console.log(JSON.stringify({ indexAudit, explainResults }, null, 2));
|
|
} else {
|
|
printResults(indexAudit, explainResults, args.strictPlan);
|
|
}
|
|
|
|
const hasMissingIndexes = indexAudit.some((item) => !item.present);
|
|
const hasSeqScans = explainResults.some((result) => !result.skipped && result.seqScans.length > 0);
|
|
if (hasMissingIndexes || (args.strictPlan && hasSeqScans)) {
|
|
process.exitCode = 1;
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|