@wpmoo/toolkit 0.9.26 → 0.9.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/dist/doctor.js +6 -266
- package/dist/postgres-diagnostics.js +646 -0
- package/docs/generated-environment-verification.md +11 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,12 +170,31 @@ npx @wpmoo/toolkit doctor --json --postgres
|
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
JSON output is optional; human-readable output remains the default.
|
|
173
|
-
`doctor --postgres`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
`doctor --postgres` runs read-only PostgreSQL diagnostics as advisory checks only; it
|
|
174
|
+
does not perform automatic tuning.
|
|
175
|
+
Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics
|
|
176
|
+
instead of being treated as successful checks.
|
|
177
|
+
|
|
178
|
+
Current advisory checks include:
|
|
179
|
+
|
|
180
|
+
- sessions currently running queries where `pg_stat_activity.state = 'active'`;
|
|
181
|
+
- connection utilization against `max_connections`;
|
|
182
|
+
- long transaction / idle-in-transaction warnings from `pg_stat_activity`;
|
|
183
|
+
- table health visibility (for example table and index bloat signals, index scan
|
|
184
|
+
efficiency, and vacuum-related blockers);
|
|
185
|
+
- optional unused index advisory output when index usage data is available;
|
|
186
|
+
- WAL and capacity visibility including WAL activity and disk-level pressure context;
|
|
187
|
+
- slow-query and query-plan readiness checks for common `log_min_duration_statement`
|
|
188
|
+
and `pg_stat_statements` prerequisites.
|
|
189
|
+
|
|
190
|
+
`npx @wpmoo/toolkit doctor --postgres` and
|
|
191
|
+
`npx @wpmoo/toolkit doctor --json --postgres` use the same checks, and the
|
|
192
|
+
JSON variant exposes a versioned PostgreSQL diagnostics contract.
|
|
193
|
+
|
|
177
194
|
`doctor --json --postgres` includes a structured `postgres` object for automation.
|
|
178
|
-
|
|
195
|
+
`doctor --json --postgres` keeps the JSON contract stable by versioning the
|
|
196
|
+
`postgres` payload; individual fields are optional so automation can safely handle
|
|
197
|
+
environments where PostgreSQL does not expose a metric.
|
|
179
198
|
|
|
180
199
|
## Release Artifacts
|
|
181
200
|
|
package/dist/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
|
|
|
5
5
|
import { dailyActionScripts } from './daily-actions.js';
|
|
6
6
|
import { defaultPostgresVersion } from './external-templates.js';
|
|
7
7
|
import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
|
|
8
|
+
import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
|
|
8
9
|
import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
9
10
|
const realCommandRunner = async (command, args, options) => {
|
|
10
11
|
const result = await execa(command, args, { cwd: options.cwd });
|
|
@@ -46,128 +47,6 @@ function isMetadataError(message) {
|
|
|
46
47
|
message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
|
|
47
48
|
}
|
|
48
49
|
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
49
|
-
const postgresDiagnosticQuery = `
|
|
50
|
-
WITH metrics(metric, value) AS (
|
|
51
|
-
SELECT 'database_count', count(*)::text
|
|
52
|
-
FROM pg_database
|
|
53
|
-
WHERE datistemplate = false
|
|
54
|
-
UNION ALL
|
|
55
|
-
SELECT 'active_connections', count(*)::text
|
|
56
|
-
FROM pg_stat_activity
|
|
57
|
-
WHERE datname IS NOT NULL
|
|
58
|
-
AND state = 'active'
|
|
59
|
-
UNION ALL
|
|
60
|
-
SELECT 'connection_count', count(*)::text
|
|
61
|
-
FROM pg_stat_activity
|
|
62
|
-
WHERE datname IS NOT NULL
|
|
63
|
-
UNION ALL
|
|
64
|
-
SELECT 'max_connections', COALESCE(
|
|
65
|
-
(SELECT setting FROM pg_settings WHERE name = 'max_connections'),
|
|
66
|
-
'unavailable'
|
|
67
|
-
)
|
|
68
|
-
UNION ALL
|
|
69
|
-
SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
|
|
70
|
-
FROM pg_database
|
|
71
|
-
WHERE datistemplate = false
|
|
72
|
-
UNION ALL
|
|
73
|
-
SELECT 'largest_database_name', COALESCE(
|
|
74
|
-
(
|
|
75
|
-
SELECT datname
|
|
76
|
-
FROM pg_database
|
|
77
|
-
WHERE datistemplate = false
|
|
78
|
-
ORDER BY pg_database_size(datname) DESC, datname
|
|
79
|
-
LIMIT 1
|
|
80
|
-
),
|
|
81
|
-
'unavailable'
|
|
82
|
-
)
|
|
83
|
-
UNION ALL
|
|
84
|
-
SELECT 'largest_database_size_bytes', COALESCE(
|
|
85
|
-
(
|
|
86
|
-
SELECT pg_database_size(datname)::text
|
|
87
|
-
FROM pg_database
|
|
88
|
-
WHERE datistemplate = false
|
|
89
|
-
ORDER BY pg_database_size(datname) DESC, datname
|
|
90
|
-
LIMIT 1
|
|
91
|
-
),
|
|
92
|
-
'0'
|
|
93
|
-
)
|
|
94
|
-
UNION ALL
|
|
95
|
-
SELECT 'slow_query_logging', COALESCE(
|
|
96
|
-
(SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
|
|
97
|
-
'unavailable'
|
|
98
|
-
)
|
|
99
|
-
UNION ALL
|
|
100
|
-
SELECT 'pg_stat_statements',
|
|
101
|
-
CASE
|
|
102
|
-
WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
|
|
103
|
-
WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
|
|
104
|
-
ELSE 'unavailable'
|
|
105
|
-
END
|
|
106
|
-
UNION ALL
|
|
107
|
-
SELECT 'pg_stat_statements_available_version', COALESCE(
|
|
108
|
-
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
|
|
109
|
-
'unavailable'
|
|
110
|
-
)
|
|
111
|
-
UNION ALL
|
|
112
|
-
SELECT 'pg_stat_statements_installed_version', COALESCE(
|
|
113
|
-
(SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
|
|
114
|
-
''
|
|
115
|
-
)
|
|
116
|
-
UNION ALL
|
|
117
|
-
SELECT 'shared_preload_libraries', COALESCE(
|
|
118
|
-
(SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
|
|
119
|
-
'unavailable'
|
|
120
|
-
)
|
|
121
|
-
UNION ALL
|
|
122
|
-
SELECT 'shared_buffers', COALESCE(
|
|
123
|
-
(SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
|
|
124
|
-
'unavailable'
|
|
125
|
-
)
|
|
126
|
-
)
|
|
127
|
-
SELECT metric || '|' || value
|
|
128
|
-
FROM metrics
|
|
129
|
-
ORDER BY CASE metric
|
|
130
|
-
WHEN 'database_count' THEN 1
|
|
131
|
-
WHEN 'active_connections' THEN 2
|
|
132
|
-
WHEN 'connection_count' THEN 3
|
|
133
|
-
WHEN 'max_connections' THEN 4
|
|
134
|
-
WHEN 'total_database_size_bytes' THEN 5
|
|
135
|
-
WHEN 'largest_database_name' THEN 6
|
|
136
|
-
WHEN 'largest_database_size_bytes' THEN 7
|
|
137
|
-
WHEN 'slow_query_logging' THEN 8
|
|
138
|
-
WHEN 'pg_stat_statements' THEN 9
|
|
139
|
-
WHEN 'pg_stat_statements_available_version' THEN 10
|
|
140
|
-
WHEN 'pg_stat_statements_installed_version' THEN 11
|
|
141
|
-
WHEN 'shared_preload_libraries' THEN 12
|
|
142
|
-
WHEN 'shared_buffers' THEN 13
|
|
143
|
-
ELSE 99
|
|
144
|
-
END;
|
|
145
|
-
`.trim();
|
|
146
|
-
const postgresDiagnosticKeys = [
|
|
147
|
-
'database_count',
|
|
148
|
-
'active_connections',
|
|
149
|
-
'connection_count',
|
|
150
|
-
'max_connections',
|
|
151
|
-
'total_database_size_bytes',
|
|
152
|
-
'largest_database_name',
|
|
153
|
-
'largest_database_size_bytes',
|
|
154
|
-
'slow_query_logging',
|
|
155
|
-
'pg_stat_statements',
|
|
156
|
-
'pg_stat_statements_available_version',
|
|
157
|
-
'pg_stat_statements_installed_version',
|
|
158
|
-
'shared_preload_libraries',
|
|
159
|
-
'shared_buffers',
|
|
160
|
-
];
|
|
161
|
-
const requiredPostgresDiagnosticKeys = [
|
|
162
|
-
'database_count',
|
|
163
|
-
'active_connections',
|
|
164
|
-
'connection_count',
|
|
165
|
-
'max_connections',
|
|
166
|
-
'total_database_size_bytes',
|
|
167
|
-
'slow_query_logging',
|
|
168
|
-
'pg_stat_statements',
|
|
169
|
-
'shared_buffers',
|
|
170
|
-
];
|
|
171
50
|
function parsePostgresMajorFromValue(value) {
|
|
172
51
|
if (!value)
|
|
173
52
|
return undefined;
|
|
@@ -178,139 +57,8 @@ function parsePostgresMajorFromValue(value) {
|
|
|
178
57
|
const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
|
|
179
58
|
return match?.[1];
|
|
180
59
|
}
|
|
181
|
-
function parsePostgresDiagnostics(output) {
|
|
182
|
-
const diagnostics = {};
|
|
183
|
-
const allowedKeys = new Set(postgresDiagnosticKeys);
|
|
184
|
-
for (const rawLine of output.split(/\r?\n/u)) {
|
|
185
|
-
const line = rawLine.trim();
|
|
186
|
-
if (!line)
|
|
187
|
-
continue;
|
|
188
|
-
const separatorIndex = line.indexOf('|');
|
|
189
|
-
if (separatorIndex === -1)
|
|
190
|
-
continue;
|
|
191
|
-
const key = line.slice(0, separatorIndex).trim();
|
|
192
|
-
const value = line.slice(separatorIndex + 1).trim();
|
|
193
|
-
if (allowedKeys.has(key) && value) {
|
|
194
|
-
diagnostics[key] = value;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return diagnostics;
|
|
198
|
-
}
|
|
199
|
-
function renderPostgresDiagnostics(diagnostics) {
|
|
200
|
-
const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
201
|
-
const parts = postgresDiagnosticKeys.flatMap((key) => {
|
|
202
|
-
const value = diagnostics[key];
|
|
203
|
-
const rendered = value ? [`${key}=${value}`] : [];
|
|
204
|
-
if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
|
|
205
|
-
rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
|
|
206
|
-
}
|
|
207
|
-
return rendered;
|
|
208
|
-
});
|
|
209
|
-
return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
|
|
210
|
-
}
|
|
211
|
-
function missingPostgresDiagnosticKeys(diagnostics) {
|
|
212
|
-
return requiredPostgresDiagnosticKeys.filter((key) => !diagnostics[key]);
|
|
213
|
-
}
|
|
214
|
-
function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
|
|
215
|
-
return Object.keys(diagnostics).length === 0
|
|
216
|
-
? 'no diagnostic rows returned'
|
|
217
|
-
: `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
|
|
218
|
-
}
|
|
219
|
-
function integerDiagnostic(value) {
|
|
220
|
-
if (!value || !/^\d+$/u.test(value)) {
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
223
|
-
return Number.parseInt(value, 10);
|
|
224
|
-
}
|
|
225
|
-
function malformedPostgresDiagnosticKeys(diagnostics) {
|
|
226
|
-
const numericKeys = [
|
|
227
|
-
'database_count',
|
|
228
|
-
'active_connections',
|
|
229
|
-
'connection_count',
|
|
230
|
-
'max_connections',
|
|
231
|
-
'total_database_size_bytes',
|
|
232
|
-
'largest_database_size_bytes',
|
|
233
|
-
];
|
|
234
|
-
return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
|
|
235
|
-
}
|
|
236
|
-
function postgresConnectionUtilizationPct(diagnostics) {
|
|
237
|
-
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
238
|
-
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
239
|
-
if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
|
|
240
|
-
return undefined;
|
|
241
|
-
}
|
|
242
|
-
return Math.round((connectionCount / maxConnections) * 100);
|
|
243
|
-
}
|
|
244
|
-
function postgresConnectionUtilizationWarning(diagnostics) {
|
|
245
|
-
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
246
|
-
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
247
|
-
const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
248
|
-
if (connectionCount === undefined ||
|
|
249
|
-
maxConnections === undefined ||
|
|
250
|
-
utilizationPct === undefined ||
|
|
251
|
-
utilizationPct < 80) {
|
|
252
|
-
return undefined;
|
|
253
|
-
}
|
|
254
|
-
return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
|
|
255
|
-
}
|
|
256
|
-
function postgresSlowQueryLoggingWarning(diagnostics) {
|
|
257
|
-
const slowQueryLogging = diagnostics.slow_query_logging?.trim();
|
|
258
|
-
if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
|
|
262
|
-
}
|
|
263
|
-
function postgresExtensionVisibilityWarning(diagnostics) {
|
|
264
|
-
if (diagnostics.pg_stat_statements === 'available' &&
|
|
265
|
-
diagnostics.pg_stat_statements_available_version &&
|
|
266
|
-
!diagnostics.pg_stat_statements_installed_version) {
|
|
267
|
-
return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
|
|
268
|
-
}
|
|
269
|
-
return undefined;
|
|
270
|
-
}
|
|
271
|
-
function structuredPostgresDiagnostics(diagnostics) {
|
|
272
|
-
const structured = {};
|
|
273
|
-
const databaseCount = integerDiagnostic(diagnostics.database_count);
|
|
274
|
-
const activeConnections = integerDiagnostic(diagnostics.active_connections);
|
|
275
|
-
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
276
|
-
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
277
|
-
const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
278
|
-
const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
|
|
279
|
-
const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
|
|
280
|
-
if (databaseCount !== undefined)
|
|
281
|
-
structured.databaseCount = databaseCount;
|
|
282
|
-
if (activeConnections !== undefined)
|
|
283
|
-
structured.activeConnections = activeConnections;
|
|
284
|
-
if (connectionCount !== undefined)
|
|
285
|
-
structured.connectionCount = connectionCount;
|
|
286
|
-
if (maxConnections !== undefined)
|
|
287
|
-
structured.maxConnections = maxConnections;
|
|
288
|
-
if (connectionUtilizationPct !== undefined)
|
|
289
|
-
structured.connectionUtilizationPct = connectionUtilizationPct;
|
|
290
|
-
if (totalDatabaseSizeBytes !== undefined)
|
|
291
|
-
structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
|
|
292
|
-
if (diagnostics.largest_database_name)
|
|
293
|
-
structured.largestDatabaseName = diagnostics.largest_database_name;
|
|
294
|
-
if (largestDatabaseSizeBytes !== undefined)
|
|
295
|
-
structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
|
|
296
|
-
if (diagnostics.slow_query_logging)
|
|
297
|
-
structured.slowQueryLogging = diagnostics.slow_query_logging;
|
|
298
|
-
if (diagnostics.pg_stat_statements)
|
|
299
|
-
structured.pgStatStatements = diagnostics.pg_stat_statements;
|
|
300
|
-
if (diagnostics.pg_stat_statements_available_version) {
|
|
301
|
-
structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
|
|
302
|
-
}
|
|
303
|
-
if (diagnostics.pg_stat_statements_installed_version) {
|
|
304
|
-
structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
|
|
305
|
-
}
|
|
306
|
-
if (diagnostics.shared_preload_libraries)
|
|
307
|
-
structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
|
|
308
|
-
if (diagnostics.shared_buffers)
|
|
309
|
-
structured.sharedBuffers = diagnostics.shared_buffers;
|
|
310
|
-
return structured;
|
|
311
|
-
}
|
|
312
60
|
async function readPostgresDiagnostics(target, runner) {
|
|
313
|
-
const queryLiteral = JSON.stringify(
|
|
61
|
+
const queryLiteral = JSON.stringify(POSTGRES_DIAGNOSTICS_QUERY);
|
|
314
62
|
const command = [
|
|
315
63
|
`query=${queryLiteral}`,
|
|
316
64
|
'. ./scripts/lib.sh >/dev/null',
|
|
@@ -716,21 +464,11 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
716
464
|
}
|
|
717
465
|
report.postgres = {
|
|
718
466
|
requested: true,
|
|
467
|
+
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
719
468
|
available: true,
|
|
720
469
|
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
721
470
|
};
|
|
722
|
-
|
|
723
|
-
if (connectionUtilizationWarning) {
|
|
724
|
-
warnings.push(connectionUtilizationWarning);
|
|
725
|
-
}
|
|
726
|
-
const slowQueryLoggingWarning = postgresSlowQueryLoggingWarning(postgresDiagnostics);
|
|
727
|
-
if (slowQueryLoggingWarning) {
|
|
728
|
-
warnings.push(slowQueryLoggingWarning);
|
|
729
|
-
}
|
|
730
|
-
const extensionVisibilityWarning = postgresExtensionVisibilityWarning(postgresDiagnostics);
|
|
731
|
-
if (extensionVisibilityWarning) {
|
|
732
|
-
warnings.push(extensionVisibilityWarning);
|
|
733
|
-
}
|
|
471
|
+
warnings.push(...postgresPostgresWarnings(postgresDiagnostics));
|
|
734
472
|
}
|
|
735
473
|
else {
|
|
736
474
|
const warning = malformedKeys.length > 0
|
|
@@ -739,6 +477,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
739
477
|
warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
|
|
740
478
|
report.postgres = {
|
|
741
479
|
requested: true,
|
|
480
|
+
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
742
481
|
available: false,
|
|
743
482
|
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
744
483
|
warning,
|
|
@@ -750,6 +489,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
750
489
|
warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
|
|
751
490
|
report.postgres = {
|
|
752
491
|
requested: true,
|
|
492
|
+
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
753
493
|
available: false,
|
|
754
494
|
diagnostics: {},
|
|
755
495
|
warning,
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
export const POSTGRES_DIAGNOSTICS_CONTRACT_VERSION = 2;
|
|
2
|
+
export const POSTGRES_DIAGNOSTICS_QUERY = `
|
|
3
|
+
WITH
|
|
4
|
+
transaction_health AS (
|
|
5
|
+
SELECT
|
|
6
|
+
COALESCE(
|
|
7
|
+
COUNT(*) FILTER (
|
|
8
|
+
WHERE state = 'active'
|
|
9
|
+
AND xact_start IS NOT NULL
|
|
10
|
+
AND now() - xact_start > interval '5 minutes'
|
|
11
|
+
),
|
|
12
|
+
0
|
|
13
|
+
)::text AS long_transaction_count,
|
|
14
|
+
COALESCE(
|
|
15
|
+
MAX(
|
|
16
|
+
EXTRACT(EPOCH FROM now() - xact_start)
|
|
17
|
+
) FILTER (
|
|
18
|
+
WHERE state = 'active'
|
|
19
|
+
AND xact_start IS NOT NULL
|
|
20
|
+
AND now() - xact_start > interval '5 minutes'
|
|
21
|
+
),
|
|
22
|
+
0
|
|
23
|
+
)::bigint::text AS oldest_long_transaction_age_seconds,
|
|
24
|
+
COALESCE(
|
|
25
|
+
COUNT(*) FILTER (
|
|
26
|
+
WHERE state = 'idle in transaction'
|
|
27
|
+
AND xact_start IS NOT NULL
|
|
28
|
+
AND now() - xact_start > interval '5 minutes'
|
|
29
|
+
),
|
|
30
|
+
0
|
|
31
|
+
)::text AS idle_in_transaction_count,
|
|
32
|
+
COALESCE(
|
|
33
|
+
MAX(
|
|
34
|
+
EXTRACT(EPOCH FROM now() - xact_start)
|
|
35
|
+
) FILTER (
|
|
36
|
+
WHERE state = 'idle in transaction'
|
|
37
|
+
AND xact_start IS NOT NULL
|
|
38
|
+
AND now() - xact_start > interval '5 minutes'
|
|
39
|
+
),
|
|
40
|
+
0
|
|
41
|
+
)::bigint::text AS oldest_idle_in_transaction_age_seconds
|
|
42
|
+
FROM pg_stat_activity
|
|
43
|
+
WHERE datname IS NOT NULL
|
|
44
|
+
),
|
|
45
|
+
table_health AS (
|
|
46
|
+
SELECT
|
|
47
|
+
COALESCE(
|
|
48
|
+
COUNT(*) FILTER (
|
|
49
|
+
WHERE n_live_tup > 0
|
|
50
|
+
AND n_dead_tup::numeric > 0
|
|
51
|
+
AND (n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0)) > 0.2
|
|
52
|
+
),
|
|
53
|
+
0
|
|
54
|
+
)::text AS table_health_risky_table_count,
|
|
55
|
+
COALESCE(
|
|
56
|
+
COUNT(*) FILTER (
|
|
57
|
+
WHERE n_dead_tup::numeric > 0
|
|
58
|
+
AND (n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0)) > 0.2
|
|
59
|
+
),
|
|
60
|
+
0
|
|
61
|
+
)::text AS table_health_requires_vacuum_count,
|
|
62
|
+
COALESCE(
|
|
63
|
+
COUNT(*) FILTER (WHERE n_mod_since_analyze > 1000),
|
|
64
|
+
0
|
|
65
|
+
)::text AS table_health_requires_analyze_count
|
|
66
|
+
FROM pg_stat_user_tables
|
|
67
|
+
),
|
|
68
|
+
table_health_ranked AS (
|
|
69
|
+
SELECT
|
|
70
|
+
schemaname,
|
|
71
|
+
relname,
|
|
72
|
+
CASE
|
|
73
|
+
WHEN n_live_tup > 0 THEN (n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0))
|
|
74
|
+
ELSE 0
|
|
75
|
+
END AS dead_tuple_ratio,
|
|
76
|
+
n_dead_tup AS dead_tup_count,
|
|
77
|
+
n_live_tup,
|
|
78
|
+
ROW_NUMBER() OVER (
|
|
79
|
+
ORDER BY
|
|
80
|
+
CASE WHEN n_live_tup > 0 THEN n_dead_tup::numeric / NULLIF(n_live_tup::numeric, 0) ELSE 0 END DESC,
|
|
81
|
+
n_dead_tup DESC,
|
|
82
|
+
n_live_tup DESC
|
|
83
|
+
) AS rn
|
|
84
|
+
FROM pg_stat_user_tables
|
|
85
|
+
),
|
|
86
|
+
top_table_health AS (
|
|
87
|
+
SELECT
|
|
88
|
+
COALESCE(
|
|
89
|
+
(SELECT format('%I.%I', schemaname, relname) FROM table_health_ranked WHERE rn = 1),
|
|
90
|
+
'unavailable'
|
|
91
|
+
) AS table_health_top_risky_table,
|
|
92
|
+
COALESCE(
|
|
93
|
+
(
|
|
94
|
+
SELECT dead_tuple_ratio
|
|
95
|
+
FROM table_health_ranked
|
|
96
|
+
WHERE rn = 1
|
|
97
|
+
)::text,
|
|
98
|
+
'0'
|
|
99
|
+
) AS table_health_top_risky_dead_tuple_ratio,
|
|
100
|
+
COALESCE(
|
|
101
|
+
(
|
|
102
|
+
SELECT dead_tup_count
|
|
103
|
+
FROM table_health_ranked
|
|
104
|
+
WHERE rn = 1
|
|
105
|
+
)::text,
|
|
106
|
+
'0'
|
|
107
|
+
) AS table_health_top_risky_dead_tuple_count
|
|
108
|
+
FROM (SELECT 1) AS _top_table_health_row
|
|
109
|
+
),
|
|
110
|
+
index_health AS (
|
|
111
|
+
SELECT
|
|
112
|
+
COALESCE(
|
|
113
|
+
COUNT(*) FILTER (
|
|
114
|
+
WHERE NOT idx.indisunique
|
|
115
|
+
AND i.idx_scan = 0
|
|
116
|
+
AND i.idx_tup_fetch = 0
|
|
117
|
+
),
|
|
118
|
+
0
|
|
119
|
+
)::text AS unused_index_candidates_count
|
|
120
|
+
FROM pg_stat_user_indexes i
|
|
121
|
+
LEFT JOIN pg_index idx ON idx.indexrelid = i.indexrelid
|
|
122
|
+
WHERE i.schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
123
|
+
),
|
|
124
|
+
wal_health AS (
|
|
125
|
+
SELECT
|
|
126
|
+
COALESCE((SELECT setting FROM pg_settings WHERE name = 'wal_level'), 'unavailable')::text AS wal_level,
|
|
127
|
+
COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode,
|
|
128
|
+
COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS wal_file_count,
|
|
129
|
+
COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text AS wal_directory_size_bytes
|
|
130
|
+
),
|
|
131
|
+
capacity_health AS (
|
|
132
|
+
SELECT
|
|
133
|
+
COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text AS default_tablespace_size_bytes,
|
|
134
|
+
COALESCE(
|
|
135
|
+
(SELECT SUM(n_tup_ins + n_tup_upd + n_tup_del) FROM pg_stat_database WHERE datname IS NOT NULL),
|
|
136
|
+
0
|
|
137
|
+
)::text AS database_write_activity_rows
|
|
138
|
+
)
|
|
139
|
+
SELECT metric || '|' || value
|
|
140
|
+
FROM (
|
|
141
|
+
SELECT 'database_count'::text AS metric, count(*)::text AS value
|
|
142
|
+
FROM pg_database
|
|
143
|
+
WHERE datistemplate = false
|
|
144
|
+
UNION ALL
|
|
145
|
+
SELECT 'active_connections', COUNT(*)::text
|
|
146
|
+
FROM pg_stat_activity
|
|
147
|
+
WHERE datname IS NOT NULL
|
|
148
|
+
AND state = 'active'
|
|
149
|
+
UNION ALL
|
|
150
|
+
SELECT 'connection_count', COUNT(*)::text
|
|
151
|
+
FROM pg_stat_activity
|
|
152
|
+
WHERE datname IS NOT NULL
|
|
153
|
+
UNION ALL
|
|
154
|
+
SELECT 'max_connections', COALESCE(
|
|
155
|
+
(SELECT setting FROM pg_settings WHERE name = 'max_connections'),
|
|
156
|
+
'unavailable'
|
|
157
|
+
)::text
|
|
158
|
+
UNION ALL
|
|
159
|
+
SELECT 'total_database_size_bytes', COALESCE(SUM(pg_database_size(datname)), 0)::text
|
|
160
|
+
FROM pg_database
|
|
161
|
+
WHERE datistemplate = false
|
|
162
|
+
UNION ALL
|
|
163
|
+
SELECT 'largest_database_name', COALESCE(
|
|
164
|
+
(
|
|
165
|
+
SELECT datname
|
|
166
|
+
FROM pg_database
|
|
167
|
+
WHERE datistemplate = false
|
|
168
|
+
ORDER BY pg_database_size(datname) DESC, datname
|
|
169
|
+
LIMIT 1
|
|
170
|
+
),
|
|
171
|
+
'unavailable'
|
|
172
|
+
)
|
|
173
|
+
UNION ALL
|
|
174
|
+
SELECT 'largest_database_size_bytes', COALESCE(
|
|
175
|
+
(
|
|
176
|
+
SELECT pg_database_size(datname)::text
|
|
177
|
+
FROM pg_database
|
|
178
|
+
WHERE datistemplate = false
|
|
179
|
+
ORDER BY pg_database_size(datname) DESC, datname
|
|
180
|
+
LIMIT 1
|
|
181
|
+
),
|
|
182
|
+
'0'
|
|
183
|
+
)
|
|
184
|
+
UNION ALL
|
|
185
|
+
SELECT 'slow_query_logging', COALESCE(
|
|
186
|
+
(SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'log_min_duration_statement'),
|
|
187
|
+
'unavailable'
|
|
188
|
+
)
|
|
189
|
+
UNION ALL
|
|
190
|
+
SELECT 'pg_stat_statements',
|
|
191
|
+
CASE
|
|
192
|
+
WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
|
|
193
|
+
WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
|
|
194
|
+
ELSE 'unavailable'
|
|
195
|
+
END
|
|
196
|
+
UNION ALL
|
|
197
|
+
SELECT 'pg_stat_statements_available_version', COALESCE(
|
|
198
|
+
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
|
|
199
|
+
'unavailable'
|
|
200
|
+
)
|
|
201
|
+
UNION ALL
|
|
202
|
+
SELECT 'pg_stat_statements_installed_version', COALESCE(
|
|
203
|
+
(SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
|
|
204
|
+
''
|
|
205
|
+
)
|
|
206
|
+
UNION ALL
|
|
207
|
+
SELECT 'shared_preload_libraries', COALESCE(
|
|
208
|
+
(SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
|
|
209
|
+
'unavailable'
|
|
210
|
+
)
|
|
211
|
+
UNION ALL
|
|
212
|
+
SELECT 'shared_buffers', COALESCE(
|
|
213
|
+
(SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
|
|
214
|
+
'unavailable'
|
|
215
|
+
)
|
|
216
|
+
UNION ALL
|
|
217
|
+
SELECT 'long_transaction_count', long_transaction_count FROM transaction_health
|
|
218
|
+
UNION ALL
|
|
219
|
+
SELECT 'oldest_long_transaction_age_seconds', oldest_long_transaction_age_seconds FROM transaction_health
|
|
220
|
+
UNION ALL
|
|
221
|
+
SELECT 'idle_in_transaction_count', idle_in_transaction_count FROM transaction_health
|
|
222
|
+
UNION ALL
|
|
223
|
+
SELECT 'oldest_idle_in_transaction_age_seconds', oldest_idle_in_transaction_age_seconds FROM transaction_health
|
|
224
|
+
UNION ALL
|
|
225
|
+
SELECT 'table_health_risky_table_count', table_health_risky_table_count FROM table_health
|
|
226
|
+
UNION ALL
|
|
227
|
+
SELECT 'table_health_requires_vacuum_count', table_health_requires_vacuum_count FROM table_health
|
|
228
|
+
UNION ALL
|
|
229
|
+
SELECT 'table_health_requires_analyze_count', table_health_requires_analyze_count FROM table_health
|
|
230
|
+
UNION ALL
|
|
231
|
+
SELECT 'table_health_top_risky_table', table_health_top_risky_table FROM top_table_health
|
|
232
|
+
UNION ALL
|
|
233
|
+
SELECT 'table_health_top_risky_dead_tuple_ratio', table_health_top_risky_dead_tuple_ratio FROM top_table_health
|
|
234
|
+
UNION ALL
|
|
235
|
+
SELECT 'table_health_top_risky_dead_tuple_count', table_health_top_risky_dead_tuple_count FROM top_table_health
|
|
236
|
+
UNION ALL
|
|
237
|
+
SELECT 'unused_index_candidates_count', unused_index_candidates_count FROM index_health
|
|
238
|
+
UNION ALL
|
|
239
|
+
SELECT 'wal_level', wal_level FROM wal_health
|
|
240
|
+
UNION ALL
|
|
241
|
+
SELECT 'wal_archive_mode', wal_archive_mode FROM wal_health
|
|
242
|
+
UNION ALL
|
|
243
|
+
SELECT 'wal_file_count', wal_file_count FROM wal_health
|
|
244
|
+
UNION ALL
|
|
245
|
+
SELECT 'wal_directory_size_bytes', wal_directory_size_bytes FROM wal_health
|
|
246
|
+
UNION ALL
|
|
247
|
+
SELECT 'default_tablespace_size_bytes', default_tablespace_size_bytes FROM capacity_health
|
|
248
|
+
UNION ALL
|
|
249
|
+
SELECT 'database_write_activity_rows', database_write_activity_rows FROM capacity_health
|
|
250
|
+
) metrics
|
|
251
|
+
ORDER BY metric;
|
|
252
|
+
`.trim();
|
|
253
|
+
export const POSTGRES_DIAGNOSTIC_KEYS = [
|
|
254
|
+
'database_count',
|
|
255
|
+
'active_connections',
|
|
256
|
+
'connection_count',
|
|
257
|
+
'max_connections',
|
|
258
|
+
'total_database_size_bytes',
|
|
259
|
+
'largest_database_name',
|
|
260
|
+
'largest_database_size_bytes',
|
|
261
|
+
'slow_query_logging',
|
|
262
|
+
'pg_stat_statements',
|
|
263
|
+
'pg_stat_statements_available_version',
|
|
264
|
+
'pg_stat_statements_installed_version',
|
|
265
|
+
'shared_preload_libraries',
|
|
266
|
+
'shared_buffers',
|
|
267
|
+
'long_transaction_count',
|
|
268
|
+
'oldest_long_transaction_age_seconds',
|
|
269
|
+
'idle_in_transaction_count',
|
|
270
|
+
'oldest_idle_in_transaction_age_seconds',
|
|
271
|
+
'table_health_risky_table_count',
|
|
272
|
+
'table_health_requires_vacuum_count',
|
|
273
|
+
'table_health_requires_analyze_count',
|
|
274
|
+
'table_health_top_risky_table',
|
|
275
|
+
'table_health_top_risky_dead_tuple_ratio',
|
|
276
|
+
'table_health_top_risky_dead_tuple_count',
|
|
277
|
+
'unused_index_candidates_count',
|
|
278
|
+
'wal_level',
|
|
279
|
+
'wal_archive_mode',
|
|
280
|
+
'wal_file_count',
|
|
281
|
+
'wal_directory_size_bytes',
|
|
282
|
+
'default_tablespace_size_bytes',
|
|
283
|
+
'database_write_activity_rows',
|
|
284
|
+
];
|
|
285
|
+
export const REQUIRED_POSTGRES_DIAGNOSTIC_KEYS = [
|
|
286
|
+
'database_count',
|
|
287
|
+
'active_connections',
|
|
288
|
+
'connection_count',
|
|
289
|
+
'max_connections',
|
|
290
|
+
'total_database_size_bytes',
|
|
291
|
+
'slow_query_logging',
|
|
292
|
+
'pg_stat_statements',
|
|
293
|
+
'shared_buffers',
|
|
294
|
+
];
|
|
295
|
+
const INTEGER_POSTGRES_DIAGNOSTIC_KEYS = [
|
|
296
|
+
'database_count',
|
|
297
|
+
'active_connections',
|
|
298
|
+
'connection_count',
|
|
299
|
+
'max_connections',
|
|
300
|
+
'total_database_size_bytes',
|
|
301
|
+
'largest_database_size_bytes',
|
|
302
|
+
'long_transaction_count',
|
|
303
|
+
'oldest_long_transaction_age_seconds',
|
|
304
|
+
'idle_in_transaction_count',
|
|
305
|
+
'oldest_idle_in_transaction_age_seconds',
|
|
306
|
+
'table_health_risky_table_count',
|
|
307
|
+
'table_health_requires_vacuum_count',
|
|
308
|
+
'table_health_requires_analyze_count',
|
|
309
|
+
'table_health_top_risky_dead_tuple_count',
|
|
310
|
+
'unused_index_candidates_count',
|
|
311
|
+
'wal_file_count',
|
|
312
|
+
'wal_directory_size_bytes',
|
|
313
|
+
'default_tablespace_size_bytes',
|
|
314
|
+
'database_write_activity_rows',
|
|
315
|
+
];
|
|
316
|
+
const DECIMAL_POSTGRES_DIAGNOSTIC_KEYS = [
|
|
317
|
+
'table_health_top_risky_dead_tuple_ratio',
|
|
318
|
+
];
|
|
319
|
+
export const LONG_TRANSACTION_AGE_WARNING_SECONDS = 5 * 60;
|
|
320
|
+
export const LONG_TRANSACTION_COUNT_WARNING_THRESHOLD = 3;
|
|
321
|
+
export const IDLE_IN_TRANSACTION_AGE_WARNING_SECONDS = 5 * 60;
|
|
322
|
+
export const IDLE_IN_TRANSACTION_COUNT_WARNING_THRESHOLD = 2;
|
|
323
|
+
export const TABLE_HEALTH_DEAD_TUPLE_RATIO_WARNING = 0.2;
|
|
324
|
+
export const TABLE_HEALTH_DEAD_TUPLE_COUNT_WARNING = 1000;
|
|
325
|
+
export const UNUSED_INDEX_CANDIDATES_WARNING = 20;
|
|
326
|
+
export const WAL_DIRECTORY_SIZE_WARNING_BYTES = 5 * 1024 * 1024 * 1024;
|
|
327
|
+
export const TOTAL_DATABASE_SIZE_WARNING_BYTES = 50 * 1024 * 1024 * 1024;
|
|
328
|
+
const allowedKeys = new Set(POSTGRES_DIAGNOSTIC_KEYS);
|
|
329
|
+
export function parsePostgresDiagnostics(output) {
|
|
330
|
+
const diagnostics = {};
|
|
331
|
+
for (const rawLine of output.split(/\r?\n/iu)) {
|
|
332
|
+
const line = rawLine.trim();
|
|
333
|
+
if (!line) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const separatorIndex = line.indexOf('|');
|
|
337
|
+
if (separatorIndex === -1) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
341
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
342
|
+
if (!value || !allowedKeys.has(key)) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
diagnostics[key] = value;
|
|
346
|
+
}
|
|
347
|
+
return diagnostics;
|
|
348
|
+
}
|
|
349
|
+
export function renderPostgresDiagnostics(diagnostics) {
|
|
350
|
+
const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
351
|
+
const parts = POSTGRES_DIAGNOSTIC_KEYS.flatMap((key) => {
|
|
352
|
+
const value = diagnostics[key];
|
|
353
|
+
const rendered = value ? [`${key}=${value}`] : [];
|
|
354
|
+
if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
|
|
355
|
+
rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
|
|
356
|
+
}
|
|
357
|
+
return rendered;
|
|
358
|
+
});
|
|
359
|
+
return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
|
|
360
|
+
}
|
|
361
|
+
export function missingPostgresDiagnosticKeys(diagnostics) {
|
|
362
|
+
return REQUIRED_POSTGRES_DIAGNOSTIC_KEYS.filter((key) => diagnostics[key] === undefined);
|
|
363
|
+
}
|
|
364
|
+
function integerDiagnostic(value) {
|
|
365
|
+
if (!value || !/^\d+$/u.test(value)) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
return Number.parseInt(value, 10);
|
|
369
|
+
}
|
|
370
|
+
function decimalDiagnostic(value) {
|
|
371
|
+
if (!value || !/^\d+(?:\.\d+)?$/u.test(value)) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
return Number.parseFloat(value);
|
|
375
|
+
}
|
|
376
|
+
export function malformedPostgresDiagnosticKeys(diagnostics) {
|
|
377
|
+
const malformed = [];
|
|
378
|
+
for (const key of INTEGER_POSTGRES_DIAGNOSTIC_KEYS) {
|
|
379
|
+
const value = diagnostics[key];
|
|
380
|
+
if (value !== undefined && integerDiagnostic(value) === undefined) {
|
|
381
|
+
malformed.push(key);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const key of DECIMAL_POSTGRES_DIAGNOSTIC_KEYS) {
|
|
385
|
+
const value = diagnostics[key];
|
|
386
|
+
if (value !== undefined && decimalDiagnostic(value) === undefined) {
|
|
387
|
+
malformed.push(key);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return malformed;
|
|
391
|
+
}
|
|
392
|
+
export function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
|
|
393
|
+
return Object.keys(diagnostics).length === 0
|
|
394
|
+
? 'no diagnostic rows returned'
|
|
395
|
+
: `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
|
|
396
|
+
}
|
|
397
|
+
export function postgresConnectionUtilizationPct(diagnostics) {
|
|
398
|
+
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
399
|
+
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
400
|
+
if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
return Math.round((connectionCount / maxConnections) * 100);
|
|
404
|
+
}
|
|
405
|
+
export function postgresConnectionUtilizationWarning(diagnostics) {
|
|
406
|
+
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
407
|
+
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
408
|
+
const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
409
|
+
if (connectionCount === undefined ||
|
|
410
|
+
maxConnections === undefined ||
|
|
411
|
+
utilizationPct === undefined ||
|
|
412
|
+
utilizationPct < 80) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
|
|
416
|
+
}
|
|
417
|
+
export function postgresSlowQueryLoggingWarning(diagnostics) {
|
|
418
|
+
const slowQueryLogging = diagnostics.slow_query_logging?.trim();
|
|
419
|
+
if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
|
|
423
|
+
}
|
|
424
|
+
export function postgresExtensionVisibilityWarning(diagnostics) {
|
|
425
|
+
if (diagnostics.pg_stat_statements === 'available' &&
|
|
426
|
+
diagnostics.pg_stat_statements_available_version &&
|
|
427
|
+
!diagnostics.pg_stat_statements_installed_version) {
|
|
428
|
+
return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
|
|
429
|
+
}
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
export function postgresLongTransactionWarning(diagnostics) {
|
|
433
|
+
const longTransactionCount = integerDiagnostic(diagnostics.long_transaction_count);
|
|
434
|
+
const oldestAgeSeconds = integerDiagnostic(diagnostics.oldest_long_transaction_age_seconds);
|
|
435
|
+
if (longTransactionCount === undefined ||
|
|
436
|
+
oldestAgeSeconds === undefined ||
|
|
437
|
+
longTransactionCount < LONG_TRANSACTION_COUNT_WARNING_THRESHOLD ||
|
|
438
|
+
oldestAgeSeconds < LONG_TRANSACTION_AGE_WARNING_SECONDS) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
return `PostgreSQL has ${longTransactionCount} long transaction(s) running longer than ${LONG_TRANSACTION_AGE_WARNING_SECONDS / 60} minutes (oldest ${oldestAgeSeconds}s).`;
|
|
442
|
+
}
|
|
443
|
+
export function postgresIdleInTransactionWarning(diagnostics) {
|
|
444
|
+
const idleTransactionCount = integerDiagnostic(diagnostics.idle_in_transaction_count);
|
|
445
|
+
const oldestAgeSeconds = integerDiagnostic(diagnostics.oldest_idle_in_transaction_age_seconds);
|
|
446
|
+
if (idleTransactionCount === undefined ||
|
|
447
|
+
oldestAgeSeconds === undefined ||
|
|
448
|
+
idleTransactionCount < IDLE_IN_TRANSACTION_COUNT_WARNING_THRESHOLD ||
|
|
449
|
+
oldestAgeSeconds < IDLE_IN_TRANSACTION_AGE_WARNING_SECONDS) {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
return `PostgreSQL has ${idleTransactionCount} transaction(s) idle in transaction for longer than ${IDLE_IN_TRANSACTION_AGE_WARNING_SECONDS / 60} minutes (oldest ${oldestAgeSeconds}s).`;
|
|
453
|
+
}
|
|
454
|
+
export function postgresTableHealthWarning(diagnostics) {
|
|
455
|
+
const riskyTables = integerDiagnostic(diagnostics.table_health_risky_table_count);
|
|
456
|
+
const vacuumNeeded = integerDiagnostic(diagnostics.table_health_requires_vacuum_count);
|
|
457
|
+
const analyzeNeeded = integerDiagnostic(diagnostics.table_health_requires_analyze_count);
|
|
458
|
+
const deadTupleRatio = decimalDiagnostic(diagnostics.table_health_top_risky_dead_tuple_ratio);
|
|
459
|
+
const deadTupleCount = integerDiagnostic(diagnostics.table_health_top_risky_dead_tuple_count);
|
|
460
|
+
if (riskyTables === undefined ||
|
|
461
|
+
vacuumNeeded === undefined ||
|
|
462
|
+
analyzeNeeded === undefined ||
|
|
463
|
+
deadTupleRatio === undefined ||
|
|
464
|
+
deadTupleCount === undefined) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
if (deadTupleRatio < TABLE_HEALTH_DEAD_TUPLE_RATIO_WARNING || deadTupleCount < TABLE_HEALTH_DEAD_TUPLE_COUNT_WARNING) {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
const maxRiskyRatioPercent = Math.round(deadTupleRatio * 100);
|
|
471
|
+
return `PostgreSQL table health risk: ${riskyTables} table(s) have high dead-tuple ratio; ${vacuumNeeded} table(s) need vacuum and ${analyzeNeeded} need analyze. Top risk: ${diagnostics.table_health_top_risky_table ?? 'unavailable'} (${maxRiskyRatioPercent}% dead tuples, ${deadTupleCount} dead tuples).`;
|
|
472
|
+
}
|
|
473
|
+
export function postgresUnusedIndexWarning(diagnostics) {
|
|
474
|
+
const candidates = integerDiagnostic(diagnostics.unused_index_candidates_count);
|
|
475
|
+
if (candidates === undefined || candidates < UNUSED_INDEX_CANDIDATES_WARNING) {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
return `PostgreSQL has ${candidates} unused index candidates with no index scans. Review indexing strategy before adding new indexes.`;
|
|
479
|
+
}
|
|
480
|
+
export function postgresWalWarning(diagnostics) {
|
|
481
|
+
const walSizeBytes = integerDiagnostic(diagnostics.wal_directory_size_bytes);
|
|
482
|
+
if (walSizeBytes !== undefined && walSizeBytes >= WAL_DIRECTORY_SIZE_WARNING_BYTES) {
|
|
483
|
+
return `PostgreSQL WAL visibility warning: WAL directory is ${walSizeBytes} bytes, which is high and can increase disk pressure.`;
|
|
484
|
+
}
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
export function postgresCapacityWarning(diagnostics) {
|
|
488
|
+
const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
|
|
489
|
+
if (totalDatabaseSizeBytes === undefined || totalDatabaseSizeBytes < TOTAL_DATABASE_SIZE_WARNING_BYTES) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
return `PostgreSQL capacity risk: total database size is ${totalDatabaseSizeBytes} bytes. Monitor growth and WAL retention settings before disk becomes constrained.`;
|
|
493
|
+
}
|
|
494
|
+
export function postgresPostgresWarnings(diagnostics) {
|
|
495
|
+
const warnings = [];
|
|
496
|
+
const connectionWarning = postgresConnectionUtilizationWarning(diagnostics);
|
|
497
|
+
if (connectionWarning) {
|
|
498
|
+
warnings.push(connectionWarning);
|
|
499
|
+
}
|
|
500
|
+
const slowQueryWarning = postgresSlowQueryLoggingWarning(diagnostics);
|
|
501
|
+
if (slowQueryWarning) {
|
|
502
|
+
warnings.push(slowQueryWarning);
|
|
503
|
+
}
|
|
504
|
+
const extensionWarning = postgresExtensionVisibilityWarning(diagnostics);
|
|
505
|
+
if (extensionWarning) {
|
|
506
|
+
warnings.push(extensionWarning);
|
|
507
|
+
}
|
|
508
|
+
const longTransactionWarning = postgresLongTransactionWarning(diagnostics);
|
|
509
|
+
if (longTransactionWarning) {
|
|
510
|
+
warnings.push(longTransactionWarning);
|
|
511
|
+
}
|
|
512
|
+
const idleTransactionWarning = postgresIdleInTransactionWarning(diagnostics);
|
|
513
|
+
if (idleTransactionWarning) {
|
|
514
|
+
warnings.push(idleTransactionWarning);
|
|
515
|
+
}
|
|
516
|
+
const tableHealthWarning = postgresTableHealthWarning(diagnostics);
|
|
517
|
+
if (tableHealthWarning) {
|
|
518
|
+
warnings.push(tableHealthWarning);
|
|
519
|
+
}
|
|
520
|
+
const indexWarning = postgresUnusedIndexWarning(diagnostics);
|
|
521
|
+
if (indexWarning) {
|
|
522
|
+
warnings.push(indexWarning);
|
|
523
|
+
}
|
|
524
|
+
const walWarning = postgresWalWarning(diagnostics);
|
|
525
|
+
if (walWarning) {
|
|
526
|
+
warnings.push(walWarning);
|
|
527
|
+
}
|
|
528
|
+
const capacityWarning = postgresCapacityWarning(diagnostics);
|
|
529
|
+
if (capacityWarning) {
|
|
530
|
+
warnings.push(capacityWarning);
|
|
531
|
+
}
|
|
532
|
+
return warnings;
|
|
533
|
+
}
|
|
534
|
+
export function structuredPostgresDiagnostics(diagnostics) {
|
|
535
|
+
const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
536
|
+
const databaseCount = integerDiagnostic(diagnostics.database_count);
|
|
537
|
+
const activeConnections = integerDiagnostic(diagnostics.active_connections);
|
|
538
|
+
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
539
|
+
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
540
|
+
const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
|
|
541
|
+
const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
|
|
542
|
+
const tableHealthRiskyTableCount = integerDiagnostic(diagnostics.table_health_risky_table_count);
|
|
543
|
+
const tableHealthRequiresVacuumCount = integerDiagnostic(diagnostics.table_health_requires_vacuum_count);
|
|
544
|
+
const tableHealthRequiresAnalyzeCount = integerDiagnostic(diagnostics.table_health_requires_analyze_count);
|
|
545
|
+
const tableHealthTopRiskyDeadTupleRatio = decimalDiagnostic(diagnostics.table_health_top_risky_dead_tuple_ratio);
|
|
546
|
+
const tableHealthTopRiskyDeadTupleCount = integerDiagnostic(diagnostics.table_health_top_risky_dead_tuple_count);
|
|
547
|
+
const longTransactionCount = integerDiagnostic(diagnostics.long_transaction_count);
|
|
548
|
+
const oldestLongTransactionAgeSeconds = integerDiagnostic(diagnostics.oldest_long_transaction_age_seconds);
|
|
549
|
+
const idleInTransactionCount = integerDiagnostic(diagnostics.idle_in_transaction_count);
|
|
550
|
+
const oldestIdleInTransactionAgeSeconds = integerDiagnostic(diagnostics.oldest_idle_in_transaction_age_seconds);
|
|
551
|
+
const unusedIndexCandidatesCount = integerDiagnostic(diagnostics.unused_index_candidates_count);
|
|
552
|
+
const walFileCount = integerDiagnostic(diagnostics.wal_file_count);
|
|
553
|
+
const walDirectorySizeBytes = integerDiagnostic(diagnostics.wal_directory_size_bytes);
|
|
554
|
+
const defaultTablespaceSizeBytes = integerDiagnostic(diagnostics.default_tablespace_size_bytes);
|
|
555
|
+
const databaseWriteActivityRows = integerDiagnostic(diagnostics.database_write_activity_rows);
|
|
556
|
+
const structured = {
|
|
557
|
+
schemaVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
558
|
+
};
|
|
559
|
+
if (databaseCount !== undefined)
|
|
560
|
+
structured.databaseCount = databaseCount;
|
|
561
|
+
if (activeConnections !== undefined)
|
|
562
|
+
structured.activeConnections = activeConnections;
|
|
563
|
+
if (connectionCount !== undefined)
|
|
564
|
+
structured.connectionCount = connectionCount;
|
|
565
|
+
if (maxConnections !== undefined)
|
|
566
|
+
structured.maxConnections = maxConnections;
|
|
567
|
+
if (connectionUtilizationPct !== undefined)
|
|
568
|
+
structured.connectionUtilizationPct = connectionUtilizationPct;
|
|
569
|
+
if (totalDatabaseSizeBytes !== undefined)
|
|
570
|
+
structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
|
|
571
|
+
if (diagnostics.largest_database_name) {
|
|
572
|
+
structured.largestDatabaseName = diagnostics.largest_database_name;
|
|
573
|
+
}
|
|
574
|
+
if (largestDatabaseSizeBytes !== undefined)
|
|
575
|
+
structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
|
|
576
|
+
if (diagnostics.slow_query_logging) {
|
|
577
|
+
structured.slowQueryLogging = diagnostics.slow_query_logging;
|
|
578
|
+
}
|
|
579
|
+
if (diagnostics.pg_stat_statements) {
|
|
580
|
+
structured.pgStatStatements = diagnostics.pg_stat_statements;
|
|
581
|
+
}
|
|
582
|
+
if (diagnostics.pg_stat_statements_available_version) {
|
|
583
|
+
structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
|
|
584
|
+
}
|
|
585
|
+
if (diagnostics.pg_stat_statements_installed_version) {
|
|
586
|
+
structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
|
|
587
|
+
}
|
|
588
|
+
if (diagnostics.shared_preload_libraries) {
|
|
589
|
+
structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
|
|
590
|
+
}
|
|
591
|
+
if (diagnostics.shared_buffers) {
|
|
592
|
+
structured.sharedBuffers = diagnostics.shared_buffers;
|
|
593
|
+
}
|
|
594
|
+
if (longTransactionCount !== undefined) {
|
|
595
|
+
structured.longTransactionCount = longTransactionCount;
|
|
596
|
+
}
|
|
597
|
+
if (oldestLongTransactionAgeSeconds !== undefined) {
|
|
598
|
+
structured.oldestLongTransactionAgeSeconds = oldestLongTransactionAgeSeconds;
|
|
599
|
+
}
|
|
600
|
+
if (idleInTransactionCount !== undefined) {
|
|
601
|
+
structured.idleInTransactionCount = idleInTransactionCount;
|
|
602
|
+
}
|
|
603
|
+
if (oldestIdleInTransactionAgeSeconds !== undefined) {
|
|
604
|
+
structured.oldestIdleInTransactionAgeSeconds = oldestIdleInTransactionAgeSeconds;
|
|
605
|
+
}
|
|
606
|
+
if (tableHealthRiskyTableCount !== undefined) {
|
|
607
|
+
structured.tableHealthRiskyTableCount = tableHealthRiskyTableCount;
|
|
608
|
+
}
|
|
609
|
+
if (tableHealthRequiresVacuumCount !== undefined) {
|
|
610
|
+
structured.tableHealthRequiresVacuumCount = tableHealthRequiresVacuumCount;
|
|
611
|
+
}
|
|
612
|
+
if (tableHealthRequiresAnalyzeCount !== undefined) {
|
|
613
|
+
structured.tableHealthRequiresAnalyzeCount = tableHealthRequiresAnalyzeCount;
|
|
614
|
+
}
|
|
615
|
+
if (diagnostics.table_health_top_risky_table) {
|
|
616
|
+
structured.tableHealthTopRiskyTable = diagnostics.table_health_top_risky_table;
|
|
617
|
+
}
|
|
618
|
+
if (tableHealthTopRiskyDeadTupleRatio !== undefined) {
|
|
619
|
+
structured.tableHealthTopRiskyDeadTupleRatio = tableHealthTopRiskyDeadTupleRatio;
|
|
620
|
+
}
|
|
621
|
+
if (tableHealthTopRiskyDeadTupleCount !== undefined) {
|
|
622
|
+
structured.tableHealthTopRiskyDeadTupleCount = tableHealthTopRiskyDeadTupleCount;
|
|
623
|
+
}
|
|
624
|
+
if (unusedIndexCandidatesCount !== undefined) {
|
|
625
|
+
structured.unusedIndexCandidatesCount = unusedIndexCandidatesCount;
|
|
626
|
+
}
|
|
627
|
+
if (diagnostics.wal_level) {
|
|
628
|
+
structured.walLevel = diagnostics.wal_level;
|
|
629
|
+
}
|
|
630
|
+
if (diagnostics.wal_archive_mode) {
|
|
631
|
+
structured.walArchiveMode = diagnostics.wal_archive_mode;
|
|
632
|
+
}
|
|
633
|
+
if (walFileCount !== undefined) {
|
|
634
|
+
structured.walFileCount = walFileCount;
|
|
635
|
+
}
|
|
636
|
+
if (walDirectorySizeBytes !== undefined) {
|
|
637
|
+
structured.walDirectorySizeBytes = walDirectorySizeBytes;
|
|
638
|
+
}
|
|
639
|
+
if (defaultTablespaceSizeBytes !== undefined) {
|
|
640
|
+
structured.defaultTablespaceSizeBytes = defaultTablespaceSizeBytes;
|
|
641
|
+
}
|
|
642
|
+
if (databaseWriteActivityRows !== undefined) {
|
|
643
|
+
structured.databaseWriteActivityRows = databaseWriteActivityRows;
|
|
644
|
+
}
|
|
645
|
+
return structured;
|
|
646
|
+
}
|
|
@@ -23,7 +23,7 @@ not validate staging or production deployments.
|
|
|
23
23
|
| Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/toolkit doctor` or `./moo doctor` |
|
|
24
24
|
| Doctor safe fixes | Safe file-level fixes are applied only with `--fix`, then doctor runs again and reports any remaining manual issues. | `npx @wpmoo/toolkit doctor --fix` |
|
|
25
25
|
| Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths and can normalize safe targets with `--fix`. | `npx @wpmoo/toolkit doctor`, `npx @wpmoo/toolkit doctor --fix` |
|
|
26
|
-
| PostgreSQL diagnostics | Optional read-only database health
|
|
26
|
+
| PostgreSQL diagnostics | Optional read-only, advisory-only database health and performance diagnostics (no automatic tuning), covering active/idle-in-transaction sessions, table health signals, WAL/capacity context, unused-index hints, and slow-query readiness. JSON mode emits a versioned PostgreSQL contract with optional fields when a metric is unavailable. | `npx @wpmoo/toolkit doctor --postgres`, `npx @wpmoo/toolkit doctor --json --postgres` |
|
|
27
27
|
| Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/toolkit add-repo ...`, `npx @wpmoo/toolkit remove-repo ...` |
|
|
28
28
|
| Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/toolkit source list`, `npx @wpmoo/toolkit source sync` |
|
|
29
29
|
| Module add/remove | Module skeleton files include manifest, model, access CSV, explicit view XML, action/menu XML, post-install test scaffold, and selected source repo registration. Existing scaffold files are not overwritten. | `npx @wpmoo/toolkit add-module ...`, `npx @wpmoo/toolkit remove-module ...` |
|
|
@@ -76,16 +76,16 @@ is involved, use PostgreSQL upgrade tooling first.
|
|
|
76
76
|
|
|
77
77
|
Use `doctor --postgres` when the database container is running and you want
|
|
78
78
|
read-only PostgreSQL diagnostics. The check uses fixed diagnostic queries for
|
|
79
|
-
database count, sessions currently running queries where
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
check.
|
|
85
|
-
|
|
86
|
-
`postgres`
|
|
87
|
-
|
|
88
|
-
|
|
79
|
+
database count, sessions currently running queries where `pg_stat_activity.state`
|
|
80
|
+
is `active`, long transactions / idle-in-transaction sessions, table health
|
|
81
|
+
signals, unused index advisor signals, WAL and capacity visibility, and
|
|
82
|
+
slow-query logging readiness (`log_min_duration_statement` and
|
|
83
|
+
`pg_stat_statements` visibility). If the database is unavailable, doctor reports
|
|
84
|
+
a warning instead of failing the whole environment check.
|
|
85
|
+
|
|
86
|
+
`doctor --json --postgres` keeps output stable by exposing a versioned PostgreSQL
|
|
87
|
+
diagnostics contract. The contract is intentionally permissive: fields are optional
|
|
88
|
+
and omitted or marked unavailable when a running database does not expose them.
|
|
89
89
|
|
|
90
90
|
## Safe reset policy
|
|
91
91
|
|