forked from wangziqi/gongxue-base
chore: gate production postgres tuning and taro handoff
This commit is contained in:
175
scripts/lib/postgres-tuning-profile.js
Normal file
175
scripts/lib/postgres-tuning-profile.js
Normal file
@@ -0,0 +1,175 @@
|
||||
export const postgresTuningProfiles = {
|
||||
'shared-host': {
|
||||
name: 'shared-host',
|
||||
label: '4c16g shared-host',
|
||||
description: '4 vCPU / 16GB RAM, PostgreSQL shares the host with API, worker, Nginx and monitoring agents.',
|
||||
settings: {
|
||||
max_connections: {
|
||||
recommended: '80',
|
||||
min: 40,
|
||||
max: 120,
|
||||
type: 'number',
|
||||
rationale: '4 vCPU should use application/pooler limits instead of many direct active connections.',
|
||||
},
|
||||
shared_buffers: {
|
||||
recommended: '3GB',
|
||||
min: '2GB',
|
||||
max: '4GB',
|
||||
type: 'bytes',
|
||||
restart: true,
|
||||
rationale: 'Keep PostgreSQL buffers large enough while leaving RAM for OS cache and sibling services.',
|
||||
},
|
||||
effective_cache_size: {
|
||||
recommended: '10GB',
|
||||
min: '8GB',
|
||||
max: '12GB',
|
||||
type: 'bytes',
|
||||
rationale: 'Planner estimate only; reflects shared buffers plus OS cache on a 16GB shared host.',
|
||||
},
|
||||
work_mem: {
|
||||
recommended: '16MB',
|
||||
min: '8MB',
|
||||
max: '32MB',
|
||||
type: 'bytes',
|
||||
rationale: 'This is per sort/hash operation, not a global cap.',
|
||||
},
|
||||
maintenance_work_mem: {
|
||||
recommended: '512MB',
|
||||
min: '256MB',
|
||||
max: '1GB',
|
||||
type: 'bytes',
|
||||
rationale: 'Helps index builds, vacuum and imports without overcommitting memory.',
|
||||
},
|
||||
autovacuum_work_mem: {
|
||||
recommended: '256MB',
|
||||
min: '128MB',
|
||||
max: '512MB',
|
||||
type: 'bytes',
|
||||
rationale: 'Avoid letting multiple autovacuum workers inherit an overly high maintenance_work_mem.',
|
||||
},
|
||||
wal_buffers: {
|
||||
recommended: '16MB',
|
||||
min: '8MB',
|
||||
max: '64MB',
|
||||
type: 'bytes',
|
||||
restart: true,
|
||||
rationale: 'Enough WAL buffering for steady learning writes and import bursts.',
|
||||
},
|
||||
min_wal_size: {
|
||||
recommended: '1GB',
|
||||
min: '1GB',
|
||||
max: '4GB',
|
||||
type: 'bytes',
|
||||
rationale: 'Reserve WAL for short write spikes and batch jobs.',
|
||||
},
|
||||
max_wal_size: {
|
||||
recommended: '6GB',
|
||||
min: '4GB',
|
||||
max: '12GB',
|
||||
type: 'bytes',
|
||||
rationale: 'Reduce checkpoint pressure while keeping crash recovery bounded.',
|
||||
},
|
||||
checkpoint_timeout: {
|
||||
recommended: '10min',
|
||||
min: '5min',
|
||||
max: '15min',
|
||||
type: 'duration',
|
||||
rationale: 'Avoid overly frequent checkpoints on write bursts.',
|
||||
},
|
||||
checkpoint_completion_target: {
|
||||
recommended: '0.9',
|
||||
min: 0.8,
|
||||
max: 0.95,
|
||||
type: 'number',
|
||||
rationale: 'Spread checkpoint I/O without pushing completion too close to the next checkpoint.',
|
||||
},
|
||||
effective_io_concurrency: {
|
||||
recommended: '100',
|
||||
min: 50,
|
||||
max: 300,
|
||||
type: 'number',
|
||||
rationale: 'SSD cloud disk starting point; validate against the actual disk class.',
|
||||
},
|
||||
random_page_cost: {
|
||||
recommended: '1.1',
|
||||
min: 1,
|
||||
max: 1.5,
|
||||
type: 'number',
|
||||
rationale: 'Reflect SSD random I/O and encourage reasonable index scans.',
|
||||
},
|
||||
jit: {
|
||||
recommended: 'off',
|
||||
exact: 'off',
|
||||
type: 'text',
|
||||
rationale: 'Question-bank API queries are mostly short OLTP requests where JIT planning cost is usually not worth it.',
|
||||
},
|
||||
log_min_duration_statement: {
|
||||
recommended: '500ms',
|
||||
min: '100ms',
|
||||
max: '1000ms',
|
||||
type: 'duration',
|
||||
rationale: 'Capture slow SQL during launch without logging every normal request.',
|
||||
},
|
||||
idle_in_transaction_session_timeout: {
|
||||
recommended: '60s',
|
||||
min: '30s',
|
||||
max: '120s',
|
||||
type: 'duration',
|
||||
rationale: 'Kill idle transactions before they block migrations and writes.',
|
||||
},
|
||||
statement_timeout: {
|
||||
recommended: '30s',
|
||||
min: '5s',
|
||||
max: '60s',
|
||||
type: 'duration',
|
||||
rationale: 'API requests should fail boundedly; import jobs can override per session.',
|
||||
},
|
||||
lock_timeout: {
|
||||
recommended: '5s',
|
||||
min: '1s',
|
||||
max: '10s',
|
||||
type: 'duration',
|
||||
rationale: 'Ordinary API requests should not wait a long time behind locks.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'dedicated-db': {
|
||||
name: 'dedicated-db',
|
||||
label: '4c16g dedicated PostgreSQL',
|
||||
description: '4 vCPU / 16GB RAM where PostgreSQL is the only heavy service on the host.',
|
||||
settings: {
|
||||
max_connections: { recommended: '120', min: 60, max: 150, type: 'number' },
|
||||
shared_buffers: { recommended: '4GB', min: '3GB', max: '5GB', type: 'bytes', restart: true },
|
||||
effective_cache_size: { recommended: '12GB', min: '10GB', max: '14GB', type: 'bytes' },
|
||||
work_mem: { recommended: '16MB', min: '8MB', max: '32MB', type: 'bytes' },
|
||||
maintenance_work_mem: { recommended: '768MB', min: '512MB', max: '1536MB', type: 'bytes' },
|
||||
autovacuum_work_mem: { recommended: '256MB', min: '128MB', max: '512MB', type: 'bytes' },
|
||||
wal_buffers: { recommended: '16MB', min: '8MB', max: '64MB', type: 'bytes', restart: true },
|
||||
min_wal_size: { recommended: '2GB', min: '1GB', max: '4GB', type: 'bytes' },
|
||||
max_wal_size: { recommended: '8GB', min: '4GB', max: '16GB', type: 'bytes' },
|
||||
checkpoint_timeout: { recommended: '15min', min: '5min', max: '20min', type: 'duration' },
|
||||
checkpoint_completion_target: { recommended: '0.9', min: 0.8, max: 0.95, type: 'number' },
|
||||
effective_io_concurrency: { recommended: '100', min: 50, max: 300, type: 'number' },
|
||||
random_page_cost: { recommended: '1.1', min: 1, max: 1.5, type: 'number' },
|
||||
jit: { recommended: 'off', exact: 'off', type: 'text' },
|
||||
log_min_duration_statement: { recommended: '500ms', min: '100ms', max: '1000ms', type: 'duration' },
|
||||
idle_in_transaction_session_timeout: { recommended: '60s', min: '30s', max: '120s', type: 'duration' },
|
||||
statement_timeout: { recommended: '30s', min: '5s', max: '60s', type: 'duration' },
|
||||
lock_timeout: { recommended: '5s', min: '1s', max: '10s', type: 'duration' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultPostgresTuningProfile = 'shared-host';
|
||||
|
||||
export function getPostgresTuningProfile(name = defaultPostgresTuningProfile) {
|
||||
const profile = postgresTuningProfiles[name];
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown PostgreSQL tuning profile: ${name}`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function getPostgresTuningSettingNames(profileName = defaultPostgresTuningProfile) {
|
||||
return Object.keys(getPostgresTuningProfile(profileName).settings);
|
||||
}
|
||||
38
scripts/postgres-tuning-evidence-test.js
Normal file
38
scripts/postgres-tuning-evidence-test.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, 'scripts', 'postgres-tuning-evidence.js');
|
||||
|
||||
function run(args) {
|
||||
return spawnSync(process.execPath, [scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: process.env.PATH || '',
|
||||
Path: process.env.Path || '',
|
||||
SystemRoot: process.env.SystemRoot || '',
|
||||
ComSpec: process.env.ComSpec || '',
|
||||
TEMP: process.env.TEMP || '',
|
||||
TMP: process.env.TMP || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sharedSql = run(['--print-sql', '--profile=shared-host']);
|
||||
assert.equal(sharedSql.status, 0, sharedSql.stderr);
|
||||
assert.match(sharedSql.stdout, /alter system set shared_buffers = '3GB';/);
|
||||
assert.match(sharedSql.stdout, /alter system set statement_timeout = '30s';/);
|
||||
assert.match(sharedSql.stdout, /alter system set jit = 'off';/);
|
||||
|
||||
const dedicatedSql = run(['--print-sql', '--profile=dedicated-db']);
|
||||
assert.equal(dedicatedSql.status, 0, dedicatedSql.stderr);
|
||||
assert.match(dedicatedSql.stdout, /alter system set shared_buffers = '4GB';/);
|
||||
assert.match(dedicatedSql.stdout, /alter system set max_wal_size = '8GB';/);
|
||||
|
||||
const invalidProfile = run(['--print-sql', '--profile=unknown']);
|
||||
assert.notEqual(invalidProfile.status, 0, 'unknown profile should fail');
|
||||
assert.match(`${invalidProfile.stderr}${invalidProfile.stdout}`, /Unknown PostgreSQL tuning profile/);
|
||||
|
||||
console.log('[PASS] PostgreSQL tuning evidence helpers');
|
||||
@@ -1,30 +1,38 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
import {
|
||||
defaultPostgresTuningProfile,
|
||||
getPostgresTuningProfile,
|
||||
getPostgresTuningSettingNames,
|
||||
} from './lib/postgres-tuning-profile.js';
|
||||
|
||||
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 parseArgs(argv) {
|
||||
const options = {
|
||||
profile: process.env.PG_TUNING_PROFILE || defaultPostgresTuningProfile,
|
||||
strict: process.env.PG_TUNING_STRICT === 'true',
|
||||
printSql: false,
|
||||
json: false,
|
||||
};
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--strict') options.strict = true;
|
||||
else if (arg === '--json') options.json = true;
|
||||
else if (arg === '--print-sql') options.printSql = true;
|
||||
else if (arg === '--profile') {
|
||||
options.profile = argv[index + 1] || options.profile;
|
||||
index += 1;
|
||||
} else if (arg.startsWith('--profile=')) {
|
||||
options.profile = arg.slice('--profile='.length);
|
||||
}
|
||||
}
|
||||
options.profileDefinition = getPostgresTuningProfile(options.profile);
|
||||
options.settingNames = getPostgresTuningSettingNames(options.profile);
|
||||
return options;
|
||||
}
|
||||
|
||||
function shanghaiStamp(date = new Date()) {
|
||||
const parts = Object.fromEntries(
|
||||
@@ -59,19 +67,184 @@ function redactUrl(value) {
|
||||
return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
|
||||
}
|
||||
|
||||
function pgUnitMultiplier(unit) {
|
||||
const text = String(unit || '').trim().toLowerCase();
|
||||
if (!text) return 1;
|
||||
if (text === 'ms') return 1;
|
||||
if (text === 's') return 1000;
|
||||
if (text === 'min') return 60_000;
|
||||
const memoryMatch = text.match(/^(\d+)?\s*(b|kb|mb|gb|tb)$/);
|
||||
if (!memoryMatch) return 1;
|
||||
const blockCount = Number(memoryMatch[1] || 1);
|
||||
const unitName = memoryMatch[2];
|
||||
const factors = {
|
||||
b: 1,
|
||||
kb: 1024,
|
||||
mb: 1024 ** 2,
|
||||
gb: 1024 ** 3,
|
||||
tb: 1024 ** 4,
|
||||
};
|
||||
return blockCount * factors[unitName];
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes)) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const rounded = value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
|
||||
return `${rounded}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
if (ms === 0) return '0ms';
|
||||
if (ms % 60_000 === 0) return `${ms / 60_000}min`;
|
||||
if (ms % 1000 === 0) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function normalizedSettingValue(setting) {
|
||||
const number = Number(setting.setting);
|
||||
if (!Number.isFinite(number)) return `${setting.setting}${setting.unit || ''}`;
|
||||
const unit = String(setting.unit || '').trim();
|
||||
if (!unit) return String(setting.setting);
|
||||
if (/^(?:\d+)?\s*(?:b|kb|mb|gb|tb)$/i.test(unit)) {
|
||||
return formatBytes(number * pgUnitMultiplier(unit));
|
||||
}
|
||||
if (/^(?:ms|s|min)$/i.test(unit)) {
|
||||
return formatDuration(number * pgUnitMultiplier(unit));
|
||||
}
|
||||
return `${setting.setting}${unit}`;
|
||||
}
|
||||
|
||||
function explainSetting(setting) {
|
||||
const value = `${setting.setting}${setting.unit || ''}`;
|
||||
return {
|
||||
name: setting.name,
|
||||
value,
|
||||
value: normalizedSettingValue(setting),
|
||||
rawSetting: setting.setting,
|
||||
unit: setting.unit || null,
|
||||
source: setting.source,
|
||||
pendingRestart: setting.pending_restart,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluate(settingsRows) {
|
||||
function parseSettingNumber(setting) {
|
||||
if (!setting) return Number.NaN;
|
||||
const number = Number(setting.setting);
|
||||
return Number.isFinite(number) ? number : Number.NaN;
|
||||
}
|
||||
|
||||
function parseBytes(value) {
|
||||
if (typeof value === 'number') return value;
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return Number.NaN;
|
||||
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
|
||||
if (!match) return Number.NaN;
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2] || 'b';
|
||||
const factors = {
|
||||
b: 1,
|
||||
byte: 1,
|
||||
bytes: 1,
|
||||
kb: 1024,
|
||||
mb: 1024 ** 2,
|
||||
gb: 1024 ** 3,
|
||||
tb: 1024 ** 4,
|
||||
};
|
||||
return amount * (factors[unit] || Number.NaN);
|
||||
}
|
||||
|
||||
function parseDurationMs(value) {
|
||||
if (typeof value === 'number') return value;
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return Number.NaN;
|
||||
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
|
||||
if (!match) return Number.NaN;
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2] || 'ms';
|
||||
const factors = {
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
sec: 1000,
|
||||
secs: 1000,
|
||||
second: 1000,
|
||||
seconds: 1000,
|
||||
min: 60_000,
|
||||
mins: 60_000,
|
||||
minute: 60_000,
|
||||
minutes: 60_000,
|
||||
h: 3_600_000,
|
||||
hour: 3_600_000,
|
||||
hours: 3_600_000,
|
||||
};
|
||||
return amount * (factors[unit] || Number.NaN);
|
||||
}
|
||||
|
||||
function comparableValue(setting, rule) {
|
||||
if (!setting) return Number.NaN;
|
||||
if (rule.type === 'bytes') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
|
||||
if (rule.type === 'duration') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
|
||||
return parseSettingNumber(setting);
|
||||
}
|
||||
|
||||
function recommendedComparator(rule, key) {
|
||||
if (rule.type === 'bytes') return parseBytes(rule[key]);
|
||||
if (rule.type === 'duration') return parseDurationMs(rule[key]);
|
||||
return Number(rule[key]);
|
||||
}
|
||||
|
||||
function evaluateProfile(settingsRows, profile) {
|
||||
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const [name, rule] of Object.entries(profile.settings)) {
|
||||
const setting = byName.get(name);
|
||||
if (!setting) {
|
||||
failures.push(`${name} is missing from pg_settings evidence.`);
|
||||
continue;
|
||||
}
|
||||
if (setting.pending_restart) {
|
||||
failures.push(`${name} has pending_restart=true; restart PostgreSQL before capacity testing.`);
|
||||
}
|
||||
if (rule.exact !== undefined) {
|
||||
const actual = String(setting.setting || '').toLowerCase();
|
||||
if (actual !== String(rule.exact).toLowerCase()) {
|
||||
failures.push(`${name} should be ${rule.exact} for ${profile.label}, got ${setting.setting}.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (rule.min !== undefined) {
|
||||
const actual = comparableValue(setting, rule);
|
||||
const expected = recommendedComparator(rule, 'min');
|
||||
if (!Number.isFinite(actual) || actual < expected) {
|
||||
failures.push(`${name} is below ${rule.min} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
|
||||
}
|
||||
}
|
||||
if (rule.max !== undefined) {
|
||||
const actual = comparableValue(setting, rule);
|
||||
const expected = recommendedComparator(rule, 'max');
|
||||
if (!Number.isFinite(actual) || actual > expected) {
|
||||
failures.push(`${name} is above ${rule.max} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
|
||||
}
|
||||
}
|
||||
if (rule.recommended !== undefined && String(setting.source || '').toLowerCase() === 'default') {
|
||||
warnings.push(`${name} still comes from default; recommended ${profile.name} value is ${rule.recommended}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { failures, warnings };
|
||||
}
|
||||
|
||||
function evaluate(settingsRows, profile, options, pgStatStatements) {
|
||||
const byName = new Map(settingsRows.map(row => [row.name, row]));
|
||||
const warnings = [];
|
||||
const failures = [];
|
||||
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 || '');
|
||||
@@ -84,9 +257,19 @@ function evaluate(settingsRows) {
|
||||
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.');
|
||||
if (pgStatStatements?.unavailable) warnings.push('pg_stat_statements is unavailable; enable it before production capacity acceptance.');
|
||||
|
||||
const profileEvaluation = evaluateProfile(settingsRows, profile);
|
||||
warnings.push(...profileEvaluation.warnings);
|
||||
if (options.strict) {
|
||||
failures.push(...profileEvaluation.failures);
|
||||
if (pgStatStatements?.unavailable) failures.push('pg_stat_statements is unavailable in strict mode.');
|
||||
}
|
||||
|
||||
return {
|
||||
status: warnings.length ? 'warn' : 'pass',
|
||||
status: failures.length ? 'fail' : options.strict ? 'pass' : warnings.length ? 'warn' : 'pass',
|
||||
profile: profile.name,
|
||||
failures,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -99,9 +282,17 @@ function markdown(report) {
|
||||
'',
|
||||
`数据库:${report.databaseUrl}`,
|
||||
'',
|
||||
`调参 profile:${report.profile.label} (${report.profile.name})`,
|
||||
'',
|
||||
`评估:${report.evaluation.status}`,
|
||||
'',
|
||||
];
|
||||
if (report.evaluation.failures.length) {
|
||||
lines.push('## 阻断项');
|
||||
lines.push('');
|
||||
for (const failure of report.evaluation.failures) lines.push(`- ${failure}`);
|
||||
lines.push('');
|
||||
}
|
||||
if (report.evaluation.warnings.length) {
|
||||
lines.push('## 警告');
|
||||
lines.push('');
|
||||
@@ -158,7 +349,30 @@ function markdown(report) {
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function sqlQuote(value) {
|
||||
return String(value).replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function alterSystemSql(profile) {
|
||||
const lines = [
|
||||
`-- PostgreSQL tuning profile: ${profile.label}`,
|
||||
'-- Review current values and take a snapshot before applying this file.',
|
||||
'-- Run npm run perf:postgres:evidence -- --strict after reload/restart.',
|
||||
'',
|
||||
];
|
||||
for (const [name, rule] of Object.entries(profile.settings)) {
|
||||
lines.push(`alter system set ${name} = '${sqlQuote(rule.recommended)}';`);
|
||||
}
|
||||
lines.push('select pg_reload_conf();');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.printSql) {
|
||||
console.log(alterSystemSql(options.profileDefinition));
|
||||
return;
|
||||
}
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
|
||||
try {
|
||||
const settingsRaw = await query(
|
||||
@@ -169,7 +383,7 @@ async function main() {
|
||||
where name = any($1::text[])
|
||||
order by array_position($1::text[], name)
|
||||
`,
|
||||
[settingNames],
|
||||
[options.settingNames],
|
||||
);
|
||||
const activityByWait = await query(
|
||||
pool,
|
||||
@@ -231,7 +445,12 @@ async function main() {
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
databaseUrl: redactUrl(databaseUrl),
|
||||
evaluation: evaluate(settingsRaw),
|
||||
profile: {
|
||||
name: options.profileDefinition.name,
|
||||
label: options.profileDefinition.label,
|
||||
description: options.profileDefinition.description,
|
||||
},
|
||||
evaluation: evaluate(settingsRaw, options.profileDefinition, options, pgStatStatements),
|
||||
settings: settingsRaw.map(explainSetting),
|
||||
activityByWait,
|
||||
databaseStats,
|
||||
@@ -239,15 +458,32 @@ async function main() {
|
||||
largeRelations,
|
||||
pgStatStatements,
|
||||
};
|
||||
const pendingRestart = settingsRaw.filter(row => row.pending_restart).length;
|
||||
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}`);
|
||||
const payload = {
|
||||
status: report.evaluation.status,
|
||||
profile: report.profile.name,
|
||||
warnings: report.evaluation.warnings.length,
|
||||
failures: report.evaluation.failures.length,
|
||||
pgStatStatementsAvailable: !pgStatStatements?.unavailable,
|
||||
pendingRestart,
|
||||
jsonPath,
|
||||
mdPath,
|
||||
};
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
console.log(`[pg-evidence] status=${report.evaluation.status}`);
|
||||
for (const failure of report.evaluation.failures) console.log(`[pg-evidence] failure: ${failure}`);
|
||||
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}`);
|
||||
}
|
||||
if (options.strict && report.evaluation.failures.length > 0) process.exitCode = 1;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
@@ -130,6 +130,17 @@ assert.ok(
|
||||
'migration profile mismatch should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingPostgresTuning = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'postgres.tuning-evidence');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingPostgresTuning.status, 0, 'missing PostgreSQL tuning evidence should fail launch gate');
|
||||
assert.ok(
|
||||
missingPostgresTuning.payload.checks?.some(item => item.id === 'check.postgres.tuning-evidence' && item.status === 'blocker'),
|
||||
'missing PostgreSQL tuning evidence should be reported as a blocker',
|
||||
);
|
||||
|
||||
const slowBenchmark = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read');
|
||||
|
||||
@@ -20,6 +20,18 @@ const gateChecks = [
|
||||
commandIncludes: 'readiness:production:db',
|
||||
summary: { blocker: 0 },
|
||||
},
|
||||
{
|
||||
id: 'postgres.tuning-evidence',
|
||||
label: 'PostgreSQL 4c16g tuning evidence',
|
||||
commandIncludes: 'perf:postgres:evidence',
|
||||
summary: {
|
||||
status: 'pass',
|
||||
profile: { oneOf: ['shared-host', 'dedicated-db'] },
|
||||
failures: 0,
|
||||
pgStatStatementsAvailable: true,
|
||||
pendingRestart: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'auth.remote-smoke',
|
||||
label: 'Remote Supabase Auth/JWKS smoke',
|
||||
|
||||
Reference in New Issue
Block a user