feat(perf): 增加热查询索引审计
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
-- V2.6 hot query indexes for performance harness, query budget audit,
|
||||
-- and background Xiaobao summary refresh scans.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "projects_product_created_at_idx"
|
||||
ON "projects"("product_id", "created_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "versions_product_created_at_idx"
|
||||
ON "versions"("product_id", "created_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "versions_product_project_created_at_idx"
|
||||
ON "versions"("product_id", "project_id", "created_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "version_plans_owner_open_due_idx"
|
||||
ON "version_plans"("owner_id", "expected_end_at" ASC, "updated_at" DESC)
|
||||
WHERE "status" <> 'completed';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "dev_tasks_assignee_open_priority_idx"
|
||||
ON "dev_tasks"("assignee_id", "priority" ASC, "updated_at" DESC)
|
||||
WHERE "status" <> 'submitted';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "test_cases_assignee_open_priority_idx"
|
||||
ON "test_cases"("assignee_id", "priority" ASC, "updated_at" DESC)
|
||||
WHERE "status" NOT IN ('passed', 'failed', 'blocked');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "bugs_version_status_priority_updated_at_idx"
|
||||
ON "bugs"("version_id", "status" ASC, "priority" ASC, "updated_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "bugs_assignee_open_priority_idx"
|
||||
ON "bugs"("assignee_id", "priority" ASC, "updated_at" DESC)
|
||||
WHERE "status" IN ('open', 'fixing', 'fixed', 'verifying');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "test_cases_version_round_status_updated_at_desc_idx"
|
||||
ON "test_cases"("version_id", "round_no" DESC, "status" ASC, "updated_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "xiaobao_risk_summaries_warning_score_idx"
|
||||
ON "xiaobao_risk_summaries"("risk_score" DESC, "updated_at" DESC)
|
||||
WHERE "risk_level" <> 'on_track';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "xiaobao_risk_summaries_dirty_updated_at_idx"
|
||||
ON "xiaobao_risk_summaries"("updated_at" ASC)
|
||||
WHERE "dirty" = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "work_activities_version_occurred_at_idx"
|
||||
ON "work_activities"("version_id", "occurred_at" DESC);
|
||||
79
docs/performance-hot-queries.md
Normal file
79
docs/performance-hot-queries.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# V2.6 Hot Query Budget And Index Audit
|
||||
|
||||
This document records the V2.6 query budget for the large-data harness and the index contracts that keep hot APIs on partition keys.
|
||||
|
||||
## Commands
|
||||
|
||||
Offline contract audit:
|
||||
|
||||
```bash
|
||||
node scripts/explain-hot-queries.mjs --dry-run
|
||||
```
|
||||
|
||||
Database explain audit:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain
|
||||
```
|
||||
|
||||
Strict plan mode is available for seeded medium/large databases:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain -- --strict-plan
|
||||
```
|
||||
|
||||
`--strict-plan` fails on sequential scans. It is useful after the medium fixture is seeded and analyzed, but not required for empty or tiny local databases where PostgreSQL may choose a sequential scan correctly.
|
||||
|
||||
## Budgets
|
||||
|
||||
| Area | Query Shape | Budget |
|
||||
| --- | --- | --- |
|
||||
| `health/version` | no database query | HTTP p95 <= 500ms |
|
||||
| Requirement pool | `requirements.product_id` + optional filters/search, cursor, `created_at` sort | HTTP p95 <= 1000ms |
|
||||
| Version detail | root version plus child rows by `version_id` | HTTP p95 <= 1500ms |
|
||||
| Workspace | owner/assignee unfinished rows | HTTP p95 <= 1200ms |
|
||||
| Xiaobao warnings | non-`on_track` summaries by score | HTTP p95 <= 1000ms |
|
||||
| Xiaobao dirty queue | `dirty=true` summaries ordered by `updated_at` | background batch <= 100 rows |
|
||||
| Audit search adapter | V2.5 audit table pending; `ai_logs` is the current AI audit surface | explain-only contract |
|
||||
|
||||
## Required Index Contracts
|
||||
|
||||
V2.6 keeps the existing partition prefixes:
|
||||
|
||||
- Requirement pool queries must include `productId`; `requirements` is hash-partitioned by `product_id`.
|
||||
- Version detail child queries must include `versionId`; `dev_tasks`, `test_cases`, and `bugs` are hash-partitioned by `version_id`.
|
||||
- Append evidence tables stay range-partitioned by `created_at`; background workers must still filter by `version_id`, `user_id`, or date before scanning.
|
||||
|
||||
Added in migration `20260708030000_v26_hot_query_indexes`:
|
||||
|
||||
| Index | Purpose |
|
||||
| --- | --- |
|
||||
| `projects_product_created_at_idx` | product-scoped project list |
|
||||
| `versions_product_created_at_idx` | product-scoped version list |
|
||||
| `versions_product_project_created_at_idx` | project-scoped version list |
|
||||
| `version_plans_owner_open_due_idx` | workspace plan queue |
|
||||
| `dev_tasks_assignee_open_priority_idx` | workspace dev task queue |
|
||||
| `test_cases_assignee_open_priority_idx` | workspace test case queue |
|
||||
| `bugs_version_status_priority_updated_at_idx` | version detail bug ordering |
|
||||
| `bugs_assignee_open_priority_idx` | workspace bug queue |
|
||||
| `test_cases_version_round_status_updated_at_desc_idx` | version detail test case ordering |
|
||||
| `xiaobao_risk_summaries_warning_score_idx` | manager Xiaobao warning list |
|
||||
| `xiaobao_risk_summaries_dirty_updated_at_idx` | background Xiaobao dirty summary queue |
|
||||
| `work_activities_version_occurred_at_idx` | Xiaobao evidence recompute |
|
||||
|
||||
Existing V2.2 indexes remain part of the contract, including requirement pool indexes, version child indexes, workspace partial indexes, task worklog date indexes, Xiaobao snapshot/insight indexes, and `ai_logs` operation/status indexes.
|
||||
|
||||
## Audit Adapter Note
|
||||
|
||||
The V2.5 RBAC/audit contract is not present in this branch. V2.6 therefore documents `audit.searchAdapter` as an adapter target instead of inventing a temporary audit table. When audit lands, the expected query shape should be:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM audit_events
|
||||
WHERE product_id = $1
|
||||
AND created_at >= $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
```
|
||||
|
||||
Expected future index: `(product_id, created_at DESC)` plus actor/resource indexes required by the audit module. Until then, `ai_logs_operation_created_at_idx` and `ai_logs_status_created_at_idx` cover AI operation audit searches only.
|
||||
@@ -54,6 +54,13 @@ Run the hot-path harness against a running NestJS API:
|
||||
pnpm perf:check -- --base-url http://localhost:3001/api/v1
|
||||
```
|
||||
|
||||
Audit SQL plans and hot-path indexes:
|
||||
|
||||
```bash
|
||||
pnpm perf:explain -- --dry-run
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm pnpm perf:explain
|
||||
```
|
||||
|
||||
## Hot Probes
|
||||
|
||||
`perf-check` measures p50 and p95 latency, records HTTP status code counts, and exits non-zero when a request fails or p95 exceeds the current query budget.
|
||||
@@ -68,6 +75,8 @@ pnpm perf:check -- --base-url http://localhost:3001/api/v1
|
||||
|
||||
The medium fixture is the V2.6 acceptance target. Large fixture runs are for index audit and explain-plan work, not for every local commit.
|
||||
|
||||
See `docs/performance-hot-queries.md` for query budgets, partition-key contracts, and required indexes.
|
||||
|
||||
## Notes
|
||||
|
||||
- `seed-large-dataset` uses deterministic IDs and dates so repeated runs are comparable.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"test": "turbo test",
|
||||
"perf:seed": "node scripts/seed-large-dataset.mjs",
|
||||
"perf:check": "node scripts/perf-check.mjs",
|
||||
"perf:explain": "node scripts/explain-hot-queries.mjs",
|
||||
"deploy:verify": "node scripts/verify-production-deploy.mjs",
|
||||
"deploy:check-runtime": "node scripts/check-runtime-version.mjs",
|
||||
"deploy:local:build": "docker compose --env-file .env.local-server -f docker-compose.local.yml build",
|
||||
|
||||
572
scripts/explain-hot-queries.mjs
Normal file
572
scripts/explain-hot-queries.mjs
Normal file
@@ -0,0 +1,572 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user